# 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; ``` **❌ 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.