# How to Implement Validation Logic in Controllers Using Zod and Chanfana

> Learn how to implement validation logic in controllers using Zod and Chanfana. Define a Zod schema and use this.getValidatedData() for automatic request parsing and validation.

- Repository: [Lucas Zanoni⠀⠀⠀⠀⠀ ⠀╱|、 ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ (˚ˎ 。7 ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ |、˜〵 ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ じしˍ,)ノ/tcc](https://github.com/castrozan/tcc)
- Tags: how-to-guide
- Published: 2026-02-27

---

**Implement validation logic in controllers by defining a Zod schema on the controller's `schema` property and calling `this.getValidatedData()` to automatically parse and validate incoming requests against that schema.**

The `castrozan/tcc` repository demonstrates a clean approach to request validation by co-locating validation rules directly within controller classes. This pattern leverages the **chanfana** framework's `OpenAPIRoute` base class combined with **Zod** schemas to ensure type-safe, self-documenting API endpoints.

## Understanding the Validation Architecture in Castrozan/TCC

The validation system in this repository relies on four core components working together to enforce data integrity at the controller layer.

**`OpenAPIRoute`** (from *chanfana*) serves as the base class for all API controllers. It automatically reads the `schema` property defined on each controller class and wires together request validation, OpenAPI metadata generation, and response description.

**`z` (Zod)** provides the declarative schema language used to define validation rules. The repository uses Zod to specify required fields, string lengths, URL formats, nullable types, and custom error messages directly in the controller.

**`getValidatedData<T>()`** is the method inherited from `OpenAPIRoute` that executes the validation logic. When called inside the controller's `handle` method, it parses the incoming request body against the Zod schema defined in `schema.request.body` and returns a strongly-typed object.

**`withErrorHandling`** is a decorator imported from `presentation/decorators` that wraps the `handle` method. It catches validation errors thrown by Zod or runtime errors from the use case layer and converts them into proper HTTP 400 or 500 responses.

## How to Implement Validation Logic in Controllers Step by Step

Follow this implementation pattern to add validation to any controller in the `castrozan/tcc` architecture.

### Step 1 – Define the Zod Schema

Create a Zod object schema that describes the expected structure of the request body. Include validation constraints such as `.min()`, `.url()`, or `.optional()` to enforce business rules.

```typescript
import { z } from 'zod';

const CreateProfessionalSchema = z.object({
  name: z.string().min(1, { message: 'Name is required' }),
  email: z.string().email({ message: 'Invalid email format' }),
  phone: z.string().optional(),
  specialty: z.string().min(1)
});

```

### Step 2 – Extend OpenAPIRoute and Configure the Schema Property

Create a controller class that extends `OpenAPIRoute` from *chanfana*. Assign the Zod schema to the `schema.request.body.content['application/json'].schema` property to register it with the framework.

```typescript
import { OpenAPIRoute } from 'chanfana';
import { withErrorHandling } from 'presentation/decorators';

export class CreateProfessionalController extends OpenAPIRoute {
  schema = {
    tags: ['Professionals'],
    summary: 'Create a new professional',
    request: {
      body: {
        content: {
          'application/json': {
            schema: CreateProfessionalSchema
          }
        }
      }
    },
    responses: {
      '201': {
        description: 'Professional created successfully'
      },
      '400': {
        description: 'Validation error'
      }
    }
  };

  // Handle method implementation follows...
}

```

### Step 3 – Validate Incoming Requests with getValidatedData

Inside the `handle` method, call `this.getValidatedData<typeof this.schema>()` to parse and validate the request. Destructure the validated fields from the returned object and pass them to your use case.

```typescript
  @withErrorHandling
  async handle(): Promise<object> {
    // Validates against CreateProfessionalSchema and returns typed data
    const data = await this.getValidatedData<typeof this.schema>();
    
    const { name, email, phone, specialty } = data.body;
    
    const useCase = new CreateProfessionalUseCase(professionalRepository);
    const professional = await useCase.execute({
      name,
      email,
      phone,
      specialty
    });
    
    return {
      success: true,
      result: {
        id: professional.id,
        name: professional.name
      }
    };
  }
}

```

### Step 4 – Handle Errors with the withErrorHandling Decorator

Apply the `@withErrorHandling` decorator to the `handle` method. This ensures that Zod validation errors (such as missing required fields or invalid email formats) are caught and returned as HTTP 400 responses with descriptive error messages, while unexpected runtime errors return as 500 responses.

## Complete Example: Creating a Professional Controller

Here is the full implementation from [`professionals-dummy-app/src/presentation/controllers/professionals/CreateProfessionalController.ts`](https://github.com/castrozan/tcc/blob/main/professionals-dummy-app/src/presentation/controllers/professionals/CreateProfessionalController.ts), demonstrating how validation logic in controllers integrates with the broader application architecture:

```typescript
import { Bool, OpenAPIRoute } from 'chanfana';
import { withErrorHandling } from 'presentation/decorators';
import { z } from 'zod';
import { CreateProfessionalUseCase } from 'application/use-cases/professionals/CreateProfessionalUseCase';
import professionalRepository from 'infrastructure/database/repositories/professional';

export class CreateProfessionalController extends OpenAPIRoute {
  schema = {
    tags: ['Professionals'],
    summary: 'Create a new professional',
    security: [{ bearerAuth: [] }],
    request: {
      body: {
        content: {
          'application/json': {
            schema: z.object({
              name: z.string().min(1, { message: 'Name is required' }),
              email: z.string().email({ message: 'Invalid email format' }),
              phone: z.string().optional(),
              specialty: z.string().min(1, { message: 'Specialty is required' })
            })
          }
        }
      }
    },
    responses: {
      '201': {
        description: 'Created successfully',
        content: {
          'application/json': {
            schema: z.object({
              success: Bool(),
              result: z.object({
                id: z.number(),
                name: z.string(),
                email: z.string()
              })
            })
          }
        }
      },
      '400': {
        description: 'Validation error'
      }
    }
  };

  @withErrorHandling
  async handle(): Promise<object> {
    const data = await this.getValidatedData<typeof this.schema>();
    const { name, email, phone, specialty } = data.body;

    const useCase = new CreateProfessionalUseCase(professionalRepository);
    const professional = await useCase.execute({ name, email, phone, specialty });

    return {
      success: true,
      result: {
        id: professional.id,
        name: professional.name,
        email: professional.email
      }
    };
  }
}

```

## Reusing Validation Schemas Across Multiple Controllers

To maintain **DRY** (Don't Repeat Yourself) principles when you implement validation logic in controllers, extract common Zod schemas into separate files and import them where needed. This approach is particularly useful when multiple controllers share similar data structures, such as `Create` and `Update` operations.

Create a dedicated validation file:

```typescript
// src/presentation/validation/professionalSchemas.ts
import { z } from 'zod';

export const ProfessionalBaseSchema = z.object({
  name: z.string().min(1, { message: 'Name is required' }),
  email: z.string().email({ message: 'Invalid email format' }),
  phone: z.string().optional(),
  specialty: z.string().min(1, { message: 'Specialty is required' })
});

export const CreateProfessionalSchema = ProfessionalBaseSchema;

export const UpdateProfessionalSchema = z.object({
  id: z.number().min(1, { message: 'ID is required' }),
  data: ProfessionalBaseSchema.partial()
});

```

Then reference these schemas in your controllers:

```typescript
import { CreateProfessionalSchema } from 'presentation/validation/professionalSchemas';

export class CreateProfessionalController extends OpenAPIRoute {
  schema = {
    request: {
      body: {
        content: {
          'application/json': { schema: CreateProfessionalSchema }
        }
      }
    }
    // ... rest of schema
  };
  // ...
}

```

This pattern ensures consistency across your API surface while keeping the validation logic in controllers declarative and maintainable.

## Summary

- **Implement validation logic in controllers** by extending `OpenAPIRoute` from the *chanfana* framework and defining a Zod schema on the `schema` property.
- Use `this.getValidatedData<typeof this.schema>()` inside the `handle` method to automatically parse and validate incoming requests against the defined schema.
- Apply the `@withErrorHandling` decorator to ensure validation errors return as HTTP 400 responses with descriptive messages.
- Extract reusable Zod schemas into separate validation files to maintain DRY principles across multiple controllers.
- Reference implementations in [`CreateProfessionalController.ts`](https://github.com/castrozan/tcc/blob/main/CreateProfessionalController.ts), [`UpdateProfessionalController.ts`](https://github.com/castrozan/tcc/blob/main/UpdateProfessionalController.ts), and [`CreateEquipmentController.ts`](https://github.com/castrozan/tcc/blob/main/CreateEquipmentController.ts) for complete working examples.

## Frequently Asked Questions

### What is Chanfana and how does it handle validation?

Chanfana is a framework that provides the `OpenAPIRoute` base class used throughout the `castrozan/tcc` repository. It handles validation by reading the `schema` property defined on each controller class, automatically parsing incoming requests against the Zod schema specified in `schema.request.body`, and providing the `getValidatedData()` method to return strongly-typed, validated data to the controller's business logic.

### How does getValidatedData() work in the controller?

The `getValidatedData()` method is inherited from `OpenAPIRoute` and is called within the controller's `handle` method. It extracts the raw request body, validates it against the Zod schema defined in the controller's `schema.request.body.content['application/json'].schema` property, and returns a typed object containing the validated data. If validation fails, it throws an error that is caught by the `@withErrorHandling` decorator and returned as an HTTP 400 response.

### Can I reuse Zod schemas across different controllers?

Yes, you can extract Zod schemas into separate files and import them into multiple controllers. This is recommended for maintaining DRY principles when multiple endpoints share similar data structures. Create a dedicated validation file (e.g., [`src/presentation/validation/professionalSchemas.ts`](https://github.com/castrozan/tcc/blob/main/src/presentation/validation/professionalSchemas.ts)), export your Zod schemas, and reference them in the `schema.request.body` property of any controller that needs to implement validation logic.

### How are validation errors handled in this architecture?

Validation errors are handled by the `@withErrorHandling` decorator applied to the controller's `handle` method. When `getValidatedData()` encounters data that fails Zod validation, it throws an error containing detailed messages about which fields failed and why. The `withErrorHandling` decorator catches these errors and converts them into HTTP 400 (Bad Request) responses with descriptive error messages, while unexpected runtime errors are returned as HTTP 500 responses.