# TypeScript Standards > **⚠️ MAINTENANCE:** This file is indexed in `dev-team/skills/shared-patterns/standards-coverage-table.md`. > When adding/removing `## ` sections, follow FOUR-FILE UPDATE RULE in CLAUDE.md: (1) edit standards file, (2) update TOC, (3) update standards-coverage-table.md, (4) update agent file. This file defines the specific standards for TypeScript (backend) development. > **Reference**: Always consult `docs/PROJECT_RULES.md` for common project standards. --- ## Table of Contents | # | Section | Description | |---|---------|-------------| | 1 | [Version](#version) | TypeScript and Node.js versions | | 2 | [Strict Configuration](#strict-configuration-mandatory) | tsconfig.json requirements | | 3 | [Frameworks & Libraries](#frameworks--libraries) | Required packages | | 4 | [Type Safety](#type-safety) | Never use any, branded types | | 5 | [Zod Validation Patterns](#zod-validation-patterns) | Schema validation | | 6 | [Dependency Injection](#dependency-injection) | TSyringe patterns | | 7 | [AsyncLocalStorage for Context](#asynclocalstorage-for-context) | Request context propagation | | 8 | [Testing](#testing) | Type-safe mocks, fixtures | | 9 | [Error Handling](#error-handling) | Custom error classes | | 10 | [Function Design](#function-design-mandatory) | Single responsibility principle | | 11 | [Naming Conventions](#naming-conventions) | Files, interfaces, types | | 12 | [Directory Structure](#directory-structure) | Project layout (Lerian pattern) | | 13 | [RabbitMQ Worker Pattern](#rabbitmq-worker-pattern) | Async message processing | | 14 | [Always-Valid Domain Model](#always-valid-domain-model-mandatory) | Constructor validation, invariant protection | **Meta-sections (not checked by agents):** - [Checklist](#checklist) - Self-verification before submitting code --- ## Version - TypeScript 5.0+ - Node.js 20+ / Deno 1.40+ / Bun 1.0+ --- ## Strict Configuration (MANDATORY) ```json { "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": true, "exactOptionalPropertyTypes": true, "noFallthroughCasesInSwitch": true, "noImplicitReturns": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": false } } ``` --- ## Frameworks & Libraries ### Backend Frameworks | Framework | Use Case | |-----------|----------| | Express | Traditional, widely adopted | | Fastify | High performance | | NestJS | Enterprise, Angular-style DI | | Hono | Ultrafast, edge-ready | | tRPC | End-to-end type safety | ### ORMs & Query Builders | Library | Use Case | |---------|----------| | Prisma | Type-safe ORM, migrations | | Drizzle | Lightweight, SQL-like | | TypeORM | Decorator-based ORM | | Kysely | Type-safe query builder | ### Validation | Library | Use Case | |---------|----------| | Zod | Schema validation + types | | Yup | Object schema validation | | joi | Classic validation | | class-validator | Decorator-based | ### Testing | Library | Use Case | |---------|----------| | Vitest | Fast, Vite-native | | Jest | Full-featured | | Supertest | HTTP testing | | testcontainers | Integration tests | --- ## Type Safety ### never use `any` ```typescript // FORBIDDEN const data: any = fetchData(); function process(x: any) { ... } // CORRECT - use unknown with type narrowing const data: unknown = fetchData(); if (isUser(data)) { console.log(data.name); // Now TypeScript knows it's User } // Type guard function isUser(value: unknown): value is User { return ( typeof value === 'object' && value !== null && 'id' in value && 'name' in value ); } ``` ### Branded Types for IDs ```typescript // Define branded type to prevent ID mixing type Brand = T & { __brand: B }; type UserId = Brand; type TenantId = Brand; type OrderId = Brand; // Factory functions with validation function createUserId(value: string): UserId { if (!value.startsWith('usr_')) { throw new Error('Invalid user ID format'); } return value as UserId; } // Now TypeScript prevents mixing IDs function getUser(id: UserId): User { ... } function getOrder(id: OrderId): Order { ... } const userId = createUserId('usr_123'); const orderId = createOrderId('ord_456'); getUser(userId); // OK getUser(orderId); // TypeScript ERROR - type mismatch ``` ### Discriminated Unions for State ```typescript // CORRECT - use discriminated unions type RequestState = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; error: Error }; function handleState(state: RequestState) { switch (state.status) { case 'idle': return null; case 'loading': return ; case 'success': return ; // TypeScript knows data exists case 'error': return ; // TypeScript knows error exists } } ``` ### Result Type for Error Handling ```typescript // Define Result type type Result = | { success: true; data: T } | { success: false; error: E }; // Usage async function createUser(input: CreateUserInput): Promise> { const validation = userSchema.safeParse(input); if (!validation.success) { return { success: false, error: new ValidationError(validation.error) }; } const user = await db.user.create({ data: validation.data }); return { success: true, data: user }; } // Pattern matching approach const result = await createUser(input); if (result.success) { console.log(result.data.id); // TypeScript knows data exists } else { console.error(result.error.message); // TypeScript knows error exists } ``` --- ## Zod Validation Patterns ### Schema Definition ```typescript import { z } from 'zod'; // Reusable primitives const emailSchema = z.string().email(); const uuidSchema = z.string().uuid(); const moneySchema = z.number().positive().multipleOf(0.01); // Compose schemas const createUserSchema = z.object({ email: emailSchema, name: z.string().min(1).max(100), role: z.enum(['admin', 'user', 'guest']), preferences: z.object({ theme: z.enum(['light', 'dark']).default('light'), notifications: z.boolean().default(true), }).optional(), }); // Infer TypeScript type from schema type CreateUserInput = z.infer; // Runtime validation function createUser(input: unknown): CreateUserInput { return createUserSchema.parse(input); // Throws on invalid } // Safe parsing (returns Result-like) function validateUser(input: unknown) { const result = createUserSchema.safeParse(input); if (!result.success) { return { error: result.error.flatten() }; } return { data: result.data }; } ``` ### Schema Composition ```typescript // Base schemas const timestampSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), }); const identifiableSchema = z.object({ id: uuidSchema, }); // Compose for full entity const userSchema = identifiableSchema .merge(timestampSchema) .extend({ email: emailSchema, name: z.string(), }); ``` --- ## Dependency Injection ### Using TSyringe ```typescript import { container, injectable, inject } from 'tsyringe'; // Define interface interface UserRepository { findById(id: string): Promise; save(user: User): Promise; } // Implement @injectable() class PostgresUserRepository implements UserRepository { constructor( @inject('Database') private db: Database ) {} async findById(id: string): Promise { return this.db.user.findUnique({ where: { id } }); } async save(user: User): Promise { await this.db.user.upsert({ where: { id: user.id }, ...user }); } } // Service using repository @injectable() class UserService { constructor( @inject('UserRepository') private repo: UserRepository ) {} async getUser(id: string): Promise { const user = await this.repo.findById(id); if (!user) throw new NotFoundError('User not found'); return user; } } // Register in container container.register('Database', { useClass: PrismaDatabase }); container.register('UserRepository', { useClass: PostgresUserRepository }); // Resolve const userService = container.resolve(UserService); ``` --- ## AsyncLocalStorage for Context ```typescript import { AsyncLocalStorage } from 'async_hooks'; // Define context type interface RequestContext { requestId: string; userId?: string; tenantId?: string; } // Create storage const asyncLocalStorage = new AsyncLocalStorage(); // Get current context export function getContext(): RequestContext { const ctx = asyncLocalStorage.getStore(); if (!ctx) throw new Error('No context available'); return ctx; } // Middleware to set context export function contextMiddleware(req: Request, res: Response, next: NextFunction) { const context: RequestContext = { requestId: req.headers['x-request-id'] as string || crypto.randomUUID(), userId: req.user?.id, tenantId: req.headers['x-tenant-id'] as string, }; asyncLocalStorage.run(context, () => next()); } // Usage anywhere in call chain async function processOrder(orderId: string) { const { tenantId, userId } = getContext(); logger.info('Processing order', { orderId, tenantId, userId }); // ... } ``` --- ## Testing ### Type-Safe Mocks ```typescript import { vi, describe, it, expect } from 'vitest'; // Create typed mock const mockUserRepository: jest.Mocked = { findById: vi.fn(), save: vi.fn(), }; describe('UserService', () => { it('returns user when found', async () => { // Arrange const user: User = { id: 'usr_123', name: 'John', email: 'john@example.com' }; mockUserRepository.findById.mockResolvedValue(user); const service = new UserService(mockUserRepository); // Act const result = await service.getUser('usr_123'); // Assert expect(result).toEqual(user); expect(mockUserRepository.findById).toHaveBeenCalledWith('usr_123'); }); it('throws NotFoundError when user not found', async () => { // Arrange mockUserRepository.findById.mockResolvedValue(null); const service = new UserService(mockUserRepository); // Act & Assert await expect(service.getUser('usr_999')).rejects.toThrow(NotFoundError); }); }); ``` ### Type-Safe Fixtures ```typescript // fixtures/user.ts import { faker } from '@faker-js/faker'; export function createUserFixture(overrides: Partial = {}): User { return { id: `usr_${faker.string.uuid()}`, name: faker.person.fullName(), email: faker.internet.email(), createdAt: faker.date.past(), updatedAt: new Date(), ...overrides, }; } // Usage in tests const user = createUserFixture({ name: 'Test User' }); ``` ### Edge Case Coverage (MANDATORY) **Every acceptance criterion MUST have edge case tests beyond the happy path.** | AC Type | Required Edge Cases | Minimum Count | |---------|---------------------|---------------| | Input validation | null, undefined, empty string, boundary values, invalid format, special chars | 3+ | | CRUD operations | not found, duplicate, concurrent access, large payload | 3+ | | Business logic | zero, negative, overflow, boundary conditions, invalid state | 3+ | | Error handling | timeout, connection refused, invalid response, retry exhausted | 2+ | | Authentication | expired token, invalid token, missing token, revoked token | 2+ | **Edge Case Test Pattern:** ```typescript describe('UserService', () => { describe('createUser', () => { // Happy path it('creates user with valid input', async () => { const result = await service.createUser(validInput); expect(result.id).toBeDefined(); }); // Edge cases (MANDATORY - minimum 3) it('throws ValidationError for null input', async () => { await expect(service.createUser(null as any)).rejects.toThrow(ValidationError); }); it('throws ValidationError for empty email', async () => { await expect(service.createUser({ ...validInput, email: '' })).rejects.toThrow(ValidationError); }); it('throws ValidationError for invalid email format', async () => { await expect(service.createUser({ ...validInput, email: 'invalid' })).rejects.toThrow(ValidationError); }); it('throws ValidationError for email exceeding max length', async () => { const longEmail = 'a'.repeat(256) + '@test.com'; await expect(service.createUser({ ...validInput, email: longEmail })).rejects.toThrow(ValidationError); }); it('throws DuplicateError for existing email', async () => { mockRepo.findByEmail.mockResolvedValue(existingUser); await expect(service.createUser(validInput)).rejects.toThrow(DuplicateError); }); }); }); ``` **Anti-Pattern (FORBIDDEN):** ```typescript // ❌ WRONG: Only happy path describe('UserService', () => { it('creates user', async () => { const result = await service.createUser(validInput); expect(result).toBeDefined(); // No edge cases = incomplete test }); }); ``` --- ## Error Handling ### Custom Error Classes ```typescript // Base application error export class AppError extends Error { constructor( message: string, public readonly code: string, public readonly statusCode: number = 500, public readonly details?: Record ) { super(message); this.name = this.constructor.name; } toJSON() { return { error: { code: this.code, message: this.message, details: this.details, }, }; } } // Specific errors export class NotFoundError extends AppError { constructor(resource: string) { super(`${resource} not found`, 'NOT_FOUND', 404); } } export class ValidationError extends AppError { constructor(errors: z.ZodError) { super('Validation failed', 'VALIDATION_ERROR', 400, { fields: errors.flatten().fieldErrors, }); } } export class UnauthorizedError extends AppError { constructor(message = 'Unauthorized') { super(message, 'UNAUTHORIZED', 401); } } ``` --- ## Function Design (MANDATORY) **Single Responsibility Principle (SRP):** Each function MUST have exactly ONE responsibility. ### Rules | Rule | Description | |------|-------------| | **One responsibility per function** | A function should do ONE thing and do it well | | **Max 20-30 lines** | If longer, break into smaller functions | | **One level of abstraction** | Don't mix high-level and low-level operations | | **Descriptive names** | Function name should describe its single responsibility | ### Examples ```typescript // ❌ BAD - Multiple responsibilities async function processOrder(order: Order): Promise { // Validate order if (!order.items?.length) { throw new Error('no items'); } // Calculate total let total = 0; for (const item of order.items) { total += item.price * item.quantity; } // Apply discount if (order.couponCode) { total = total * 0.9; } // Save to database await db.orders.save(order); // Send email await sendEmail(order.customerEmail, 'Order confirmed'); } // ✅ GOOD - Single responsibility per function async function processOrder(order: Order): Promise { validateOrder(order); const total = calculateTotal(order.items); const finalTotal = applyDiscount(total, order.couponCode); await saveOrder(order, finalTotal); await notifyCustomer(order.customerEmail); } function validateOrder(order: Order): void { if (!order.items?.length) { throw new ValidationError('Order must have items'); } } function calculateTotal(items: OrderItem[]): number { return items.reduce((sum, item) => sum + item.price * item.quantity, 0); } function applyDiscount(total: number, couponCode?: string): number { return couponCode ? total * 0.9 : total; } ``` ### Signs a Function Has Multiple Responsibilities | Sign | Action | |------|--------| | Multiple `// section` comments | Split at comment boundaries | | "and" in function name | Split into separate functions | | More than 3 parameters | Consider parameter object or splitting | | Nested conditionals > 2 levels | Extract inner logic to functions | | Function does validation and processing | Separate validation function | --- ## Naming Conventions | Element | Convention | Example | |---------|------------|---------| | Files | kebab-case | `user-service.ts` | | Interfaces | PascalCase | `UserRepository` | | Types | PascalCase | `CreateUserInput` | | Functions | camelCase | `createUser` | | Constants | UPPER_SNAKE | `MAX_RETRY_COUNT` | | Enums | PascalCase + UPPER_SNAKE values | `UserRole.ADMIN` | --- ## Directory Structure The directory structure follows the **Lerian pattern** - a simplified hexagonal architecture without explicit DDD folders. ``` /src /bootstrap # Application initialization config.ts server.ts service.ts /services # Business logic /command # Write operations (use cases) /query # Read operations (use cases) /adapters # Infrastructure implementations /http/in # HTTP handlers + routes /grpc/in # gRPC handlers (if needed) /postgres # PostgreSQL repositories /mongodb # MongoDB repositories /redis # Redis repositories /rabbitmq # RabbitMQ producers/consumers /lib # Utilities db.ts logger.ts /types # Shared types and models index.ts /tests /unit /integration ``` **Key differences from traditional DDD:** - **No `/src/domain` folder** - Business entities live in `/src/types` or within service files - **Services are the core** - `/src/services` contains all business logic (command/query pattern) - **Adapters are flat** - Database repositories are organized by technology, not by domain --- ## RabbitMQ Worker Pattern When the application includes async processing (API+Worker or Worker Only), follow this pattern. ### Application Types | Type | Characteristics | Components | |------|----------------|------------| | **API Only** | HTTP endpoints, no async processing | Handlers, Services, Repositories | | **API + Worker** | HTTP endpoints + async message processing | All above + Consumers, Producers | | **Worker Only** | No HTTP, only message processing | Consumers, Services, Repositories | ### Architecture Overview ```text ┌─────────────────────────────────────────────────────────────┐ │ Service Bootstrap │ │ ├── HTTP Server (Express/Fastify) ← API endpoints │ │ ├── RabbitMQ Consumer ← Event-driven workers │ │ └── Redis Consumer (optional) ← Scheduled polling │ └─────────────────────────────────────────────────────────────┘ ``` ### Core Types ```typescript // Handler function signature type QueueHandlerFunc = (ctx: Context, body: Buffer) => Promise; // Consumer configuration interface ConsumerConfig { connection: RabbitMQConnection; routes: Map; numberOfWorkers: number; // Workers per queue (default: 5) prefetchCount: number; // QoS prefetch (default: 10) logger: Logger; telemetry: Telemetry; } // Context for handlers interface Context { requestId: string; logger: Logger; span: Span; } ``` ### Worker Configuration | Config | Default | Purpose | |--------|---------|---------| | `RABBITMQ_NUMBERS_OF_WORKERS` | 5 | Concurrent workers per queue | | `RABBITMQ_NUMBERS_OF_PREFETCH` | 10 | Messages buffered per worker | | `RABBITMQ_CONSUMER_USER` | - | Separate credentials for consumer | | `RABBITMQ_{QUEUE}_QUEUE` | - | Queue name per handler | **Formula:** `Total buffered = Workers × Prefetch` (e.g., 5 × 10 = 50 messages) ### Handler Registration ```typescript // Register handlers per queue class MultiQueueConsumer { registerRoutes(routes: ConsumerRoutes): void { routes.register( process.env.RABBITMQ_BALANCE_CREATE_QUEUE!, this.handleBalanceCreate.bind(this) ); routes.register( process.env.RABBITMQ_TRANSACTION_QUEUE!, this.handleTransaction.bind(this) ); } } ``` ### Handler Implementation ```typescript async handleBalanceCreate(ctx: Context, body: Buffer): Promise { // 1. Parse and validate message const parsed = queueMessageSchema.safeParse(JSON.parse(body.toString())); if (!parsed.success) { ctx.logger.error('Invalid message format', { error: parsed.error }); throw new Error(`Invalid message: ${parsed.error.message}`); } // 2. Execute business logic const result = await this.useCase.createBalance(ctx, parsed.data); if (!result.success) { throw result.error; } // 3. Success → Ack automatically (by returning without error) } ``` ### Message Acknowledgment | Result | Action | Effect | |--------|--------|--------| | Resolves | `msg.ack()` | Message removed from queue | | Rejects/Throws | `msg.nack(false, true)` | Message requeued | ### Worker Lifecycle ```text runConsumers() ├── For each registered queue: │ ├── ensureChannel() with exponential backoff │ ├── Set QoS (prefetch) │ ├── Start consume() │ └── Process messages with concurrency limit processMessage(): ├── Extract/generate TraceID from headers ├── Create context with requestId ├── Start OpenTelemetry span ├── Call handler(ctx, msg.content) ├── On success: msg.ack() └── On error: log + msg.nack(false, true) ``` ### Exponential Backoff with Jitter ```typescript const BACKOFF_CONFIG = { maxRetries: 5, initialBackoff: 500, // ms maxBackoff: 10_000, // ms backoffFactor: 2.0, } as const; function fullJitter(baseDelay: number): number { const jitter = Math.random() * baseDelay; return Math.min(jitter, BACKOFF_CONFIG.maxBackoff); } function nextBackoff(current: number): number { const next = current * BACKOFF_CONFIG.backoffFactor; return Math.min(next, BACKOFF_CONFIG.maxBackoff); } ``` ### Producer Implementation ```typescript class ProducerRepository { async publish( exchange: string, routingKey: string, message: unknown, ctx: Context ): Promise { await this.ensureChannel(); const headers = { 'x-request-id': ctx.requestId, ...injectTraceHeaders(ctx.span), }; this.channel.publish( exchange, routingKey, Buffer.from(JSON.stringify(message)), { contentType: 'application/json', persistent: true, headers, } ); } } ``` ### Message Schema with Zod ```typescript const queueDataSchema = z.object({ id: z.string().uuid(), value: z.unknown(), }); const queueMessageSchema = z.object({ organizationId: z.string().uuid(), ledgerId: z.string().uuid(), auditId: z.string().uuid(), data: z.array(queueDataSchema), }); type QueueMessage = z.infer; ``` ### Service Bootstrap (API + Worker) ```typescript class Service { constructor( private readonly server: HttpServer, private readonly consumer: MultiQueueConsumer, private readonly logger: Logger, ) {} async run(): Promise { // Run all components concurrently await Promise.all([ this.server.listen(), this.consumer.start(), ]); // Graceful shutdown process.on('SIGTERM', async () => { this.logger.info('Shutting down...'); await this.consumer.stop(); await this.server.close(); }); } } ``` ### Directory Structure for Workers ```text /src /infrastructure /rabbitmq consumer.ts # ConsumerRoutes, worker pool producer.ts # ProducerRepository connection.ts # Connection management /bootstrap rabbitmq-server.ts # MultiQueueConsumer, handler registration service.ts # Service orchestration /lib backoff.ts # Backoff utilities /types queue.ts # Message schemas ``` ### Worker Checklist - [ ] Handlers are idempotent (safe to process duplicates) - [ ] Manual Ack enabled (`noAck: false`) - [ ] Error handling throws error (triggers Nack) - [ ] Context propagation with requestId - [ ] OpenTelemetry spans for tracing - [ ] Exponential backoff for connection recovery - [ ] Graceful shutdown with proper cleanup - [ ] Separate credentials for consumer vs producer - [ ] Zod validation for all message payloads --- ## Always-Valid Domain Model (MANDATORY) **HARD GATE:** All domain entities MUST use the Always-Valid Domain Model pattern. Anemic models (plain objects without validation) are FORBIDDEN. ### Why This Pattern Is Mandatory | Problem with Anemic Models | Impact | |---------------------------|--------| | Objects can exist in invalid state | Bugs propagate through system | | Validation scattered across codebase | Duplication, inconsistency | | Business rules not enforced at creation | Invalid data reaches database | | No single source of truth for validity | Every consumer must re-validate | ### The Pattern **Core Principle:** An entity can NEVER exist in an invalid state. Validation happens in the factory, not later. ```typescript // ✅ CORRECT: Always-Valid Domain Model class Rule { private constructor( private readonly _id: string, private readonly _name: string, private readonly _expression: string, private readonly _createdAt: Date, ) {} // Factory method MUST validate and return Result static create(name: string, expression: string): Result { // Validation at construction time if (!name || name.trim().length === 0) { return err(new ValidationError('name is required')); } if (name.length > 255) { return err(new ValidationError('name exceeds 255 characters')); } if (!isValidExpression(expression)) { return err(new ValidationError('invalid expression syntax')); } return ok(new Rule( crypto.randomUUID(), name.trim(), expression, new Date(), )); } // Getters expose immutable data get id(): string { return this._id; } get name(): string { return this._name; } get expression(): string { return this._expression; } } ``` ```typescript // ❌ FORBIDDEN: Anemic Model (validation elsewhere) interface Rule { id: string; name: string; // Can be empty - invalid! expression: string; // Can be invalid - no validation! } // ❌ FORBIDDEN: Factory without validation function createRule(name: string, expression: string): Rule { return { id: crypto.randomUUID(), name, // No validation! expression, // No validation! }; } ``` ### Requirements | Requirement | Description | |-------------|-------------| | **Factory returns Result** | `Entity.create(...): Result` - MUST return error if invalid | | **Private constructor** | Prevent direct instantiation with `new` | | **Readonly properties** | Use `readonly` or getters to prevent mutation | | **No Setters** | Mutation through domain methods that validate | | **Invariants enforced** | Business rules validated at construction | ### Mutation Pattern When entities need to change state, use domain methods that validate: ```typescript // ✅ CORRECT: Mutation with validation class Rule { // ... updateExpression(newExpression: string): Result { if (!isValidExpression(newExpression)) { return err(new ValidationError('invalid expression syntax')); } // TypeScript: use Object.assign or create new instance for immutability Object.assign(this, { _expression: newExpression }); return ok(undefined); } } // ❌ FORBIDDEN: Direct property assignment rule.expression = 'invalid!!!'; // Compilation error (readonly) ``` ### Reconstruction from Database When loading from database, use a separate reconstruction method: ```typescript // For repository use ONLY - reconstructs from trusted storage static reconstruct( id: string, name: string, expression: string, createdAt: Date, ): Rule { // Skip validation - data is from trusted storage return new Rule(id, name, expression, createdAt); } ``` **Note:** `reconstruct` methods skip validation because data is from trusted storage (already validated at creation). ### Integration with HTTP Layer HTTP handlers still use Zod for input validation, but MUST create domain entities via factories: ```typescript // Zod schema - validation at boundary const createRuleSchema = z.object({ name: z.string().min(1).max(255), expression: z.string().min(1), }); // Handler creates domain entity async function createRule(req: Request): Promise { // Boundary validation const parsed = createRuleSchema.safeParse(req.body); if (!parsed.success) { return errorResponse(parsed.error); } // Domain entity creation - additional business validation const ruleResult = Rule.create(parsed.data.name, parsed.data.expression); if (ruleResult.isErr()) { return errorResponse(ruleResult.error); } // ... } ``` ### Result Type Pattern Use a Result type for operations that can fail: ```typescript type Result = { ok: true; value: T } | { ok: false; error: E }; function ok(value: T): Result { return { ok: true, value }; } function err(error: E): Result { return { ok: false, error }; } // Usage const result = Rule.create(name, expression); if (result.ok) { const rule = result.value; } else { const error = result.error; } ``` ### Anti-Rationalization Table | Rationalization | Why It's WRONG | Required Action | |-----------------|----------------|-----------------| | "Zod validation at boundary is enough" | Boundary validation is for input format. Domain validation is for business rules. | **Use both: Zod validation + factory validation** | | "Adds boilerplate" | Invalid objects cause more work debugging than factories. | **Write the factory. It's an investment.** | | "We trust our code" | Every consumer must remember to validate. Humans forget. | **Enforce at construction. Forget-proof.** | | "Performance overhead" | Validation once at creation vs checking everywhere. | **Single validation is MORE efficient** | | "Existing code doesn't do this" | Technical debt. Refactor when touching the code. | **New code MUST follow. Refactor gradually.** | | "Plain interfaces are fine for DTOs" | DTOs are fine as plain objects. Domain entities are NOT. | **Distinguish DTO from Domain Entity** | ### Checklist - [ ] All domain entities use `private constructor` + `static create()` factory - [ ] Factories return `Result` - never throw - [ ] Properties are `readonly` or accessed via getters - [ ] Mutation through validated methods only - [ ] Reconstruct methods for database loading - [ ] No direct object instantiation outside factories --- ## Checklist Before submitting TypeScript code, verify: ### Type Safety - [ ] No `any` types (use `unknown` with narrowing) - [ ] Strict mode enabled in tsconfig.json - [ ] Zod validation for all external input - [ ] Branded types for IDs - [ ] Discriminated unions for state machines - [ ] Type inference used where possible (avoid redundant annotations) - [ ] No `@ts-ignore` or `@ts-expect-error` without explanation ### Error Handling - [ ] Error classes extend base AppError - [ ] All async functions have proper error handling - [ ] Result type used for operations that can fail ### DDD (if enabled) - [ ] Entities have identity comparison (`equals` method) - [ ] Value Objects are immutable (private constructor, factory methods) - [ ] Aggregates enforce invariants before state changes - [ ] Domain Events emitted for significant state changes - [ ] Repository interfaces defined in domain layer - [ ] No infrastructure dependencies in domain layer