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:
- Multiple objects need to communicate and the number of connections grows as N*(N-1)/2
- Changing one component requires changes to many others
- Components can't be reused because they're hardwired to specific partners
- 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:
- Invalid transitions throw errors immediately rather than silently doing nothing
- Each state class has only the methods that make sense in that state
- Adding a new state (e.g., "ReturnRequested") means adding one class, not modifying every method
- 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.