414 lines
11 KiB
Markdown
414 lines
11 KiB
Markdown
|
|
# 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);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## 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.
|
||
|
|
|