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
- Schema-First Validation: Define validation rules in Zod schemas, not in imperative code
- Single Source of Truth: Each validation rule should be defined once and reused
- Layer Separation: Keep format validation in schemas, business logic in validation functions
- 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 validationvalidation.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: 1limit: 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:
- Identify Duplicate Logic: Find manual validation that duplicates schemas
- Move to Domain Layer: Define schemas in
packages/domain/*/schema.ts - Create Helper Functions: Extract reusable business logic to
validation.ts - Update Services: Replace manual checks with schema/function calls
- Update Controllers: Add
@UsePipes(ZodValidationPipe)decorators - Remove Duplicate Code: Delete manual validation that duplicates schemas
- 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.