Assist_Design/docs/validation/VALIDATION_PATTERNS.md

519 lines
13 KiB
Markdown
Raw Normal View History

# Validation Patterns Guide
## Overview
This guide establishes consistent validation patterns across the customer portal codebase. Follow these patterns to ensure maintainable, DRY (Don't Repeat Yourself) validation logic.
## Core Principles
1. **Schema-First Validation**: Define validation rules in Zod schemas, not in imperative code
2. **Single Source of Truth**: Each validation rule should be defined once and reused
3. **Layer Separation**: Keep format validation in schemas, business logic in validation functions
4. **No Duplicate Logic**: Never duplicate validation between schemas and manual checks
## Architecture
### Domain Layer (`packages/domain/`)
Each domain should have:
- **`schema.ts`**: Zod schemas for runtime type validation
- **`validation.ts`**: Business validation functions (using schemas internally)
- **`contract.ts`**: TypeScript types (inferred from schemas)
#### Example Structure:
```
packages/domain/orders/
├── schema.ts # Zod schemas (format validation)
├── validation.ts # Business validation functions
├── contract.ts # TypeScript types
└── index.ts # Public exports
```
### BFF Layer (`apps/bff/src/`)
- **Controllers**: Use `@UsePipes(ZodValidationPipe)` for request validation
- **Services**: Call domain validation functions (not schemas directly)
- **Validators**: Infrastructure-dependent validation only (DB checks, API calls)
## Validation Patterns
### Pattern 1: Controller Input Validation
**✅ DO: Use ZodValidationPipe**
```typescript
import { Controller, Post, Body, UsePipes } from "@nestjs/common";
import { ZodValidationPipe } from "@bff/core/validation";
import { createOrderRequestSchema, type CreateOrderRequest } from "@customer-portal/domain/orders";
@Controller("orders")
export class OrdersController {
@Post()
@UsePipes(new ZodValidationPipe(createOrderRequestSchema))
async create(@Body() body: CreateOrderRequest) {
return this.orderService.createOrder(body);
}
}
```
**❌ DON'T: Validate manually in controllers**
```typescript
// Bad - manual validation in controller
@Post()
async create(@Body() body: any) {
if (!body.orderType || typeof body.orderType !== "string") {
throw new BadRequestException("Invalid order type");
}
// ...
}
```
### Pattern 2: Schema Definition
**✅ DO: Define schemas in domain layer**
```typescript
// packages/domain/orders/schema.ts
import { z } from "zod";
export const createOrderRequestSchema = z.object({
orderType: z.enum(["Internet", "SIM", "VPN"]),
skus: z.array(z.string().min(1)),
configurations: z.object({
activationType: z.enum(["Immediate", "Scheduled"]).optional(),
}).optional(),
});
export type CreateOrderRequest = z.infer<typeof createOrderRequestSchema>;
```
**❌ DON'T: Define validation logic in multiple places**
```typescript
// Bad - validation in service duplicates schema
async createOrder(data: any) {
if (!data.orderType || !["Internet", "SIM", "VPN"].includes(data.orderType)) {
throw new Error("Invalid order type");
}
// This duplicates the schema validation!
}
```
### Pattern 3: Business Validation Functions
**✅ DO: Use helper functions with schema refinements**
```typescript
// packages/domain/orders/validation.ts
// Helper function (reusable)
export function hasSimServicePlan(skus: string[]): boolean {
return skus.some(sku =>
sku.toUpperCase().includes("SIM") &&
!sku.toUpperCase().includes("ACTIVATION")
);
}
// Schema uses helper function (DRY)
export const orderWithSkuValidationSchema = baseOrderSchema
.refine(
(data) => data.orderType !== "SIM" || hasSimServicePlan(data.skus),
{ message: "SIM orders must include a service plan", path: ["skus"] }
);
```
**❌ DON'T: Duplicate logic in refinements**
```typescript
// Bad - logic duplicated between helper and schema
export function hasSimServicePlan(skus: string[]): boolean {
return skus.some(sku => sku.includes("SIM"));
}
const schema = baseSchema.refine((data) => {
// Duplicates the helper function logic!
if (data.orderType === "SIM") {
return data.skus.some(sku => sku.includes("SIM"));
}
return true;
});
```
### Pattern 4: Sanitization
**✅ DO: Use schema transforms for sanitization**
```typescript
// Good - sanitization in schema
export const emailSchema = z
.string()
.email()
.toLowerCase()
.trim();
```
**✅ DO: Separate sanitization functions (if needed)**
```typescript
// Good - pure sanitization (no validation)
export function sanitizeCreateRequest(request: CreateMappingRequest): CreateMappingRequest {
return {
userId: request.userId?.trim(),
whmcsClientId: request.whmcsClientId,
sfAccountId: request.sfAccountId?.trim() || undefined,
};
}
```
**❌ DON'T: Mix sanitization with validation**
```typescript
// Bad - mixing sanitization and validation
export function sanitizeAndValidate(request: any) {
const sanitized = { userId: request.userId?.trim() };
// Validation mixed with sanitization
if (!sanitized.userId || sanitized.userId.length === 0) {
throw new Error("Invalid userId");
}
return sanitized;
}
```
### Pattern 5: Validation Wrapper Functions
**✅ DO: Keep wrappers simple and documented**
```typescript
/**
* Validate and normalize email address
*
* This is a convenience wrapper that throws on invalid input.
* For validation without throwing, use emailSchema.safeParse()
*
* @throws Error if email format is invalid
*/
export function normalizeAndValidateEmail(email: string): string {
const emailValidationSchema = z.string().email().transform(e => e.toLowerCase().trim());
return emailValidationSchema.parse(email);
}
```
**❌ DON'T: Use safeParse + manual error handling**
```typescript
// Bad - unnecessary complexity
export function normalizeAndValidateEmail(email: string): string {
const result = emailSchema.safeParse(email);
if (!result.success) {
throw new Error("Invalid email");
}
return result.data;
}
// Better - just use .parse()
export function normalizeAndValidateEmail(email: string): string {
return emailSchema.parse(email);
}
```
### Pattern 6: Infrastructure Validation
**✅ DO: Keep infrastructure checks in BFF services**
```typescript
// Good - infrastructure validation in BFF
@Injectable()
export class OrderValidator {
async validateUserMapping(userId: string) {
const mapping = await this.mappings.findByUserId(userId);
if (!mapping) {
throw new BadRequestException("User mapping required");
}
return mapping;
}
}
```
**✅ DO: Use schemas for type/format validation even in services**
```typescript
// Good - use schema for format validation
const salesforceAccountIdSchema = z.string().min(1);
async validateOrder(order: SalesforceOrder) {
const accountId = salesforceAccountIdSchema.parse(order.AccountId);
// Continue with business logic...
}
```
**❌ DON'T: Use manual type checks**
```typescript
// Bad - manual type checking
if (typeof order.AccountId !== "string" || order.AccountId.length === 0) {
throw new Error("Invalid AccountId");
}
```
## Common Anti-Patterns
### Anti-Pattern 1: Duplicate Password Validation
**❌ DON'T:**
```typescript
// Schema defines password rules
const passwordSchema = z.string()
.min(8)
.regex(/[A-Z]/, "Must contain uppercase")
.regex(/[a-z]/, "Must contain lowercase");
// Service duplicates the same rules!
function validatePassword(password: string) {
if (password.length < 8 || !/[A-Z]/.test(password) || !/[a-z]/.test(password)) {
throw new Error("Invalid password");
}
}
```
**✅ DO:**
```typescript
// Schema defines rules once
const passwordSchema = z.string()
.min(8)
.regex(/[A-Z]/, "Must contain uppercase")
.regex(/[a-z]/, "Must contain lowercase");
// Service uses the schema
function validatePassword(password: string) {
return passwordSchema.parse(password);
}
```
### Anti-Pattern 2: Manual String Matching for Validation
**❌ DON'T** (for input validation):
```typescript
// Bad - manual string checks for validation
function validateOrderType(type: any) {
if (typeof type !== "string") return false;
if (!["Internet", "SIM", "VPN"].includes(type)) return false;
return true;
}
```
**✅ DO:**
```typescript
// Good - use Zod enum
const orderTypeSchema = z.enum(["Internet", "SIM", "VPN"]);
```
**✅ OKAY** (for business logic with external data):
```typescript
// This is fine - checking WHMCS API response data
const hasInternet = products.some(p =>
p.groupname.toLowerCase().includes("internet")
);
```
### Anti-Pattern 3: Validation in Multiple Layers
**❌ DON'T:**
```typescript
// Controller validates
@Post()
async create(@Body() body: any) {
if (!body.email) throw new BadRequestException("Email required");
// Service validates again
await this.service.create(body);
}
// Service
async create(data: any) {
if (!data.email || !data.email.includes("@")) {
throw new Error("Invalid email");
}
}
```
**✅ DO:**
```typescript
// Schema validates once
const createUserSchema = z.object({
email: z.string().email(),
});
// Controller uses schema
@Post()
@UsePipes(new ZodValidationPipe(createUserSchema))
async create(@Body() body: CreateUserRequest) {
return this.service.create(body);
}
// Service trusts the validated input
async create(data: CreateUserRequest) {
// No validation needed - input is already validated
return this.repository.save(data);
}
```
## Pagination Schemas
The codebase provides two approaches for pagination validation:
### Standard Pagination: `paginationParamsSchema`
Use this for most cases where you need standard pagination with fixed defaults.
**Location**: `packages/domain/common/schema.ts`
```typescript
import { paginationParamsSchema } from "@customer-portal/domain/common";
// Standard pagination with defaults: page=1, limit=20, max=100
export const orderQueryParamsSchema = z.object({
status: z.string().optional(),
orderType: z.string().optional(),
}).merge(paginationParamsSchema);
```
**Defaults**:
- `page`: 1
- `limit`: 20
- Maximum limit: 100
**Use cases**:
- Standard API endpoints (orders, invoices, subscriptions)
- When you want consistent pagination across endpoints
- When default limits are appropriate for your data
### Custom Pagination: `createPaginationSchema()`
Use this when you need different limits or defaults for specific endpoints.
**Location**: `packages/domain/toolkit/validation/helpers.ts`
```typescript
import { createPaginationSchema } from "@customer-portal/domain/toolkit/validation";
// Custom pagination for large datasets
const customPaginationSchema = createPaginationSchema({
minLimit: 5,
maxLimit: 500,
defaultLimit: 50,
});
export const bulkOrderQuerySchema = z.object({
status: z.string().optional(),
}).merge(customPaginationSchema);
```
**Configuration options**:
- `minLimit`: Minimum records per page (default: 1)
- `maxLimit`: Maximum records per page (default: 100)
- `defaultLimit`: Default records if not specified (default: 10)
**Use cases**:
- Admin endpoints with larger datasets
- Bulk operations requiring different limits
- Special cases where 20/page doesn't make sense
### Decision Guide
```
Do you need custom pagination limits?
├─ NO → Use paginationParamsSchema
│ ✅ Simple, consistent
│ ✅ Standard defaults
└─ YES → Use createPaginationSchema()
✅ Configurable limits
✅ Flexible defaults
```
### Example: Orders API
```typescript
// Standard orders list (20 per page by default)
@Get()
@UsePipes(new ZodValidationPipe(orderQueryParamsSchema))
async getOrders(@Query() query: OrderQuery) {
// query.page defaults to 1
// query.limit defaults to 20
// query.limit max is 100
}
// Admin bulk orders (50 per page, up to 500)
const adminOrderQuerySchema = z.object({
status: z.string().optional(),
}).merge(createPaginationSchema({
minLimit: 10,
maxLimit: 500,
defaultLimit: 50,
}));
@Get("admin/orders")
@UsePipes(new ZodValidationPipe(adminOrderQuerySchema))
async getAdminOrders(@Query() query: AdminOrderQuery) {
// query.page defaults to 1
// query.limit defaults to 50
// query.limit max is 500
}
```
## Migration Guide
When refactoring existing validation code:
1. **Identify Duplicate Logic**: Find manual validation that duplicates schemas
2. **Move to Domain Layer**: Define schemas in `packages/domain/*/schema.ts`
3. **Create Helper Functions**: Extract reusable business logic to `validation.ts`
4. **Update Services**: Replace manual checks with schema/function calls
5. **Update Controllers**: Add `@UsePipes(ZodValidationPipe)` decorators
6. **Remove Duplicate Code**: Delete manual validation that duplicates schemas
7. **Add Documentation**: Document why each validation exists
## Testing Validation
```typescript
import { describe, it, expect } from "vitest";
import { orderTypeSchema } from "./schema";
describe("Order Type Validation", () => {
it("should accept valid order types", () => {
expect(orderTypeSchema.parse("Internet")).toBe("Internet");
expect(orderTypeSchema.parse("SIM")).toBe("SIM");
});
it("should reject invalid order types", () => {
expect(() => orderTypeSchema.parse("Invalid")).toThrow();
expect(() => orderTypeSchema.parse(null)).toThrow();
});
});
```
## Summary
- **Use schemas for format validation**
- **Use functions for business validation**
- **Never duplicate validation logic**
- **Validate once at the entry point**
- **Trust validated data downstream**
- **Keep infrastructure validation separate**
Following these patterns ensures maintainable, consistent validation across the entire codebase.