Episode 9 — System Design / 9.5 — Behavioral Design Patterns

9.5 Interview Questions -- Behavioral Design Patterns


Q1: What is the Observer pattern and where have you used it?

Model Answer:

The Observer pattern defines a one-to-many dependency so that when a subject changes state, all registered observers are automatically notified. The subject maintains a list of observers and provides subscribe, unsubscribe, and notify methods.

Real-world usage:

JavaScript is built on this pattern. The DOM event system (addEventListener/removeEventListener) is the Observer pattern. React's useState and useEffect are observer mechanisms -- when state changes, dependent components re-render. Node.js EventEmitter is the canonical implementation.

// Minimal Observer implementation
class EventEmitter {
  constructor() {
    this.listeners = new Map();
  }
  on(event, fn) {
    if (!this.listeners.has(event)) this.listeners.set(event, new Set());
    this.listeners.get(event).add(fn);
    return () => this.listeners.get(event).delete(fn); // return unsubscribe
  }
  emit(event, ...args) {
    this.listeners.get(event)?.forEach(fn => fn(...args));
  }
}

Key considerations:

  • Always provide an unsubscribe mechanism to prevent memory leaks
  • Handle errors in observers so one bad observer doesn't break the notification chain
  • Be cautious of notification storms when multiple properties change rapidly

Q2: Explain the difference between Strategy and State patterns

Model Answer:

Both patterns delegate behavior to an encapsulated object that can be swapped, but they differ in who controls the transition and why it changes.

Strategy Pattern:

  • The client explicitly chooses which algorithm to use
  • Strategies are independent -- they don't know about each other
  • Behavior changes because the client decided to use a different algorithm
  • Example: User picks a payment method (credit card, PayPal, crypto)

State Pattern:

  • The state objects themselves trigger transitions based on business rules
  • States know about other states (they create the next state)
  • Behavior changes because the internal state changed
  • Example: An order automatically moves from PAID to PROCESSING to SHIPPED
// STRATEGY: Client chooses
processor.setStrategy(new PayPalStrategy());  // Client's explicit choice
processor.process(100);

// STATE: Automatic transition
order.pay();     // PendingState -> PaidState (state object triggers it)
order.process(); // PaidState -> ProcessingState (state object triggers it)

The mental model: Strategy is like choosing a route on a GPS (you pick). State is like a traffic light (it changes on its own based on rules).


Q3: How does the Command pattern enable undo/redo functionality?

Model Answer:

The Command pattern encapsulates an action as an object with execute() and undo() methods. Each command stores enough information to reverse itself.

Undo/Redo architecture:

Execute: push to undoStack, clear redoStack
Undo:    pop from undoStack, call undo(), push to redoStack
Redo:    pop from redoStack, call execute(), push to undoStack
class InsertTextCommand {
  constructor(document, position, text) {
    this.document = document;
    this.position = position;
    this.text = text;
  }
  execute() {
    this.document.insertAt(this.position, this.text);
  }
  undo() {
    this.document.deleteRange(this.position, this.position + this.text.length);
  }
}

class Editor {
  constructor() {
    this.undoStack = [];
    this.redoStack = [];
  }
  execute(command) {
    command.execute();
    this.undoStack.push(command);
    this.redoStack = []; // New action invalidates redo history
  }
  undo() {
    const cmd = this.undoStack.pop();
    if (cmd) { cmd.undo(); this.redoStack.push(cmd); }
  }
  redo() {
    const cmd = this.redoStack.pop();
    if (cmd) { cmd.execute(); this.undoStack.push(cmd); }
  }
}

Key insight: The redo stack must be cleared whenever a new command is executed. Otherwise, the redo history would be inconsistent with the current document state. Composite commands (macros) undo their sub-commands in reverse order.


Q4: What is the Iterator pattern and how is it built into JavaScript?

Model Answer:

The Iterator pattern provides sequential access to elements of a collection without exposing its underlying structure. JavaScript has this pattern built into the language through two protocols:

Iterable Protocol: An object implements [Symbol.iterator]() which returns an iterator.

Iterator Protocol: An object has a next() method that returns { value, done }.

// Any object implementing Symbol.iterator is iterable
class Range {
  constructor(start, end) {
    this.start = start;
    this.end = end;
  }
  [Symbol.iterator]() {
    let current = this.start;
    const end = this.end;
    return {
      next() {
        if (current <= end) return { value: current++, done: false };
        return { done: true };
      }
    };
  }
}

// Works with all iteration constructs
for (const n of new Range(1, 5)) console.log(n);  // 1, 2, 3, 4, 5
const arr = [...new Range(1, 3)];                   // [1, 2, 3]
const [a, b] = new Range(10, 20);                   // a=10, b=11

Generator functions (function* with yield) are syntactic sugar for creating iterators. Async iterators (Symbol.asyncIterator with for await...of) handle asynchronous data sources like paginated APIs.

