Assist_Design/docs/decisions/002-zod-first-validation.md

130 lines
3.9 KiB
Markdown
Raw Normal View History

# 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<typeof invoiceSchema>;
```
## 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)