Design Patterns & LLD
Design a Vending Machine and an Elevator (State Machines)
The State pattern versus a transition table, and elevator scheduling compared by floors travelled.
On this page
In one line: a vending machine and an elevator are both state machines: at any moment they are in exactly one state, and each event (a coin, a button, a floor reached) either moves them to a new state or is not allowed. Write the allowed moves down as a table first, then code it, then test every cell of the table.
These two questions are asked for the same reason: they hide a lot of if statements. A beginner writes if (hasMoney && !soldOut && !jammed ...) and the code grows into a knot. A good answer names the states, lists the events, and makes every state responsible for its own rules. The elevator adds a second idea: scheduling, which means deciding which request to serve next.
Read Lesson 3 for the State and Strategy patterns in general, and Lesson 0 for the interview frame. The previous lesson is Lesson 6.
What I ran / checked. All code ran on Node 22.22 with built-in type stripping (no compile step), and the tests use node:test. The system-design-primer's object-oriented list has hash map, LRU cache, call center, deck of cards, parking lot and chat server. It has no vending machine or elevator, so these two are my own designs, not copies of a published solution. I did not look up an official source for the elevator algorithm. I name the idea "SCAN" because that is what the disk-scheduling idea is called; I know that from general knowledge and did not verify the name in a source here. The coin values, prices and floors are made-up examples.
Words used in this lesson
- State is the situation the thing is in right now:
idle,hasMoney,doorsOpen. - Event is something that happens to it: a coin is inserted, the car arrives at a floor.
- Transition is the move from one state to another because of an event.
- State machine is the full set of states, events and allowed transitions.
- Transition table is the same thing written as a grid: rows are states, columns are events, each cell is "go to state X" or "not allowed".
- Guard is a condition on a transition, such as "only if the balance is enough".
- State pattern means: each state is its own object with the same methods, and the main object forwards every call to its current state object.
- Invalid transition is an event that a state does not accept. For example, pressing "select" before any money is in.
- Scheduling is choosing which waiting request to serve next.
Step 1. Write the table before the code
For any state machine question, I draw this first. It is the most useful minute of the interview, because the interviewer can fix a wrong rule on paper.
Vending machine
| State \ Event | insert coin | select item | cancel | repair |
|---|---|---|---|---|
| idle | go to hasMoney | not allowed | not allowed | not allowed |
| hasMoney | stay (add to balance) | sold or rejected (see below) | refund, go to idle | not allowed |
| outOfService | not allowed | not allowed | not allowed | go to idle |
"Select" in hasMoney has several outcomes, which are guards, not states:
| Check, in order | If it fails |
|---|---|
| Is the code known? | Reply "unknown code", stay in hasMoney |
| Is the item in stock? | Reply "sold out", stay |
| Is the balance enough? | Reply "insert N more cents", stay |
| Did the hardware dispense it? | Refund, go to outOfService |
| All passed | Give item and change, go to idle |
I keep two kinds of "no" apart on purpose:
- A business failure (sold out, not enough money) is an ordinary answer, returned as a result. The user can try again.
- An invalid transition (selecting with no money, inserting a coin when the machine is broken) means the caller broke the rules of the machine, so it throws
InvalidTransition. The UI layer decides what to show.
This split is a choice. Say it is a choice. Some teams return a result for both.
Step 2. The vending machine with the State pattern
Each state is an object with the same four methods. VendingMachine only forwards calls. There is no big if (state === ...) anywhere, and adding a state (say maintenanceMode) means adding one object.
export class InvalidTransition extends Error {}export type Result = { ok: true; item?: string; change?: number[] } | { ok: false; reason: string };type Product = { name: string; priceCents: number; qty: number };const COINS = [100, 25, 10, 5]; // cents; the only coins we accept and give backexport function makeChange(cents: number): number[] { const out: number[] = []; for (const c of COINS) while (cents >= c) { out.push(c); cents -= c; } if (cents !== 0) throw new Error("amount is not a multiple of 5 cents"); return out;}// Each state decides what every event does. Events a state cannot accept throw.interface VState { readonly name: string; insert(m: VendingMachine, cents: number): Result; select(m: VendingMachine, code: string): Result; cancel(m: VendingMachine): Result; repair(m: VendingMachine): Result;}function invalid(state: string, event: string): never { throw new InvalidTransition(`${event} is not allowed in state ${state}`);}const Idle: VState = { name: "idle", insert(m, cents) { m.balance += cents; m.state = HasMoney; return { ok: true }; }, select() { return invalid("idle", "select"); }, cancel() { return invalid("idle", "cancel"); }, repair() { return invalid("idle", "repair"); },};const HasMoney: VState = { name: "hasMoney", insert(m, cents) { m.balance += cents; return { ok: true }; }, select(m, code) { const p = m.inventory.get(code); if (!p) return { ok: false, reason: "unknown code" }; if (p.qty === 0) return { ok: false, reason: "sold out" }; if (m.balance < p.priceCents) return { ok: false, reason: `insert ${p.priceCents - m.balance} more cents` }; if (!m.dispense(code)) { // hardware failed: give the money back and stop selling const refund = makeChange(m.balance); m.balance = 0; m.state = OutOfService; return { ok: false, reason: `jam, refunded ${refund.join("+")}` }; } p.qty--; const change = makeChange(m.balance - p.priceCents); m.balance = 0; m.state = Idle; return { ok: true, item: p.name, change }; }, cancel(m) { const change = makeChange(m.balance); m.balance = 0; m.state = Idle; return { ok: true, change }; }, repair() { return invalid("hasMoney", "repair"); },};const OutOfService: VState = { name: "outOfService", insert() { return invalid("outOfService", "insert"); }, select() { return invalid("outOfService", "select"); }, cancel() { return invalid("outOfService", "cancel"); }, repair(m) { m.state = Idle; return { ok: true }; },};export class VendingMachine { balance = 0; state: VState = Idle; readonly inventory: Map<string, Product>; readonly dispense: (code: string) => boolean; // injected hardware constructor(products: Record<string, Product>, dispense: (code: string) => boolean = () => true) { this.inventory = new Map(Object.entries(products).map(([k, v]) => [k, { ...v }])); this.dispense = dispense; } get stateName(): string { return this.state.name; } insert(cents: number) { return this.state.insert(this, cents); } select(code: string) { return this.state.select(this, code); } cancel() { return this.state.cancel(this); } repair() { return this.state.repair(this); }}Things to point out:
- The hardware is injected (
dispense). A test passes() => falseto simulate a jam. No mocking library is needed. - Money is integer cents, and
makeChangeuses the biggest coins first. For these coins (100, 25, 10, 5) greedy gives the fewest coins. For other coin sets, greedy can be wrong, and then you need dynamic programming (see DSA Lesson 55, 1-D Dynamic Programming). The code also assumes the machine has unlimited change; limited coin stock is an extension question. - States are constants, not classes created for each call. They hold no data, because the data (balance, stock) lives in the machine. If a state needed its own data, I would use classes.
- A jam is handled in the one place that can see it: the
hasMoneyselect. Money goes back first, then the state changes. Order matters: if you change state first and the refund throws, the customer's money is lost.
Run it:
import { Elevator, FirstComeFirstServed, Scan, type Scheduler } from "./elevator.ts";import { VendingMachine, InvalidTransition } from "./vending.ts";const vm = new VendingMachine({ A1: { name: "chips", priceCents: 125, qty: 1 } });console.log(vm.stateName, vm.insert(100), vm.stateName);console.log(vm.select("A1"));console.log(vm.insert(100), vm.select("A1"), vm.stateName);try { vm.select("A1"); } catch (e) { console.log(e instanceof InvalidTransition, (e as Error).message); }Step 3. The elevator with a transition table
For the elevator I use the other common style: one table in code. Every (state, event) pair is a cell, and a missing cell means not allowed. I show both styles on purpose, because the interviewer may ask "how else could you do it?".
| Style | Good when | Cost |
|---|---|---|
| State pattern (object per state) | Each state has a lot of different behaviour, or its own data | More types. The whole machine is spread over several places. |
| Transition table | Many states and events, mostly "go to X", and you want to see everything at once | Behaviour that is not a simple move needs a function in the cell (a guard). |
The elevator's states are idle, movingUp, movingDown, doorsOpen and fault. The events are request, arrive, doorTimeout, fault and reset.
| State \ Event | request | arrive | doorTimeout | fault | reset |
|---|---|---|---|---|---|
| idle | decide what to do | no | no | fault | no |
| movingUp | stay (add stop) | stop here? open : keep going | no | fault | no |
| movingDown | stay (add stop) | stop here? open : keep going | no | fault | no |
| doorsOpen | stay (add stop) | no | decide what to do | fault | no |
| fault | no | no | no | no | decide what to do |
"Decide what to do" is the scheduler's job, so it is a strategy (a swappable decision). It answers two questions: which way to go next, and should I stop at this floor?
import { InvalidTransition } from "./vending.ts";export type EState = "idle" | "movingUp" | "movingDown" | "doorsOpen" | "fault";export type EEvent = "request" | "arrive" | "doorTimeout" | "fault" | "reset";export type Dir = "up" | "down";// Strategy: which way to go, and whether to stop here. pending is in arrival order.export interface Scheduler { direction(floor: number, last: Dir, pending: number[]): Dir | null; stopHere(floor: number, pending: number[]): boolean;}export class Scan implements Scheduler { // "elevator algorithm": keep going one way, then turn direction(floor: number, last: Dir, pending: number[]): Dir | null { const above = pending.some((f) => f > floor); const below = pending.some((f) => f < floor); if (last === "up") return above ? "up" : below ? "down" : null; return below ? "down" : above ? "up" : null; } stopHere(floor: number, pending: number[]): boolean { return pending.includes(floor); // stop for anyone on the way }}export class FirstComeFirstServed implements Scheduler { direction(floor: number, _last: Dir, pending: number[]): Dir | null { if (pending.length === 0) return null; return pending[0] > floor ? "up" : "down"; } stopHere(floor: number, pending: number[]): boolean { return pending[0] === floor; // only the oldest request counts }}type Next = EState | ((e: Elevator) => EState);// The whole behaviour in one table. A missing cell means "not allowed".const TABLE: Record<EState, Partial<Record<EEvent, Next>>> = { idle: { request: (e) => e.decide(), fault: "fault" }, movingUp: { request: "movingUp", arrive: (e) => e.onArrive("up"), fault: "fault" }, movingDown: { request: "movingDown", arrive: (e) => e.onArrive("down"), fault: "fault" }, doorsOpen: { request: "doorsOpen", doorTimeout: (e) => e.decide(), fault: "fault" }, fault: { reset: (e) => e.decide() }, // resume the requests that were waiting};export class Elevator { floor = 0; state: EState = "idle"; pending: number[] = []; travelled = 0; stops: number[] = []; // floors where the doors opened, in order private last: Dir = "up"; private readonly sched: Scheduler; private readonly topFloor: number; constructor(sched: Scheduler, topFloor = 10) { this.sched = sched; this.topFloor = topFloor; } send(event: EEvent, floor?: number): EState { const next = TABLE[this.state][event]; if (next === undefined) throw new InvalidTransition(`${event} is not allowed in state ${this.state}`); if (event === "request") { if (!Number.isInteger(floor) || floor! < 0 || floor! > this.topFloor) throw new RangeError(`no floor ${floor}`); if (!this.pending.includes(floor!)) this.pending.push(floor!); } this.state = typeof next === "function" ? next(this) : next; return this.state; } decide(): EState { if (this.sched.stopHere(this.floor, this.pending)) return this.open(); const d = this.sched.direction(this.floor, this.last, this.pending); if (d === null) return "idle"; this.last = d; return d === "up" ? "movingUp" : "movingDown"; } onArrive(d: Dir): EState { if (this.sched.stopHere(this.floor, this.pending)) return this.open(); return d === "up" ? "movingUp" : "movingDown"; } private open(): EState { this.pending = this.pending.filter((f) => f !== this.floor); this.stops.push(this.floor); return "doorsOpen"; } // One step of time: move one floor, or close the doors. tick(): void { if (this.state === "movingUp" || this.state === "movingDown") { this.floor += this.state === "movingUp" ? 1 : -1; this.travelled++; this.send("arrive"); } else if (this.state === "doorsOpen") this.send("doorTimeout"); }}Scheduling: which request first?
| Scheduler | Rule | Good | Bad |
|---|---|---|---|
| First come, first served | Serve the oldest request | Fair, simple | Goes up and down for no reason |
| SCAN (the "elevator algorithm") | Keep going the same way, stop for everyone on the path, turn around when nothing is ahead | Fewer floors travelled | A far request in the other direction waits longer |
Both are in the file above. This demo gives each one the same four requests (5, 2, 8, 3) from floor 0:
function run(name: string, sched: Scheduler) { const e = new Elevator(sched); for (const f of [5, 2, 8, 3]) e.send("request", f); while (e.state !== "idle") e.tick(); console.log(name.padEnd(5), "stops:", e.stops.join(" "), "| floors travelled:", e.travelled);}run("scan", new Scan());run("fcfs", new FirstComeFirstServed());const lift = new Elevator(new Scan());try { lift.send("arrive"); } catch (e) { console.log((e as Error).message); }$ node demo.tsidle { ok: true } hasMoney{ ok: false, reason: 'insert 25 more cents' }{ ok: true } { ok: true, item: 'chips', change: [ 25, 25, 25 ] } idletrue select is not allowed in state idlescan stops: 2 3 5 8 | floors travelled: 8fcfs stops: 5 2 8 3 | floors travelled: 19arrive is not allowed in state idleSCAN stopped in floor order (2, 3, 5, 8) and travelled 8 floors. First come, first served stopped in request order and travelled 19. That is a measurement of this one example, not a general law. For a real building you would also care about waiting time and fairness, and I have not simulated those.
The last line of the output shows an invalid transition: arrive makes no sense while the elevator is idle, so send throws instead of quietly doing something.
Do not let a "can't happen" event pass silently. If arrive in idle just returns, a bug in the motor code is hidden, and you find it a week later as a wrong floor. Throw, log, and let a human see it.
How to test a state machine
The table gives you the test plan: one test per cell, plus a few full journeys.
- Every cell. For each state and each event, assert that it either works or throws
InvalidTransition. I wrote the expected "throws" grid by hand and checked the machine against it. This test fails the day someone adds a state and forgets a rule. - Journeys. Pay, buy, get change. Jam, refund, repair. Request while moving.
- Time and hardware are injected. The elevator has no timers at all.
tick()is one step of time, so a test moves time forward by calling it. - Assert the state name, not the internals.
stateNameandstateare what other code can see.
test("vending: the full transition matrix", () => { const events = ["insert", "select", "cancel", "repair"] as const; const makers = { idle: () => new VendingMachine(products), hasMoney: () => { const m = new VendingMachine(products); m.insert(5); return m; }, outOfService: () => { const m = new VendingMachine(products, () => false); m.insert(125); m.select("A1"); return m; }, }; const throws: Record<string, string[]> = { idle: ["select", "cancel", "repair"], hasMoney: ["repair"], outOfService: ["insert", "select", "cancel"], }; for (const [state, make] of Object.entries(makers)) { for (const ev of events) { const m = make(); assert.equal(m.stateName, state); const call = () => (ev === "insert" ? m.insert(5) : ev === "select" ? m.select("A1") : m[ev]()); if (throws[state].includes(ev)) assert.throws(call, InvalidTransition, `${state}/${ev}`); else assert.doesNotThrow(call, `${state}/${ev}`); } }});The cell-by-cell test above covers all 12 vending cells (3 states times 4 events). Here is the whole run, including the elevator tests:
$ node --test --test-reporter=spec state.test.ts | LC_ALL=C sed 's/\xE2\x9C\x94/pass/; s/\xE2\x84\xB9/#/'pass vending: pay, get item and change, go back to idle (3.291634ms)pass vending: business failures keep the money and the state (0.386883ms)pass vending: a jam refunds and takes the machine out of service until repaired (0.685565ms)pass vending: the full transition matrix (0.866313ms)pass makeChange uses the biggest coins first (0.393002ms)pass elevator: scan serves floors on the way, in floor order (0.521624ms)pass elevator: first-come-first-served keeps request order and travels further (0.381602ms)pass elevator: a request made while moving is picked up if it is on the way (0.187408ms)pass elevator: events a state cannot accept are rejected (0.696541ms)pass elevator: a fault stops everything until reset (0.641554ms)# tests 10# suites 0# pass 10# fail 0# cancelled 0# skipped 0# todo 0# duration_ms 232.741462While writing the tests I found a real bug in my first version. After a fault and reset, the elevator went to idle and forgot it still had a waiting request, so it never moved. The test "a fault stops everything until reset" caught it, because it checks that the car resumes and stops at floor 3. The fix was to make reset run decide() instead of going straight to idle. This is the sort of bug a table makes easy to see: reset was the only cell that did not call the scheduler.
Extension questions
| They ask | Short answer |
|---|---|
| Several elevators | A Dispatcher above them chooses which car gets a new hall call (for example the nearest car moving the same way). Each car keeps its own machine. |
| Up and down buttons on each floor | A request now has a direction. stopHere checks the direction too, so a car going up does not stop for someone who wants to go down. |
| Weight limit | A weight guard. When over the limit, the doors stay open and the car does not move. |
| Doors must stay open if blocked | A doorBlocked event in doorsOpen, with the table cell "stay". |
| Vending machine has limited change | Track coins in stock. makeChange becomes a search, and the "select" guard also checks "can I make change?". |
| Add card payments | A PaymentMethod interface (strategy). The state machine stays the same. |
| Persist the state across a power cut | Store state, balance and stock. On restart, load them, and treat a half-finished dispense as a jam. |
Interview questions
How do you approach a vending machine design? (Must know)
I list the states (idle, has money, out of service), the events (insert, select, cancel, repair) and fill in a transition table. Then I code each state as an object with the same methods, and the machine forwards calls to the current state. That removes the big if blocks. I say which failures are normal replies (sold out) and which are invalid transitions (select with no money).
What is the State pattern and when is it worth it? (Must know)
The object keeps a reference to a state object and forwards every call to it. Changing state means swapping the reference. It is worth it when the same call means different things in different states. For two states and one flag, a simple if is clearer. I do not use a pattern just to show I know it.
State pattern or transition table? (Senior signal)
State objects when each state has a lot of its own behaviour or data. A table when there are many states and events and most cells are just "go to X", because you can see all of it at once and test it cell by cell. I used objects for the vending machine and a table for the elevator, and both work.
What do you do with an invalid transition? (Must know)
Reject it loudly. I throw a specific error type and the caller decides what to do (show a message, log). The bad answer is to ignore it, because that hides bugs. I also keep it apart from normal failures like "sold out", which are expected results.
What happens if the machine jams after taking the money? (Senior signal)
Refund first, then change the state to out of service, so money is never lost if something else fails. The state stops all sales until a repair event. My test checks the refund text and that insert now throws. In a real machine I would also log it and send an alert; I have not built that.
How does your elevator choose the next stop? (Must know)
Through a scheduler strategy that answers two questions: which direction next, and do I stop at this floor. I wrote first come first served and SCAN. SCAN keeps its direction, stops for anyone on the way, and turns around when nothing is ahead. On my four-request example SCAN travelled 8 floors and first come first served travelled 19.
What is the weakness of SCAN? (Senior signal)
A request just behind the car, in the opposite direction, has to wait until the car reaches the end of its current sweep and comes back. So worst-case waiting for that one person is long. Fairness and waiting time matter as well as distance, and a real design would measure both.
How do you test an elevator without real time? (Must know)
No timers in the model. One tick() is one unit of time: move a floor, or close the doors. Tests call tick() in a loop until the state is idle, then check the list of stops and the distance. That makes tests fast and the same every run.
How do you make sure you tested every transition? (Senior signal)
I write the expected grid by hand (which cells must throw) and loop over every state and event in one test. If I add a state later, the loop covers it only if I add it to the list, so I treat that test as a checklist. A stricter approach is to define the states and events in one place and generate the matrix from it.
How would you handle several elevators? (Senior signal)
Each car keeps its own machine. A dispatcher above them takes a hall call and chooses a car, for example by cost: distance, direction and how many stops it already has. The dispatcher is another strategy. I would keep that out of the first version and say so.
Why not just use a boolean per condition (hasMoney, isBroken)? (Trap)
Booleans multiply: three flags are eight combinations, and some are impossible (isBroken and hasMoney at once?). Nothing in the code stops the impossible ones. One state value makes them impossible by construction. That is the main reason to use a state machine at all.
Where would you store the state in a real device? (Trap)
If the machine can lose power, the state and the balance must be stored somewhere that survives. On restart the machine loads the saved state. A half-finished dispense is treated as a jam, so the customer is refunded. This is a design decision, not something I tested here.
Key takeaways
- Name the states and events, and write the transition table first. Every cell is either a move or "not allowed".
- Prefer one state value to several booleans. Impossible combinations cannot exist.
- State pattern: one object per state, same methods. Transition table: one grid in code, guards as functions. Know both.
- Normal failures are results. Invalid transitions throw.
- Order money operations so nothing is lost if a step fails: refund, then change state.
- Elevator scheduling is a strategy: which direction, and stop here or not. SCAN beat first come first served on distance in my example (8 against 19).
- Test every cell of the table, plus a few journeys. Inject hardware and time.
What's next
Next: Lesson 8, Design a Deck of Cards, Blackjack and a Chat Server. Practise: add a doorBlocked event to the elevator table, write the failing test first, then make it pass.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Part of the study guide
Related series