The key advantage is lazy evaluation -- values are computed on demand, which is essential for infinite sequences and memory-constrained environments.


Q5: How would you implement middleware in a web framework?

Model Answer:

Middleware is the Chain of Responsibility pattern applied to HTTP request processing. Each middleware is a handler that can process the request, modify it, pass it to the next handler, or short-circuit the chain.

class MiddlewareChain {
  constructor() {
    this.middlewares = [];
  }

  use(fn) {
    this.middlewares.push(fn);
    return this;
  }

  async execute(req, res) {
    let index = 0;

    const next = async () => {
      if (index < this.middlewares.length) {
        const middleware = this.middlewares[index++];
        await middleware(req, res, next);
      }
    };

    await next();
  }
}

// Usage (Express-like)
const app = new MiddlewareChain();

app.use(async (req, res, next) => {
  console.log(`${req.method} ${req.path}`);  // Logger
  await next();                                // Pass to next
});

app.use(async (req, res, next) => {
  if (!req.headers.auth) {
    res.status = 401;
    return; // Short-circuit -- don't call next()
  }
  await next();
});

app.use(async (req, res, next) => {
  res.body = { data: 'Hello!' };  // Final handler
});

Key design decisions:

  • Each middleware calls next() to pass control -- not calling it short-circuits the chain
  • Middleware can run code both BEFORE and AFTER next() (useful for timing, error handling)
  • Error-handling middleware is a special case (4 args in Express: err, req, res, next)
  • Order matters: authentication should come before authorization, logging before everything

Q6: When would you use the Mediator pattern instead of direct communication?

Model Answer:

Use Mediator when multiple objects have complex, interdependent communication that creates a web of direct references. The Mediator centralizes the interaction logic so that objects only know about the mediator, not each other.

Signs you need a Mediator:

  1. Multiple objects need to communicate and the number of connections grows as N*(N-1)/2
  2. Changing one component requires changes to many others
  3. Components can't be reused because they're hardwired to specific partners
  4. The interaction logic is scattered across many classes

Real-world example: Chat room

Without Mediator, each user would need a reference to every other user. Adding a new user means updating ALL existing users. With a Mediator (the chat room), users only know the room. The room handles message routing.

Redux/Vuex as Mediator:

In React/Vue applications, the store acts as a mediator. Components don't talk to each other directly. They dispatch actions to the store (mediator), and the store notifies relevant components. This is why large React apps use Redux -- without it, "prop drilling" creates the exact many-to-many coupling problem that Mediator solves.

Trade-off: The mediator can become a "God Object" if it accumulates too much logic. Keep mediators focused on coordination, not business logic.


Q7: How do behavioral patterns relate to SOLID principles?

Model Answer:

Each behavioral pattern supports specific SOLID principles:

Single Responsibility Principle (SRP):

  • Strategy: Each algorithm has its own class (one reason to change)
  • Command: Each action is encapsulated in its own command
  • Chain of Responsibility: Each handler has one job (auth, logging, validation)
  • State: Each state class handles behavior for one state

Open/Closed Principle (OCP):

  • Strategy: Add new algorithms without modifying the context
  • Observer: Add new observers without modifying the subject
  • Chain of Responsibility: Add new handlers without modifying existing ones
  • Template Method: Add new implementations without changing the skeleton

Liskov Substitution Principle (LSP):

  • Strategy: All strategies are interchangeable through a common interface
  • State: All states implement the same interface
  • Iterator: All iterators follow the same protocol (next/done)

Dependency Inversion Principle (DIP):

  • Strategy: Context depends on abstract strategy interface, not concrete algorithms
  • Observer: Subject depends on abstract Observer, not concrete implementations
  • Mediator: Components depend on abstract Mediator, not concrete colleagues

The underlying theme: behavioral patterns help you program to interfaces, not implementations, which is the essence of SOLID.


Q8: Explain how you would use the State pattern for a complex order workflow

Model Answer:

An order workflow is a finite state machine. Each state is a class that encapsulates the behavior allowed in that state and defines valid transitions.

PENDING -> PAID -> PROCESSING -> SHIPPED -> DELIVERED
  |          |                                    
  v          v                                    
CANCELLED  CANCELLED (with refund)               
class OrderState {
  constructor(order) { this.order = order; }
  pay() { throw new Error(`Cannot pay in ${this.constructor.name}`); }
  ship() { throw new Error(`Cannot ship in ${this.constructor.name}`); }
  cancel() { throw new Error(`Cannot cancel in ${this.constructor.name}`); }
}

class PendingState extends OrderState {
  pay(details) {
    this.order.paymentDetails = details;
    this.order.setState(new PaidState(this.order));
  }
  cancel(reason) {
    this.order.setState(new CancelledState(this.order));
  }
}

class PaidState extends OrderState {
  ship(tracking) {
    this.order.tracking = tracking;
    this.order.setState(new ShippedState(this.order));
  }
  cancel(reason) {
    // Initiate refund, then cancel
    this.order.refund = true;
    this.order.setState(new CancelledState(this.order));
  }
}

