# 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)
