Design Patterns & LLD

Behavioural Patterns: Strategy, Observer, Command, State and Chain of Responsibility

The five main behavioural patterns plus iterator and template method, with runnable TypeScript.

Lesson 0320 min read#behavioural-patterns#state-machine#observer

On this page

  1. Words used in this lesson
  2. Strategy: swap the rule
  3. Observer: tell everyone who cares
  4. Command: a request as an object
  5. State: behaviour depends on state
  6. Chain of Responsibility: pass it along
  7. Two smaller ones
  8. Choosing quickly
  9. Interview questions
  10. Key takeaways
  11. What's next

In one line: behavioural patterns are about how objects share work and talk to each other. Strategy swaps a rule, Observer tells many listeners about a change, Command turns a request into an object (so you can queue and undo it), State changes behaviour when the state changes, and Chain of Responsibility passes a request along until someone handles it. Iterator and Template Method are two smaller ones you meet daily.

Lesson 1 was about creating objects and Lesson 2 about joining them. This lesson is about behaviour: who decides, who is told, and who does the work. This is the group that interview problems use most. A vending machine is State. A notification system is Observer and Strategy. An editor with undo is Command. A support ticket router is Chain of Responsibility.

What I ran / checked. I ran every TypeScript block with Node 22.22.0 (node --experimental-transform-types) and pasted the real output. All files also pass tsc --strict (TypeScript 7.0.2). Pattern names and intents are general knowledge of the classic 1994 catalogue; I quote no text from it. I could not open one popular pattern guide on GitHub from this sandbox. The "strategy versus state" and "observer versus pub/sub" comparisons are my own summary of common practice.

Words used in this lesson

  • A behavioural pattern is a pattern about how objects interact and divide responsibility.
  • A strategy is a swappable rule or algorithm behind an interface.
  • A subject (or publisher) is the object that has news. An observer (or listener, subscriber) is an object that wants to hear it.
  • A command is an object that stands for one action and everything needed to do it (and maybe undo it).
  • An undo stack is a list of done commands, newest last. Undo pops the last one and reverses it.
  • A state machine is a model with a fixed set of states and rules for moving between them.
  • A handler is an object that may deal with a request. A chain is handlers linked in order.
  • An iterator is an object that gives you the items of a collection one at a time. A generator is a function marked function* that can pause with yield and so makes iterators easily.
  • Lazy means producing a value only when asked.
  • A hook is a step in an algorithm that children may override but do not have to.

Strategy: swap the rule

Your cart has discounts. Today it is 10 percent. Next month it is "5 off", then "bulk". If the rule is an if/else inside Cart, every new rule edits Cart. With Strategy the rule is a separate object behind one interface.

JavaScript
// The rule is an object you plug in, not an if/else inside Cart.interface DiscountStrategy { apply(total: number): number; }const none: DiscountStrategy = { apply: t => t };const tenPercent: DiscountStrategy = { apply: t => t * 0.9 };const flat5: DiscountStrategy = { apply: t => Math.max(0, t - 5) };const bulk = (minQty: number, pct: number) => (qty: number): DiscountStrategy =>  ({ apply: t => (qty >= minQty ? t * (1 - pct) : t) });class Cart {  private items: number[] = [];  constructor(private strategy: DiscountStrategy) {}  add(price: number) { this.items.push(price); return this; }  setStrategy(s: DiscountStrategy) { this.strategy = s; }     // change at run time  total() { return this.strategy.apply(this.items.reduce((a, b) => a + b, 0)); }}const cart = new Cart(none).add(40).add(60);console.log(cart.total());cart.setStrategy(tenPercent);console.log(cart.total());cart.setStrategy(flat5);console.log(cart.total());cart.setStrategy(bulk(2, 0.25)(2));console.log(cart.total());// In JavaScript a strategy is often just a function. Array.sort takes one.const words = ["pear", "fig", "banana"];console.log([...words].sort((a, b) => a.length - b.length));console.log([...words].sort((a, b) => b.localeCompare(a)));

Output:

TEXT
100909575[ 'fig', 'pear', 'banana' ][ 'pear', 'fig', 'banana' ]

Cart never learned how a discount works. We changed the strategy at run time with setStrategy. The last two lines show the best news for JavaScript developers: a strategy is often just a function. Array.sort takes one (the comparator), and two different sorts came from the same list.

When to use it: you can name the rule, the rule may change or vary per customer, and you may want to test each rule alone. When not to: two fixed branches that never change.

Observer: tell everyone who cares

A price changes. The log, an alert and a chart all want to know. The price code should not call all of them by name. Observer lets listeners subscribe, and the subject publishes to whoever is subscribed.

