Decoupling algorithms, event distribution, and transaction execution from entity state transforms brittle procedural workflows into extensible, testable, and undoable behavioral pipelines. This article covers strongly-typed Strategy registries, leak-free Observer subscriptions with `Symbol.dispose`, and Command pattern undo/redo stacks for ledger operations.
In TypeScript 5+, OOP is an architectural contract, not an inheritance tree: enforce domain invariants at compile time, encapsulate mutation strictly within aggregates, and invert dependencies so high-level business policy never couples to execution details. Behavioral patterns define how objects communicate — the protocols, callbacks, and queued operations that make those contracts dynamic without making them chaotic.
This article covers the three most commonly tested behavioral patterns in LLD interviews: Strategy (interchangeable algorithms), Observer (decoupled event notification), and Command (encapsulated operations with optional undo). Each is demonstrated against the fintech domain with TypeScript 5+ idioms that eliminate the runtime ambiguity found in classical GoF implementations.
Here is the event handling pattern that appears in every monolith's first payment integration:
TYPESCRIPT
// ❌ Anti-Pattern: Synchronous, monolithic event handler — all logic in one placeclassPaymentService {
asyncprocessPayment(orderId: string, amount: number): Promise<void> {
// ... payment logic ...const transactionId = `txn_${Date.now()}`;
// All notification logic hardcoded here — SRP violation, OCP violationawaitthis.sendEmail(orderId, transactionId); // Marketing wants to control thisawaitthis.updateInventory(orderId); // Inventory team owns thisawaitthis.notifyFraudDetection(transactionId); // Security team owns thisawaitthis.sendPushNotification(orderId); // Mobile team owns thisawaitthis.updateAnalytics(transactionId); // Data team owns this
}
}
Five teams, five independent deployment schedules, five reasons to change processPayment. Every new notification requirement modifies the payment path — a critical financial operation — introducing regression risk for all five existing notifications simultaneously.
The Strategy pattern extracts a family of algorithms behind a shared interface, making them interchangeable at runtime. In TypeScript 5+, the strategy registry with satisfies combines the pattern with compile-time exhaustiveness guarantees.
The Observer pattern decouples publishers from subscribers. In Node.js, EventEmitter provides a dynamic runtime version but lacks compile-time type safety on event payloads. The TypeScript 5+ version uses discriminated union event types to make the payload shape of each event literal-typed.
// Domain events — discriminated union ensures exhaustive handlingtypePaymentEvent =
| { kind: 'PAYMENT_AUTHORIZED'; transactionId: string; amount: number; orderId: string }
| { kind: 'PAYMENT_DECLINED'; orderId: string; reason: string }
| { kind: 'REFUND_INITIATED'; transactionId: string; refundAmount: number };
// Event bus with per-kind type inferencetypeEventHandler<T> = (event: T) =>void | Promise<void>;
classPaymentEventBus {
// Map from event kind to list of handlers
#handlers = newMap<string, Array<EventHandler<PaymentEvent>>>();
on<K extendsPaymentEvent['kind']>(
kind: K,
handler: EventHandler<Extract<PaymentEvent, { kind: K }>>,
): () =>void { // Returns cleanup functionconst handlers = this.#handlers.get(kind) ?? [];
handlers.push(handler asEventHandler<PaymentEvent>);
this.#handlers.set(kind, handlers);
return() =>this.off(kind, handler asEventHandler<PaymentEvent>);
}
off(kind: string, handler: EventHandler<PaymentEvent>): void {
const handlers = this.#handlers.get(kind) ?? [];
this.#handlers.set(kind, handlers.filter(h => h !== handler));
}
async emit<K extendsPaymentEvent['kind']>(
event: Extract<PaymentEvent, { kind: K }>,
): Promise<void> {
const handlers = this.#handlers.get(event.kind) ?? [];
awaitPromise.all(handlers.map(h =>h(event)));
}
}
// Usage — fully typed handlersconst bus = newPaymentEventBus();
// The handler receives Extract<PaymentEvent, { kind: 'PAYMENT_AUTHORIZED' }>// — the compiler knows this is { kind: 'PAYMENT_AUTHORIZED'; transactionId: string; amount: number; orderId: string }const cleanup = bus.on('PAYMENT_AUTHORIZED', async (event) => {
// event.transactionId — ✅ TypeScript knows this exists// event.reason — ❌ TS Error: Property 'reason' does not exist on this event typeconsole.log(`Order ${event.orderId} authorized: ${event.transactionId}`);
awaitsendConfirmationEmail(event.orderId, event.amount);
});
// PaymentService emits — never knows who is listeningawait bus.emit({ kind: 'PAYMENT_AUTHORIZED', transactionId: 'txn_001', amount: 5000, orderId: 'ord_abc' });
// Cleanup on service disposalcleanup(); // Removes the handler — no memory leak
Pro Tip & Optimization
The on() method returns a cleanup function. This is the Observer pattern's equivalent of Symbol.dispose (Part 4) — always capture the cleanup handle and call it when the subscriber is torn down. In a Node.js request handler: const cleanup = bus.on(...); try { ... } finally { cleanup(); }.
The Command pattern encapsulates an operation as an object, decoupling the requester from the executor. When combined with an undo/redo stack, it enables safe rollback of financial operations in the fintech domain.
Two-column diagram. LEFT column labeled 'Observer Pattern — Decoupled Event Notification' in cyan: shows 'PaymentService' at top emitting 'PAYMENT_AUTHORIZED…
In Part 11, we conquer the two most complex behavioral patterns: State Machines and the Chain of Responsibility. Part 11: Behavioral Patterns: State Machines, Chains & Mediators builds a finite state machine for order lifecycle management using discriminated unions as states, a middleware chain for payment request validation, and a Mediator that coordinates aggregate roots without coupling them to each other.
Research & Synthesis Note
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.