rovide better testability. You can reset the registry between test suites, inject mock services, and enforce strict registration contracts. This avoids the hidden state pollution that plagues traditional static class implementations.
2. Event Bus (Observer Pattern)
Decoupling components requires a reliable pub/sub mechanism. The event bus enables asynchronous communication without direct references.
type EventHandler<T = unknown> = (payload: T) => void;
class EventBus {
private channels: Map<string, Set<EventHandler>> = new Map();
subscribe<T>(channel: string, handler: EventHandler<T>): () => void {
if (!this.channels.has(channel)) {
this.channels.set(channel, new Set());
}
this.channels.get(channel)!.add(handler as EventHandler);
return () => this.unsubscribe(channel, handler);
}
unsubscribe<T>(channel: string, handler: EventHandler<T>): void {
const handlers = this.channels.get(channel);
if (handlers) handlers.delete(handler as EventHandler);
}
publish<T>(channel: string, payload: T): void {
const handlers = this.channels.get(channel);
if (handlers) {
handlers.forEach(handler => {
try { handler(payload); }
catch (err) { console.error(`Handler failed on ${channel}:`, err); }
});
}
}
}
Architecture Rationale: Using Set instead of arrays prevents duplicate subscriptions and simplifies cleanup. Wrapping handler execution in try/catch ensures one failing listener does not crash the entire broadcast cycle. This is critical in production where telemetry or logging handlers must never block core business logic.
3. Service Builder (Factory Pattern)
Object creation should be abstracted when instantiation involves branching, configuration resolution, or resource pooling.
interface DataFetcher {
retrieve(query: string): Promise<Record<string, unknown>>;
}
class DatabaseFetcher implements DataFetcher {
async retrieve(query: string) { /* DB logic */ return {}; }
}
class CacheFetcher implements DataFetcher {
async retrieve(query: string) { /* Cache logic */ return {}; }
}
class FetcherFactory {
static create(strategy: 'db' | 'cache' | 'hybrid'): DataFetcher {
switch (strategy) {
case 'db': return new DatabaseFetcher();
case 'cache': return new CacheFetcher();
case 'hybrid': return new ProxyFetcher(new CacheFetcher(), new DatabaseFetcher());
default: throw new Error('Unsupported fetch strategy');
}
}
}
Architecture Rationale: Factories should only be used when creation logic exceeds a simple new call. The ProxyFetcher demonstrates how factories can compose multiple implementations behind a single interface. This keeps routing handlers clean and delegates resource selection to configuration or runtime conditions.
4. Policy Engine (Strategy Pattern)
Runtime behavior swapping is essential for validation, routing, and feature flagging. Strategies isolate algorithms behind consistent contracts.
interface ValidationPolicy {
evaluate(input: string): { valid: boolean; errors: string[] };
}
class EmailPolicy implements ValidationPolicy {
evaluate(input: string) {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return { valid: regex.test(input), errors: regex.test(input) ? [] : ['Invalid email format'] };
}
}
class PolicyRouter {
private currentPolicy: ValidationPolicy;
constructor(initial: ValidationPolicy) {
this.currentPolicy = initial;
}
switchPolicy(policy: ValidationPolicy): void {
this.currentPolicy = policy;
}
validate(input: string) {
return this.currentPolicy.evaluate(input);
}
}
Architecture Rationale: Strategies must be stateless and pure. The PolicyRouter demonstrates explicit switching without conditional sprawl. This pattern shines in compliance-heavy systems where validation rules change based on jurisdiction, user tier, or feature rollout percentage.
5. Handler Wrapper (Decorator Pattern)
Cross-cutting concerns like logging, retry logic, and caching should wrap core functions without modifying their implementation.
type AsyncHandler<TArgs extends unknown[], TResult> = (...args: TArgs) => Promise<TResult>;
function withRetry<TArgs extends unknown[], TResult>(
handler: AsyncHandler<TArgs, TResult>,
attempts: number = 3,
delayMs: number = 1000
): AsyncHandler<TArgs, TResult> {
return async (...args: TArgs) => {
for (let i = 0; i < attempts; i++) {
try { return await handler(...args); }
catch (err) {
if (i === attempts - 1) throw err;
await new Promise(res => setTimeout(res, delayMs * (i + 1)));
}
}
throw new Error('Unreachable');
};
}
function withCache<TArgs extends unknown[], TResult>(
handler: AsyncHandler<TArgs, TResult>,
ttlMs: number = 5000
): AsyncHandler<TArgs, TResult> {
const store = new Map<string, { result: TResult; expires: number }>();
return async (...args: TArgs) => {
const key = JSON.stringify(args);
const cached = store.get(key);
if (cached && Date.now() < cached.expires) return cached.result;
const result = await handler(...args);
store.set(key, { result, expires: Date.now() + ttlMs });
return result;
};
}
Architecture Rationale: Decorators must be composable and order-aware. Applying withRetry before withCache ensures failed requests are retried before caching a stale error. Explicit composition pipelines prevent the "magic wrapper" anti-pattern where behavior becomes impossible to trace.
6. Gateway Bridge (Adapter Pattern)
Third-party services rarely share consistent interfaces. Adapters normalize external contracts into internal DTOs.
interface PaymentGateway {
charge(amount: number, currency: string, token: string): Promise<{ id: string; status: string }>;
}
class StripeBridge implements PaymentGateway {
constructor(private client: any) {}
async charge(amount: number, currency: string, token: string) {
const charge = await this.client.charges.create({
amount: Math.round(amount * 100),
currency,
source: token,
});
return { id: charge.id, status: charge.status };
}
}
class PayPalBridge implements PaymentGateway {
constructor(private client: any) {}
async charge(amount: number, currency: string, token: string) {
const order = await this.client.orders.create({
intent: 'CAPTURE',
purchase_units: [{ amount: { currency_code: currency, value: amount.toFixed(2) } }],
});
return { id: order.id, status: 'PENDING' };
}
}
Architecture Rationale: Adapters must never leak provider-specific types. The PaymentGateway interface enforces a strict contract. This enables seamless provider swaps, A/B testing payment flows, and mocking external dependencies during integration tests.
7. Pipeline Orchestrator (Middleware Chain)
Request processing benefits from sequential, composable execution contexts.
type PipelineStep = (context: Record<string, unknown>, advance: () => Promise<void>) => Promise<void>;
class PipelineOrchestrator {
private steps: PipelineStep[] = [];
append(step: PipelineStep): this {
this.steps.push(step);
return this;
}
async execute(initialContext: Record<string, unknown>): Promise<Record<string, unknown>> {
let index = -1;
const context = { ...initialContext };
const advance = async (): Promise<void> => {
index++;
if (index < this.steps.length) {
await this.steps[index](context, advance);
}
};
await advance();
return context;
}
}
Architecture Rationale: The recursive advance function enables pre/post execution hooks without callback hell. Context is passed explicitly, preventing global state mutations. This pattern scales cleanly from simple logging chains to complex authentication, rate-limiting, and transformation pipelines.
Pitfall Guide
1. Singleton State Pollution
Explanation: Developers often store request-scoped data in application singletons, causing cross-request leakage in concurrent environments.
Fix: Scope singletons to infrastructure resources (DB pools, config loaders). Use dependency injection or request context objects for per-request state.
2. Observer Memory Leaks
Explanation: Unbounded listener accumulation occurs when components subscribe but never unsubscribe, especially in SPA frameworks or long-running Node processes.
Fix: Always pair subscriptions with teardown hooks. Use WeakRef for DOM-bound listeners, and implement bounded queues or TTL-based cleanup for event buses.
3. Strategy Explosion
Explanation: Creating dozens of strategy classes for minor variations leads to maintenance overhead and conditional routing sprawl.
Fix: Keep strategies atomic. Use configuration-driven parameters instead of branching classes. Group related rules under a single policy with internal rule engines.
4. Decorator Order Fragility
Explanation: Applying caching before logging vs. logging before caching produces fundamentally different observability and correctness guarantees.
Fix: Document execution order explicitly. Use a composition pipeline that enforces sequence. Never rely on implicit decorator stacking.
5. Adapter Interface Leakage
Explanation: Returning raw third-party response objects forces consumers to handle provider-specific fields, breaking abstraction.
Fix: Define strict DTOs. Map external responses to internal types inside the adapter. Throw typed errors for unsupported fields rather than passing them through.
6. Middleware Blocking
Explanation: Synchronous heavy computation or unoptimized I/O in a middleware chain blocks the event loop and degrades throughput.
Fix: Offload CPU-intensive work to worker threads. Set explicit timeouts on I/O operations. Implement fail-fast logic to skip downstream steps when early validation fails.
7. Factory Over-Abstraction
Explanation: Wrapping simple new calls in factories adds indirection without value, making code harder to trace.
Fix: Use factories only when creation involves branching, configuration resolution, connection pooling, or lazy initialization. Otherwise, prefer direct instantiation or DI containers.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-frequency state updates | Event Bus + Immutable Payloads | Prevents race conditions and enables replayability | Low (memory overhead negligible) |
| Third-party API integration | Gateway Bridge + Strict DTOs | Isolates provider changes and simplifies mocking | Medium (initial mapping effort) |
| Complex validation rules | Policy Engine + Rule Registry | Enables runtime swapping and A/B testing | Low (pure functions, easy to test) |
| Request lifecycle management | Pipeline Orchestrator + Context | Provides deterministic execution and clean separation | Low (minimal boilerplate) |
| Cross-cutting concerns | Handler Wrapper (Decorator) | Keeps core logic pure and composable | Low (order-aware composition) |
Configuration Template
// architecture.config.ts
import { ServiceRegistry } from './registry';
import { EventBus } from './event-bus';
import { PipelineOrchestrator } from './pipeline';
import { PolicyRouter } from './policy';
import { FetcherFactory } from './factory';
export function bootstrapArchitecture() {
const registry = ServiceRegistry.getInstance();
const eventBus = new EventBus();
const pipeline = new PipelineOrchestrator();
const policyRouter = new PolicyRouter(new EmailPolicy());
const dataFetcher = FetcherFactory.create('hybrid');
registry.register('events', eventBus);
registry.register('pipeline', pipeline);
registry.register('policyRouter', policyRouter);
registry.register('fetcher', dataFetcher);
return { registry, eventBus, pipeline, policyRouter, dataFetcher };
}
Quick Start Guide
- Scaffold the registry: Create a
ServiceRegistry class with explicit register and resolve methods. Initialize it at application startup.
- Wire the event bus: Instantiate an
EventBus, attach critical listeners (logging, metrics, state sync), and store the unsubscribe functions for teardown.
- Define contracts: Write TypeScript interfaces for adapters, strategies, and factories. Implement them behind these contracts.
- Compose the pipeline: Build a
PipelineOrchestrator, append middleware steps in execution order, and pass a mutable context object through the chain.
- Validate boundaries: Write integration tests that swap strategies, mock adapters, and verify pipeline context mutations. Ensure no pattern leaks implementation details to consumers.