Benefits over if/else:

  1. Invalid transitions throw errors immediately rather than silently doing nothing
  2. Each state class has only the methods that make sense in that state
  3. Adding a new state (e.g., "ReturnRequested") means adding one class, not modifying every method
  4. State history can be tracked for auditing

Production considerations: Store the state type as a string in the database and hydrate the state object on load. Use events/observers to trigger side effects (send email on state change).


Q9: How would you design a plugin system using behavioral patterns?

Model Answer:

A robust plugin system combines multiple behavioral patterns:

Chain of Responsibility for request processing hooks -- plugins can intercept, modify, or block requests:

class Plugin {
  onRequest(request, next) {
    // Modify request, then pass along
    return next(request);
  }
}

Observer for lifecycle events -- plugins subscribe to events like app:start, user:login, data:save:

pluginManager.on('user:login', (user) => {
  // Analytics plugin reacts to login
});

Strategy for pluggable implementations -- plugins provide alternative strategies for core functions:

// Default: store files locally. Plugin provides S3 strategy
app.setStorageStrategy(new S3StoragePlugin());

Command for undoable plugin actions:

// Plugin registers custom commands that support undo
pluginManager.registerCommand('myPlugin:transform', new TransformCommand());

The plugin manager acts as a Mediator, coordinating communication between plugins without them knowing about each other.

This is exactly how systems like WordPress (PHP), VS Code (extensions), and Webpack (plugins/loaders) work.


Q10: Compare Observer pattern with the Pub/Sub pattern. When would you use each?

Model Answer:

OBSERVER (Direct):                  PUB/SUB (Via Broker):
Subject ----> Observer              Publisher -> [Broker] -> Subscriber
Subject knows observers             Publisher doesn't know subscribers
Typically synchronous               Can be asynchronous
Same process / thread               Can cross processes / machines
Tighter coupling                    Fully decoupled

Observer is used when:

  • Communication is within a single application/process
  • Subject needs to know its observer count (for backpressure, etc.)
  • Synchronous notification is acceptable
  • Examples: DOM events, React state, Vue reactivity, EventEmitter

Pub/Sub is used when:

  • Communication crosses process/service boundaries
  • Publishers and subscribers deploy independently
  • Messages need to be persisted, retried, or replayed
  • Fan-out to many subscribers at scale
  • Examples: Redis Pub/Sub, Kafka, AWS SNS/SQS, RabbitMQ

The hybrid approach: Many systems start with Observer within a service and use Pub/Sub between services. For example, a microservice uses EventEmitter internally (Observer) but publishes domain events to Kafka (Pub/Sub) for other services.

Key trade-offs:

  • Observer: simpler, faster, but creates coupling
  • Pub/Sub: more complex infrastructure, but enables independent scaling and deployment
  • Pub/Sub adds latency and potential message ordering challenges
  • Pub/Sub typically requires a message broker (additional infrastructure)

Q11: Design a transaction system using Command and State patterns together

Model Answer:

A financial transaction system naturally combines both patterns. The State pattern manages the transaction lifecycle, while the Command pattern encapsulates the atomic operations that can be undone.

// Transaction states: PENDING -> VALIDATING -> EXECUTING -> COMMITTED / ROLLED_BACK
// Each command in the transaction supports execute() and compensate()

class Transaction {
  constructor() {
    this.commands = [];
    this.executedCommands = [];
    this.state = new PendingTransactionState(this);
  }

  addCommand(command) {
    this.state.addCommand(command);
  }

  commit() {
    this.state.commit();
  }

  rollback() {
    this.state.rollback();
  }
}

class ExecutingState extends TransactionState {
  async commit() {
    for (const cmd of this.transaction.commands) {
      try {
        await cmd.execute();
        this.transaction.executedCommands.push(cmd);
      } catch (error) {
        // If any command fails, compensate all executed ones
        await this.rollback();
        throw new TransactionError('Transaction failed', { cause: error });
      }
    }
    this.transaction.setState(new CommittedState(this.transaction));
  }

  async rollback() {
    // Compensate in reverse order (like undo)
    for (let i = this.transaction.executedCommands.length - 1; i >= 0; i--) {
      await this.transaction.executedCommands[i].compensate();
    }
    this.transaction.setState(new RolledBackState(this.transaction));
  }
}

This is the Saga pattern used in distributed systems. Each service action is a command with a compensating action. If step 3 of 5 fails, steps 2 and 1 are compensated in reverse order.

State controls the lifecycle: PENDING allows adding commands, EXECUTING runs them, COMMITTED is terminal (no more changes), ROLLED_BACK triggers compensations.

Command enables rollback: Each operation knows how to reverse itself (debit -> credit, reserve -> release).

The combination is powerful: State prevents invalid operations at each stage, while Command enables atomic rollback of partial work.