Siddhant Deval
Siddhant Deval
system design19 min read

Behavioral Patterns: Strategy, Observer & Command Pipelines

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.

Behavioral Patterns: Strategy, Observer & Command

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.


1. The Anti-Pattern Graveyard: The God Notification Handler

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 place

class PaymentService {
  async processPayment(orderId: string, amount: number): Promise<void> {
    // ... payment logic ...
    const transactionId = `txn_${Date.now()}`;

    // All notification logic hardcoded here — SRP violation, OCP violation
    await this.sendEmail(orderId, transactionId);       // Marketing wants to control this
    await this.updateInventory(orderId);                // Inventory team owns this
    await this.notifyFraudDetection(transactionId);    // Security team owns this
    await this.sendPushNotification(orderId);          // Mobile team owns this
    await this.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.


2. Strategy Pattern: Type-Safe Interchangeable Algorithms

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.

2.1 Discount Strategy

TYPESCRIPT
// Strategy interface — shared signature for all discount algorithms
interface IDiscountStrategy {
  apply(originalPriceCents: number, context: OrderContext): number; // Returns discounted price
  describe(): string;
}

type OrderContext = {
  customerId: string;
  orderTotal: number;
  itemCount: number;
  isLoyaltyMember: boolean;
};

// Concrete strategies — each encapsulates one algorithm
class PercentageDiscount implements IDiscountStrategy {
  constructor(private readonly rate: number) {}
  apply(price: number, _ctx: OrderContext): number {
    return Math.round(price * (1 - this.rate));
  }
  describe(): string { return `${(this.rate * 100).toFixed(0)}% off`; }
}

class LoyaltyDiscount implements IDiscountStrategy {
  apply(price: number, ctx: OrderContext): number {
    return ctx.isLoyaltyMember ? Math.round(price * 0.9) : price; // 10% for loyalty members
  }
  describe(): string { return '10% loyalty member discount'; }
}

class BulkDiscount implements IDiscountStrategy {
  constructor(private readonly minItems: number, private readonly rate: number) {}
  apply(price: number, ctx: OrderContext): number {
    return ctx.itemCount >= this.minItems ? Math.round(price * (1 - this.rate)) : price;
  }
  describe(): string { return `${(this.rate * 100).toFixed(0)}% off when ${this.minItems}+ items`; }
}

// ✅ Type-safe strategy registry with satisfies
type DiscountKey = 'seasonal_10pct' | 'loyalty' | 'bulk_5pct';

const discountStrategies = {
  seasonal_10pct: new PercentageDiscount(0.10),
  loyalty:        new LoyaltyDiscount(),
  bulk_5pct:      new BulkDiscount(5, 0.05),
} satisfies Record<DiscountKey, IDiscountStrategy>;

// Pricing engine — closed for modification, open for strategy extension
class PricingEngine {
  calculatePrice(
    originalPrice: number,
    strategyKey: DiscountKey,
    context: OrderContext,
  ): { finalPrice: number; description: string } {
    const strategy = discountStrategies[strategyKey];
    return {
      finalPrice: strategy.apply(originalPrice, context),
      description: strategy.describe(),
    };
  }
}

3. Observer Pattern: Type-Safe Generic Event Bus

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.

3.1 Type-Safe EventBus with Discriminated Unions

TYPESCRIPT
// Domain events — discriminated union ensures exhaustive handling
type PaymentEvent =
  | { 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 inference
type EventHandler<T> = (event: T) => void | Promise<void>;

class PaymentEventBus {
  // Map from event kind to list of handlers
  #handlers = new Map<string, Array<EventHandler<PaymentEvent>>>();

  on<K extends PaymentEvent['kind']>(
    kind: K,
    handler: EventHandler<Extract<PaymentEvent, { kind: K }>>,
  ): () => void { // Returns cleanup function
    const handlers = this.#handlers.get(kind) ?? [];
    handlers.push(handler as EventHandler<PaymentEvent>);
    this.#handlers.set(kind, handlers);
    return () => this.off(kind, handler as EventHandler<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 extends PaymentEvent['kind']>(
    event: Extract<PaymentEvent, { kind: K }>,
  ): Promise<void> {
    const handlers = this.#handlers.get(event.kind) ?? [];
    await Promise.all(handlers.map(h => h(event)));
  }
}

// Usage — fully typed handlers
const bus = new PaymentEventBus();

// 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 type
  console.log(`Order ${event.orderId} authorized: ${event.transactionId}`);
  await sendConfirmationEmail(event.orderId, event.amount);
});

// PaymentService emits — never knows who is listening
await bus.emit({ kind: 'PAYMENT_AUTHORIZED', transactionId: 'txn_001', amount: 5000, orderId: 'ord_abc' });

// Cleanup on service disposal
cleanup(); // 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(); }.


4. Command Pattern: Undo/Redo Ledger Operations

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.

4.1 Command Interface and Ledger Commands

TYPESCRIPT
// Command interface — every operation must be reversible
interface ILedgerCommand {
  execute(): void;
  undo(): void;
  describe(): string;
}

// Concrete commands
class DebitCommand implements ILedgerCommand {
  constructor(
    private readonly ledger: LedgerAccount,
    private readonly amountCents: number,
    private readonly reason: string,
  ) {}

  execute(): void {
    this.ledger.debit(this.amountCents);
  }

  undo(): void {
    this.ledger.credit(this.amountCents); // Reverse: credit the amount back
  }

  describe(): string {
    return `DEBIT ${this.amountCents} cents — ${this.reason}`;
  }
}

class CreditCommand implements ILedgerCommand {
  constructor(
    private readonly ledger: LedgerAccount,
    private readonly amountCents: number,
    private readonly reason: string,
  ) {}

  execute(): void { this.ledger.credit(this.amountCents); }
  undo(): void    { this.ledger.debit(this.amountCents); }
  describe(): string { return `CREDIT ${this.amountCents} cents — ${this.reason}`; }
}

// Macro command — composite of multiple commands executed as one unit
class TransferCommand implements ILedgerCommand {
  private readonly commands: ILedgerCommand[];

  constructor(
    private readonly fromAccount: LedgerAccount,
    private readonly toAccount: LedgerAccount,
    private readonly amountCents: number,
  ) {
    this.commands = [
      new DebitCommand(fromAccount, amountCents, `Transfer to ${toAccount.accountId}`),
      new CreditCommand(toAccount, amountCents, `Transfer from ${fromAccount.accountId}`),
    ];
  }

  execute(): void { this.commands.forEach(c => c.execute()); }
  undo(): void    { [...this.commands].reverse().forEach(c => c.undo()); }
  describe(): string { return `TRANSFER ${this.amountCents} cents between accounts`; }
}

class LedgerAccount {
  #balance: number;
  readonly accountId: string;

  constructor(accountId: string, initialBalance: number) {
    this.accountId = accountId;
    this.#balance = initialBalance;
  }

  debit(amount: number): void {
    if (this.#balance < amount) throw new Error('Insufficient funds');
    this.#balance -= amount;
  }

  credit(amount: number): void { this.#balance += amount; }

  get balance(): number { return this.#balance; }
}

4.2 The Command Processor with Undo/Redo Stack

TYPESCRIPT
class LedgerCommandProcessor {
  readonly #history: ILedgerCommand[] = [];
  readonly #redoStack: ILedgerCommand[] = [];

  execute(command: ILedgerCommand): void {
    command.execute();
    this.#history.push(command);
    this.#redoStack.length = 0; // New command clears redo stack
    console.log(`[CMD] Executed: ${command.describe()}`);
  }

  undo(): void {
    const command = this.#history.pop();
    if (!command) throw new Error('Nothing to undo');
    command.undo();
    this.#redoStack.push(command);
    console.log(`[CMD] Undone: ${command.describe()}`);
  }

  redo(): void {
    const command = this.#redoStack.pop();
    if (!command) throw new Error('Nothing to redo');
    command.execute();
    this.#history.push(command);
    console.log(`[CMD] Redone: ${command.describe()}`);
  }

  getHistory(): ReadonlyArray<string> {
    return this.#history.map(c => c.describe());
  }
}

// Usage
const alice = new LedgerAccount('acc_alice', 100_00); // $100.00
const bob   = new LedgerAccount('acc_bob',   50_00);  // $50.00

const processor = new LedgerCommandProcessor();

processor.execute(new TransferCommand(alice, bob, 30_00)); // Alice → Bob: $30
console.log(alice.balance); // 7000 (= $70.00)
console.log(bob.balance);   // 8000 (= $80.00)

processor.undo(); // Reverse the transfer
console.log(alice.balance); // 10000 (= $100.00 restored)
console.log(bob.balance);   // 5000  (= $50.00 restored)

processor.redo(); // Re-apply
console.log(alice.balance); // 7000 again
Two-column diagram. LEFT column labeled 'Observer Pattern — Decoupled Event Notification' in cyan: shows 'PaymentService' at top emitting 'PAYMENT_AUTHORIZED' event via an arrow to 'EventBus' box. From the EventBus, three separate arrows point to three subscriber boxes: 'EmailService (on PAYMENT_AUTHORIZED)', 'InventoryService (on PAYMENT_AUTHORIZED)', 'FraudDetection (on PAYMENT_AUTHORIZED)'. Each subscriber box is independent. Cyan callout: 'PaymentService never imports subscriber classes — zero coupling'. RIGHT column labeled 'Command Pattern — Undo/Redo Queue' in violet: shows a vertical timeline of command objects in a stack. Top: 'DebitCommand (execute)' → 'CreditCommand (execute)' → 'TransferCommand (execute)'. A ↩ arrow labeled 'undo()' reverses the stack. A ↪ arrow labeled 'redo()' re-applies. Violet callout: 'Each command is an object — serialize, audit, or replay entire ledger history'.
Two-column diagram. LEFT column labeled 'Observer Pattern — Decoupled Event Notification' in cyan: shows 'PaymentService' at top emitting 'PAYMENT_AUTHORIZED…

5. Interview Application: Which Behavioral Pattern?

Scenario Pattern 60-Second Sketch
"Plug in different pricing algorithms" Strategy interface IDiscountStrategy { apply() } + registry
"Notify multiple services when payment completes" Observer EventBus.emit('PAYMENT_AUTHORIZED', event)
"Support undo for financial operations" Command interface ICommand { execute(); undo() } + history stack
"Transform a request through multiple stages" Chain of Responsibility handler.setNext(nextHandler) — Part 11
"Represent payment workflow states" State Machine Part 11

Summary

Pattern Core Abstraction TypeScript 5+ Idiom
Strategy Interchangeable algorithms behind a shared interface Registry satisfies Record<K, IStrategy> — exhaustiveness via satisfies
Observer Decoupled publish/subscribe notification Generic EventBus<T extends { kind: string }> + discriminated union payload
Command Encapsulated, reversible operations interface ICommand { execute(); undo() } + #history and #redoStack
Observer cleanup Subscription teardown prevents memory leaks on() returns cleanup function; call in finally block
Macro Command Atomic multi-step operations with atomic undo TransferCommand contains array of child commands

What's Next

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.

#TypeScript#Design Patterns#Strategy Pattern#Observer Pattern#Command Pattern#LLD Interview
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.