JavaScript
type Listener<T> = (event: T) => void;class Subject<T> {  private listeners = new Set<Listener<T>>();  subscribe(fn: Listener<T>): () => void {      // returns an "unsubscribe" function    this.listeners.add(fn);    return () => { this.listeners.delete(fn); };  }  publish(event: T) {    for (const fn of [...this.listeners]) {     // copy: listeners may unsubscribe while we loop      try { fn(event); } catch (e) { console.log("  listener failed:", (e as Error).message); }    }  }  get count() { return this.listeners.size; }}type PriceChange = { symbol: string; price: number };const prices = new Subject<PriceChange>();const offLog = prices.subscribe(e => console.log("log:", e.symbol, e.price));prices.subscribe(e => { if (e.price < 0) throw new Error("bad price"); });const offAlert = prices.subscribe(e => e.price > 100 && console.log("alert: high", e.symbol));prices.publish({ symbol: "ABC", price: 120 });prices.publish({ symbol: "XYZ", price: -1 });     // one listener throws, the others still runoffLog();                                          // forget to do this and the listener lives onoffAlert();console.log("listeners left:", prices.count);prices.publish({ symbol: "ABC", price: 130 });

Output:

TEXT
log: ABC 120alert: high ABClog: XYZ -1  listener failed: bad pricelisteners left: 1

Four things are visible in the output. All three listeners ran for the first event. The second event made one listener throw, and the others still ran (log: XYZ -1 came out before the failure line), because publish catches errors per listener. After offLog() and offAlert(), only one listener was left, and the last publish printed nothing, since the remaining listener prints only on errors. Two design choices matter: subscribe returns an unsubscribe function, and publish loops over a copy of the set, so a listener can unsubscribe in the middle of a broadcast without breaking the loop.

The classic bug is the forgotten unsubscribe. A listener that is never removed keeps its owner alive in memory (a leak) and keeps running after its owner is "gone". If an object subscribes in its constructor, give it a dispose() that unsubscribes.

Observer is the idea behind EventEmitter, DOM events and many UI libraries. Lesson 4 looks at those. One honest limit: listeners that publish new events can make the flow hard to follow.

Command: a request as an object

Normally a click calls editor.insert(...) and it is done. If you turn the action into an object with do() and undo(), you can store it, queue it, log it, send it, replay it, and undo it.

JavaScript
interface Command { do(): void; undo(): void; }class Editor {  text = "";  private undoStack: Command[] = [];  private redoStack: Command[] = [];  run(cmd: Command) { cmd.do(); this.undoStack.push(cmd); this.redoStack = []; }  undo() { const c = this.undoStack.pop(); if (c) { c.undo(); this.redoStack.push(c); } }  redo() { const c = this.redoStack.pop(); if (c) { c.do(); this.undoStack.push(c); } }}class Insert implements Command {  constructor(private ed: Editor, private at: number, private s: string) {}  do() { this.ed.text = this.ed.text.slice(0, this.at) + this.s + this.ed.text.slice(this.at); }  undo() { this.ed.text = this.ed.text.slice(0, this.at) + this.ed.text.slice(this.at + this.s.length); }}class Delete implements Command {  private removed = "";  constructor(private ed: Editor, private from: number, private len: number) {}  do() {    this.removed = this.ed.text.slice(this.from, this.from + this.len);   // remember for undo    this.ed.text = this.ed.text.slice(0, this.from) + this.ed.text.slice(this.from + this.len);  }  undo() { this.ed.text = this.ed.text.slice(0, this.from) + this.removed + this.ed.text.slice(this.from); }}const ed = new Editor();ed.run(new Insert(ed, 0, "hello world"));ed.run(new Delete(ed, 5, 6));console.log(JSON.stringify(ed.text));ed.undo();console.log(JSON.stringify(ed.text));ed.undo();console.log(JSON.stringify(ed.text));ed.redo();console.log(JSON.stringify(ed.text));ed.run(new Insert(ed, 5, "!"));            // a new command clears the redo stacked.redo();console.log(JSON.stringify(ed.text));

Output:

TEXT
"hello""hello world""""hello world""hello! world"

Follow the text. After insert and delete the text is "hello". The first undo brought " world" back (the delete remembered what it removed). The second undo emptied the text. redo replayed the insert. At the end, running a new command (!) cleared the redo stack, so the next redo did nothing. That is how most editors behave.

Two rules make undo work. A command must store what it needs to reverse itself (Delete kept removed). And commands are applied only through Editor.run, so the stacks stay correct. The same shape gives you job queues (a command is a job), macros (a list of commands) and audit logs.

State: behaviour depends on state

An order can be pending, paid, shipped or cancelled. pay() is fine only when pending. If you write that with if (status === ...) in every method, the rules are spread out and easy to break. In the State pattern each state is an object that decides what each action does, and it switches the order to the next state.

JavaScript
// State pattern: each state is an object that knows what is allowed next.interface OrderState {  readonly name: string;  pay(o: Order): void;  ship(o: Order): void;  cancel(o: Order): void;}const illegal = (what: string, from: string) => { throw new Error(`cannot ${what} when ${from}`); };const Cancelled: OrderState = {  name: "cancelled",  pay: o => illegal("pay", "cancelled"), ship: o => illegal("ship", "cancelled"), cancel: o => illegal("cancel", "cancelled"),};const Shipped: OrderState = {  name: "shipped",  pay: o => illegal("pay", "shipped"), ship: o => illegal("ship", "shipped"), cancel: o => illegal("cancel", "shipped"),};const Paid: OrderState = {  name: "paid",  pay: o => illegal("pay", "paid"), ship: o => { o.state = Shipped; }, cancel: o => { o.state = Cancelled; },};const Pending: OrderState = {  name: "pending",  pay: o => { o.state = Paid; }, ship: o => illegal("ship", "pending"), cancel: o => { o.state = Cancelled; },};class Order {  state: OrderState = Pending;  pay() { this.state.pay(this); }  ship() { this.state.ship(this); }  cancel() { this.state.cancel(this); }}const o = new Order();const attempt = (label: string, f: () => void) => {  try { f(); console.log(label, "->", o.state.name); }  catch (e) { console.log(label, "failed:", (e as Error).message); }};attempt("ship", () => o.ship());attempt("pay", () => o.pay());attempt("pay again", () => o.pay());attempt("ship", () => o.ship());attempt("cancel", () => o.cancel());

Output:

TEXT
ship failed: cannot ship when pendingpay -> paidpay again failed: cannot pay when paidship -> shippedcancel failed: cannot cancel when shipped

ship on a pending order failed. pay moved it to paid, a second pay failed, ship moved it to shipped, and cancel after shipping failed. The Order class has no if about status: it forwards to its current state. A new state (say refunded) is a new object, and the rules of that state are all in one place.

A table of transitions is a smaller alternative: a Map from state:event to the next state. It is easier to print and test. I use a table when states have no special behaviour beyond "what comes next", and State objects when each state does real, different work. Lesson 7 builds a vending machine and an elevator this way.

Strategy versus State. The code is almost the same: an object holds a "current" object and forwards calls. The intent differs. A strategy is chosen from outside and usually stays (a discount). A state changes itself as things happen, and often picks its own successor.

Chain of Responsibility: pass it along

A purchase request needs approval. Up to 1,000 a lead can approve. Up to 10,000 a director. Above that, a VP. The sender should not know who is right. In a chain, each handler either handles the request or passes it to the next.

JavaScript
type Request = { amount: number; by: string };interface Handler { setNext(h: Handler): Handler; handle(r: Request): string; }abstract class BaseHandler implements Handler {  private next?: Handler;  setNext(h: Handler) { this.next = h; return h; }          // returns h so calls chain  handle(r: Request): string {    return this.canHandle(r) ? this.process(r) : this.next ? this.next.handle(r) : `nobody approved ${r.amount}`;  }  protected abstract canHandle(r: Request): boolean;  protected abstract process(r: Request): string;}class Limit extends BaseHandler {  constructor(private role: string, private max: number) { super(); }  protected canHandle(r: Request) { return r.amount <= this.max; }  protected process(r: Request) { return `${this.role} approved ${r.amount} for ${r.by}`; }}const lead = new Limit("lead", 1_000);lead.setNext(new Limit("director", 10_000)).setNext(new Limit("vp", 100_000));for (const amount of [200, 5_000, 50_000, 500_000]) console.log(lead.handle({ amount, by: "ann" }));// A function-style chain: each step returns a result or passes on (undefined).type Step = (r: Request) => string | undefined;const steps: Step[] = [  r => (r.amount < 0 ? "rejected: negative" : undefined),  r => (r.by === "" ? "rejected: no requester" : undefined),  r => (r.amount <= 1_000 ? "auto-approved" : undefined),];const run = (r: Request) => steps.map(s => s(r)).find(x => x !== undefined) ?? "needs a human";console.log(run({ amount: -1, by: "x" }), "|", run({ amount: 10, by: "x" }), "|", run({ amount: 5000, by: "x" }));

Output:

TEXT
lead approved 200 for anndirector approved 5000 for annvp approved 50000 for annnobody approved 500000rejected: negative | auto-approved | needs a human

Each amount stopped at the first handler that could take it. 500,000 fell off the end, so we must decide what "nobody" means (here a message; in real code maybe an error). The second half is a light function-style chain: a list of steps, each returning a result or undefined to pass on. This is closer to how middleware is written in practice.

When to use it: the sender should not know the receiver, the set of handlers may change, or order matters (validation steps, approval levels, support tiers). Drawbacks: a request can end unhandled, and it is harder to see the flow when it is long. Lesson 4 shows that Express and Koa middleware is this pattern.

Two smaller ones

Iterator: walk without knowing the insides

An iterator gives the items of a collection one by one without exposing how they are stored. JavaScript has it built in: any object with a [Symbol.iterator]() method works with for...of, spread and destructuring.

JavaScript
class Range {  constructor(private from: number, private to: number, private step = 1) {}  *[Symbol.iterator](): Iterator<number> {            // a generator makes the iterator for us    for (let i = this.from; i < this.to; i += this.step) {      console.log("  (producing", i + ")");      yield i;    }  }}const r = new Range(0, 100, 10);for (const n of r) { if (n >= 20) break; console.log("got", n); }   // stops early, lazyconsole.log([...new Range(0, 5)]);// Manual iterator protocol: an object with next() that returns { value, done }.const it = new Range(0, 2)[Symbol.iterator]();console.log(it.next(), it.next(), it.next());

Output:

TEXT
  (producing 0)got 0  (producing 10)got 10  (producing 20)  (producing 0)  (producing 1)  (producing 2)  (producing 3)  (producing 4)[ 0, 1, 2, 3, 4 ]  (producing 0)  (producing 1){ value: 0, done: false } { value: 1, done: false } { value: undefined, done: true }

The loop broke at 20, and the generator produced only 0, 10 and 20. It is lazy: nothing was computed past what the loop asked for. [...new Range(0, 5)] pulled everything, and the manual part shows the raw protocol: next() returns { value, done }. Use iterators when the collection is big or infinite, or when you want callers to be free of the internal layout (a tree, a paged API).

Template Method: fixed steps, variable parts

The parent class fixes the order of steps. Children fill in the steps that vary. Some steps are hooks with a safe default.

JavaScript
// Template Method: the parent fixes the order of steps; children fill in some steps.abstract class Importer {  run(raw: string): string {    const rows = this.parse(raw);    const good = rows.filter(r => this.validate(r));    this.save(good);    return `${this.name()}: saved ${good.length} of ${rows.length}`;  }  protected abstract name(): string;  protected abstract parse(raw: string): string[][];  protected validate(row: string[]): boolean { return row.every(c => c !== ""); }   // hook with a default  protected save(rows: string[][]): void { /* default: do nothing */ }}class CsvImporter extends Importer {  protected name() { return "csv"; }  protected parse(raw: string) { return raw.split("\n").map(l => l.split(",")); }}class PipeImporter extends Importer {  protected name() { return "pipe"; }  protected parse(raw: string) { return raw.split("\n").map(l => l.split("|")); }  protected validate(row: string[]) { return row.length === 2; }          // override the hook}console.log(new CsvImporter().run("a,1\nb,\nc,3"));console.log(new PipeImporter().run("a|1\nb|2|x\nc|3"));

Output:

TEXT
csv: saved 2 of 3pipe: saved 2 of 3

run is written once: parse, validate, save. CsvImporter only supplies parse. PipeImporter also overrides the validate hook. Both saved two of three rows, for different reasons. The Factory Method in Lesson 1 is a special case of this idea. Its danger is the same as any inheritance: children depend on the parent's steps. If the variations are small, pass functions (strategies) in instead and avoid the class tree.

Choosing quickly

Your needPattern
"This rule will change or differ per case"Strategy
"Many things must react when X happens"Observer
"I need undo, queue, retry or audit of actions"Command
"Behaviour differs by status and the status changes"State
"A request passes through levels or checks"Chain of Responsibility
"Walk a collection without exposing it"Iterator
"Same steps, a few steps differ"Template Method (or Strategy)

Interview questions

Strategy or State: what is the difference? (Must know)

The structure is the same: a context object holds another object and forwards to it. A strategy is an algorithm chosen from outside, such as a discount, and it normally stays the same. A state is the object's own condition, and it changes as events happen, often choosing the next state itself. My test: if the object swaps its own helper as it lives, it is State. If the caller picks a rule, it is Strategy.

When would you use Strategy over an if/else? (Must know)

When the rule has a name, can grow, can differ per customer or per setting, or needs to be tested on its own. With two stable branches I keep the if. When I move to Strategy I make sure the new strategies fit an interface that the context owns. In JavaScript the strategy is often a plain function, as in a sort comparator.

Explain Observer. How do you avoid memory leaks? (Must know)

A subject keeps a list of listeners and calls them when something happens. The leak comes from a listener that is never removed: the subject keeps a reference, so the listener and its owner cannot be freed. I make subscribe return an unsubscribe function and give owners a dispose() that calls it. I also loop over a copy of the listener set so unsubscribing during a broadcast is safe.

What happens if one observer throws? (Senior signal)

By default, one throwing listener can stop the loop and the rest never hear the event. In my example, publish catches errors per listener and logs them, so one bad listener does not hurt the others. The right choice depends on the case: for notifications I isolate failures, for validation I may want the error to stop the action.

Observer versus pub/sub? (Senior signal)

In the classic Observer, listeners subscribe directly to the subject and are called in the same process. In pub/sub there is a broker or topic between publisher and subscriber, so they do not know each other, and it may cross processes, as with a message queue (System Design, Lesson 35). The Observer in a class is the small, in-memory cousin.

How would you implement undo and redo? (Must know)

With the Command pattern. Each action is an object with do() and undo(). The editor keeps an undo stack and a redo stack. Doing a command pushes it on the undo stack and clears the redo stack. Undo pops, calls undo() and pushes onto redo. A command stores what it needs to reverse itself, such as the deleted text. If commands are big, I would store only differences.

Follow-up: "What about undo of a command that called an API?" Not always possible. I would use a compensating action (refund, cancel) if the business allows it, and I would say clearly that some actions cannot be undone.

Model a vending machine or an order. Which pattern? (Must know)

State. I list the states (idle, has money, dispensing, out of stock), the events (insert coin, select, refund), and for each state which events are valid. I implement it as state objects if states do different work, or as a transition table if they mainly decide the next state. Invalid events throw a clear error, as ship when pending did in my example. Lesson 7 builds this in full.

What is Chain of Responsibility, and where do you see it? (Must know)

A request passes through a series of handlers. Each can handle it, change it, or pass it on. Examples: approval levels, a support escalation, validation steps, and web middleware (Express, Koa). The sender does not know who handles it. The costs are that a request may go unhandled and a long chain is hard to debug, so I decide what "end of chain" means.

How is Command different from Strategy? (Trap)

Both wrap behaviour in an object. A strategy is a way to compute something, called many times, and it carries no record of one action. A command represents one particular request with its data. It is created per action and is often stored, queued, logged or undone. If you need history, replay or a queue, it is Command.

How does for...of work with your own class? (Senior signal)

It calls obj[Symbol.iterator](), which must return an object with next() giving { value, done }. A generator method (*[Symbol.iterator]()) is the easy way to write it. My Range shows two benefits: lazy production, since the loop stopped early and no more values were made, and a hidden internal layout.

Template Method or Strategy? (Senior signal)

Template Method uses inheritance: a parent fixes the steps and children override some. Strategy uses composition: an object holds a replaceable part. I prefer Strategy because it can change at run time and avoids a class tree. I choose Template Method when the order of steps is the main idea and there are several mandatory steps to fill in, as with an importer.

Which patterns would you use for a notification system? (Senior signal)

Strategy for the channel (email, SMS, push) behind a Notifier interface, with a factory to pick one from a user setting. Observer so that the order service can publish "order shipped" without knowing who listens. Decorator for retry and logging around a notifier. Chain of Responsibility if the message must go through rules (quiet hours, rate limit, user preference). I say which one I would build first and which one I would wait for until needed.

Key takeaways

  • Strategy swaps a rule behind an interface. In JavaScript it is often a function.
  • Observer broadcasts to subscribers. Return an unsubscribe function, isolate listener errors, and never forget to unsubscribe.
  • Command makes an action an object, which gives undo, redo, queues and logs. Store what you need to reverse it.
  • State puts the rules of each status in one object each, so there are no scattered if (status ...) checks. A transition table is the lighter version.
  • Chain of Responsibility passes a request along handlers. Decide what happens at the end of the chain.
  • Iterator and generators give lazy, hidden-layout traversal. Template Method fixes the order of steps. Prefer Strategy if the variations are small.

What's next

Next: Patterns you already use: middleware, hooks, dependency injection and anti-patterns, Lesson 4. Practise: take a status field in a project of yours, list its states and valid transitions on paper, and decide whether a table or State objects fit better.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Part of the study guide

Related series