Assist_Design/docs/VALIDATION_PATTERNS.md

11 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);
}

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.