130 lines
3.9 KiB
Markdown
130 lines
3.9 KiB
Markdown
|
|
# 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)
|