home / skills / gilbertopsantosjr / fullstacknextjs / gs-zod-validation

gs-zod-validation skill

/skills/gs-zod-validation

This skill helps you enforce input shape with Zod in the presentation layer, while business rules live in entities and server actions use schemas.

npx playbooks add skill gilbertopsantosjr/fullstacknextjs --skill gs-zod-validation

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
4.6 KB
---
name: gs-zod-validation
description: "Guide for Zod validation schemas in Clean Architecture. Zod validates input SHAPE in Presentation layer; business rules belong in Entity.validate(). Use when creating input schemas for server actions."
---

# Zod Validation (Presentation Layer Only)

## Where Validation Belongs

| Validation Type | Location | Example |
|-----------------|----------|---------|
| **Input Shape** | Zod Schema (Presentation) | Required fields, string/number types |
| **Format** | Zod Schema | Email format, ULID format, URL |
| **Basic Constraints** | Zod Schema | min/max length, positive numbers |
| **Business Rules** | Entity.validate() (Domain) | Name uniqueness, status transitions |
| **Complex Rules** | Use Case (Application) | Cross-entity validation |

## Schema Location

```
src/features/<feature>/schemas/
└── <entity>-schemas.ts
```

## Input Schemas (Shape Only)

```typescript
// src/features/category/schemas/category-schemas.ts
import { z } from 'zod'

// Shape validation - NOT business rules
export const CreateCategorySchema = z.object({
  name: z.string().min(1, 'Name is required'),
  description: z.string().optional(),
})

export const UpdateCategorySchema = z.object({
  id: z.string().ulid(),
  name: z.string().min(1).optional(),
  description: z.string().optional(),
})

export const GetCategorySchema = z.object({
  id: z.string().ulid(),
})

export const ListCategoriesSchema = z.object({
  limit: z.coerce.number().min(1).max(100).optional(),
  cursor: z.string().optional(),
  status: z.enum(['active', 'inactive', 'archived']).optional(),
})

export type CreateCategoryInput = z.infer<typeof CreateCategorySchema>
export type UpdateCategoryInput = z.infer<typeof UpdateCategorySchema>
```

## Common Validators

```typescript
// ID formats
z.string().ulid()
z.string().uuid()

// Strings
z.string().min(1, 'Required')
z.string().max(255)
z.string().email()
z.string().url()
z.string().trim()

// Numbers
z.coerce.number()           // String to number
z.number().positive()
z.number().min(0).max(100)

// Optional/Nullable
z.string().optional()       // string | undefined
z.string().nullable()       // string | null

// Enums
z.enum(['active', 'inactive', 'archived'])

// Arrays
z.array(z.string()).min(1)
```

## Business Rules in Entity (NOT Zod)

### ❌ Bad: Business Rules in Zod
```typescript
export const CreateCategorySchema = z.object({
  name: z.string()
    .refine(name => !name.includes('banned'), 'Contains banned words')  // Business rule!
    .refine(name => !name.startsWith('_'), 'Invalid format'),           // Business rule!
})
```

### ✅ Good: Shape in Zod, Rules in Entity
```typescript
// Zod - shape only
export const CreateCategorySchema = z.object({
  name: z.string().min(1),
})

// Entity - business rules
export class Category {
  private validate(): void {
    if (this.props.name.includes('banned')) {
      throw new CategoryValidationException('Name contains banned words')
    }
    if (this.props.name.startsWith('_')) {
      throw new CategoryValidationException('Name cannot start with underscore')
    }
  }
}
```

## Zod in Server Actions

```typescript
'use server'
import { authedProcedure } from '@/lib/zsa'
import { CreateCategorySchema } from '../schemas/category-schemas'

export const createCategoryAction = authedProcedure
  .createServerAction()
  .input(CreateCategorySchema)  // Validates shape before handler
  .handler(async ({ input, ctx }) => {
    const useCase = DIContainer.resolve<CreateCategoryUseCase>(TOKENS.CreateCategoryUseCase)
    return useCase.execute({ ...input, userId: ctx.user.id })
  })
```

## Validation Error Handling

```typescript
// Client side
const [data, err] = await execute({ name: '' })
if (err) {
  // err.name = "ZodError"
  // err.fieldErrors = { name: ["Name is required"] }
}
```

## Anti-Patterns

| Anti-Pattern | Correct Approach |
|--------------|------------------|
| Business rules in Zod (.refine) | Entity.validate() |
| Async validation in Zod | Use Case |
| Database checks in Zod | Repository |

## Detection Commands

```bash
# Business logic in schemas
grep -rn "refine\|superRefine" src/features/*/schemas/

# Complex validation (should be in Entity)
grep -rn "banned\|forbidden\|unique\|exists" src/features/*/schemas/
```

## Summary

| Layer | What to Validate |
|-------|------------------|
| **Presentation (Zod)** | Shape, format, basic constraints |
| **Domain (Entity)** | Business rules, invariants |
| **Application (Use Case)** | Cross-entity rules |

## References

- Server Actions: `skills/nextjs-server-actions/SKILL.md`
- Create Domain Module: `skills/create-domain-module/SKILL.md`

Overview

This skill guides building Zod validation schemas within a Clean Architecture setup. It clarifies that Zod should validate input shape, format, and basic constraints in the Presentation layer, while business rules belong in Entity.validate() or the Use Case layer. It also shows schema placement, common validators, error handling, and detection commands for anti-patterns.

How this skill works

The guide defines a clear separation of concerns: Zod schemas live in feature-specific schemas files and only assert shape, types, and simple constraints. Business invariants and domain rules are implemented inside domain entities or use cases. It includes examples for schema structure, common validators, server-action usage, and patterns to avoid (like .refine for business logic).

When to use it

  • When creating input schemas for server actions or API endpoints
  • When you need to enforce input shape, formats, and basic constraints
  • When organizing feature code under src/features/<feature>/schemas/
  • When you want consistent client-side and server-side input validation
  • When separating domain rules from presentation concerns

Best practices

  • Keep Zod schemas limited to shape, format, and simple constraints (min/max, email, IDs)
  • Implement business rules and invariants inside Entity.validate() or domain services
  • Put cross-entity or async checks in Use Case layer, not in Zod (no DB calls in schemas)
  • Store schemas in src/features/<feature>/schemas/ and export input types with z.infer
  • Use detection grep commands to find .refine/.superRefine and move logic to domain

Example use cases

  • CreateCategorySchema to validate name and description shape before use case execution
  • ListCategoriesSchema to coerce and constrain pagination and filters
  • Server action that calls .input(CreateSchema) to validate shape before handler
  • Entity.validate() that enforces unique-name or status-transition rules
  • Use Case that performs cross-entity or database existence checks

FAQ

Why not put business rules in Zod with .refine?

Business rules belong to the domain to keep presentation decoupled and to allow reuse, testing, and richer error handling; .refine mixes concerns and can hide important domain logic.

Where should async or DB validations live?

Place async checks and repository/database validations in the Use Case or repository layer, not in Zod schemas.