Assist_Design/docs/validation/VALIDATION_PATTERNS.md

13 KiB

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

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

// 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

// 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

// 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

// 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

// 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

// Good - sanitization in schema
export const emailSchema = z
  .string()
  .email()
  .toLowerCase()
  .trim();

DO: Separate sanitization functions (if needed)

// 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

// 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

/**
 * 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

// 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

// 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

// 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

// 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:

// 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:

// 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):

// 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:

// Good - use Zod enum
const orderTypeSchema = z.enum(["Internet", "SIM", "VPN"]);

OKAY (for business logic with external data):

// 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:

// 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:

// 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

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

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

// 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

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.