# ADR-002: Zod-First Validation **Date**: 2025-01-15 **Status**: Accepted ## Context The Customer Portal needs validation at multiple layers: - BFF request/response validation - Domain type validation - Portal form validation Traditional approaches use separate validation at each layer (class-validator in NestJS, yup/joi in React, manual TypeScript types). This leads to: - Duplicated validation logic - Type/validation drift - Inconsistent error messages ## Decision Use **Zod schemas as the single source of truth** for both TypeScript types and runtime validation across all layers. ```typescript // Schema defines both type AND validation export const invoiceSchema = z.object({ id: z.number(), status: z.enum(["paid", "unpaid", "overdue"]), amount: z.number().positive(), }); // Type derived from schema - always in sync export type Invoice = z.infer; ``` ## Rationale ### Why Zod? 1. **Single source of truth**: Schema defines both TypeScript type and runtime validation. No drift possible. 2. **Cross-layer consistency**: Same schema works in: - Domain layer (type definitions) - BFF (request/response validation via `nestjs-zod`) - Portal (form validation via `@hookform/resolvers/zod`) 3. **Runtime safety**: TypeScript only validates at compile time. Zod validates at runtime for external data (API responses, user input). 4. **Better DX**: - Composable schemas (`z.extend()`, `z.pick()`, `z.omit()`) - Excellent error messages - Full TypeScript inference ### Alternatives Considered | Approach | Pros | Cons | | --------------------------------------- | -------------------------------- | ---------------------------------------------------- | | **class-validator + class-transformer** | NestJS native, decorators | Types separate from validation, verbose | | **TypeScript only** | No runtime overhead | No runtime validation, unsafe for external data | | **Joi/Yup** | Mature libraries | Poor TypeScript inference, separate type definitions | | **Zod** | Types from schemas, excellent TS | Slightly newer ecosystem | ## Consequences ### Positive - Types and validation always in sync - Consistent validation across BFF and Portal - Excellent TypeScript inference - Composable schemas reduce duplication - Runtime safety for external data ### Negative - Slightly larger bundle size vs TypeScript-only - Team needs to learn Zod API - Some NestJS features require `createZodDto()` wrapper ## Implementation ### BFF Validation ```typescript // apps/bff/src/app.module.ts providers: [ { provide: APP_PIPE, useClass: ZodValidationPipe }, ] // Controller uses DTOs built from domain schemas import { createZodDto } from "nestjs-zod"; import { invoiceQuerySchema } from "@customer-portal/domain/billing"; class InvoiceQueryDto extends createZodDto(invoiceQuerySchema) {} @Get() async getInvoices(@Query() query: InvoiceQueryDto) { ... } ``` ### Domain Mappers ```typescript // packages/domain/billing/providers/whmcs/mapper.ts export function transformWhmcsInvoice(raw: unknown): Invoice { const validated = whmcsInvoiceRawSchema.parse(raw); // Validate input const result = { /* transform */ }; return invoiceSchema.parse(result); // Validate output } ``` ### Portal Forms ```typescript // apps/portal/src/features/auth/components/LoginForm.tsx import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { loginSchema } from "@customer-portal/domain/auth"; const form = useForm({ resolver: zodResolver(loginSchema), }); ``` ## Related - [Domain Structure](../development/domain/structure.md) - [BFF Validation](../development/bff/validation.md) - [Integration Patterns](../development/bff/integration-patterns.md)