Low-level design · Problem P07 of 15 · Classic systems
Vending machine
The textbook case for the state pattern. Every action (insert, select, cancel) means something different depending on what the machine is doing, and the state pattern turns a tangle of if-statements into small classes.
1. The prompt
The prompt"Design a vending machine. Users insert coins, select a product, and get the product and their change; they can cancel and get a refund. Products can sell out."
2. Clarify the requirements
Spend the first five minutes here. Ask, then write the answers down where the interviewer can see them.
| Ask | Assume (unless told otherwise) |
|---|---|
| Which coins? | 5, 10, 25 and 100 cents; anything else is rejected. |
| Insert after selecting? | Yes: the balance accumulates until a product is paid for. |
| Change? | Returned as an amount; making it from a coin inventory is a follow-up. |
Must do
- Accept valid coins only
- Select a product: dispense if paid enough, else say how much more
- Cancel: refund the balance
- Handle sold-out slots
Must be
- Every action valid in every state, with a sensible answer
- Easy to add a state (e.g. maintenance)
- Never lose or create money
Out of scope
- Card payments
- Restocking UI
- Exact change from a coin inventory (follow-up)
3. The classes
| Class | Its one job |
|---|---|
| VendingMachine | Holds the balance, the slots and the current state; delegates every action to the state |
| State | The interface: insert, select, cancel |
| Idle / HasMoney | One class per state, each answering every action its own way |
| Slot | A product, its price and how many are left |
4. How it works
5. Patterns and why
| Pattern | Where | Why it's worth it here |
|---|---|---|
| State | Idle and HasMoney implement State | No 'if state == …' anywhere; adding a Maintenance state is one new class |
| Validation at the edge | Coins checked once in VendingMachine.insert() | No state has to repeat it |
| Integer cents | All money | Exact arithmetic; no rounding errors |
Memory hook: "The state answers, the machine delegates"The machine forwards every action to its current state object; states decide the answer and the next state.
6. The code
States are small classes; the machine forwards each call. The bracket after each line shows the state the machine ends in.
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
COINS = (5, 10, 25, 100) # cents the machine accepts
@dataclass
class Slot:
name: str
price: int
count: int
class State(ABC):
"""Each state answers every action in its own way: no if/else on 'what state am I in?'."""
@abstractmethod
def insert(self, m: VendingMachine, cents: int) -> str: ...
@abstractmethod
def select(self, m: VendingMachine, code: str) -> str: ...
@abstractmethod
def cancel(self, m: VendingMachine) -> str: ...
class Idle(State):
def insert(self, m: VendingMachine, cents: int) -> str:
m.balance += cents
m.state = HasMoney()
return f"balance {m.money(m.balance)}"
def select(self, m: VendingMachine, code: str) -> str:
return "insert money first"
def cancel(self, m: VendingMachine) -> str:
return "nothing to refund"
class HasMoney(State):
def insert(self, m: VendingMachine, cents: int) -> str:
m.balance += cents
return f"balance {m.money(m.balance)}"
def select(self, m: VendingMachine, code: str) -> str:
slot = m.slots.get(code)
if slot is None:
return f"no slot {code}"
if slot.count == 0:
return f"{slot.name} is sold out, pick another"
if m.balance < slot.price:
return f"{slot.name} costs {m.money(slot.price)}, insert {m.money(slot.price - m.balance)} more"
slot.count -= 1
change, m.balance = m.balance - slot.price, 0
m.state = Idle()
return f"dispense {slot.name}, change {m.money(change)}"
def cancel(self, m: VendingMachine) -> str:
refund, m.balance = m.balance, 0
m.state = Idle()
return f"refund {m.money(refund)}"
class VendingMachine:
def __init__(self, slots: dict[str, Slot]) -> None:
self.slots, self.balance = slots, 0
self.state: State = Idle()
def insert(self, cents: int) -> str:
if cents not in COINS: # checked here once, before any state sees it
return f"rejected coin {cents}c"
return self.state.insert(self, cents)
def select(self, code: str) -> str:
return self.state.select(self, code)
def cancel(self) -> str:
return self.state.cancel(self)
@staticmethod
def money(c: int) -> str:
return f"${c // 100}.{c % 100:02d}"
if __name__ == "__main__":
vm = VendingMachine({"A1": Slot("Water", 100, 5), "B2": Slot("Chips", 150, 2), "C3": Slot("Gum", 75, 0)})
steps = [("select", "A1"), ("insert", 100), ("select", "B2"), ("insert", 25), ("insert", 25), ("select", "B2"),
("insert", 3), ("insert", 100), ("select", "C3"), ("cancel", None)]
for action, arg in steps:
out = vm.select(arg) if action == "select" else vm.insert(arg) if action == "insert" else vm.cancel() # type: ignore[arg-type]
print(f"{action} {arg if arg is not None else ''}".rstrip() + f": {out} [{type(vm.state).__name__}]")
const COINS = new Set([5, 10, 25, 100]); // cents the machine accepts
interface Slot { name: string; price: number; count: number; }
/** Each state answers every action in its own way: no if/else on "what state am I in?". */
interface State {
readonly name: string;
insert(m: VendingMachine, cents: number): string;
select(m: VendingMachine, code: string): string;
cancel(m: VendingMachine): string;
}
const money = (c: number) => `$${Math.floor(c / 100)}.${String(c % 100).padStart(2, "0")}`;
class Idle implements State {
readonly name = "Idle";
insert(m: VendingMachine, cents: number): string {
m.balance += cents;
m.state = new HasMoney();
return `balance ${money(m.balance)}`;
}
select(): string { return "insert money first"; }
cancel(): string { return "nothing to refund"; }
}
class HasMoney implements State {
readonly name = "HasMoney";
insert(m: VendingMachine, cents: number): string {
m.balance += cents;
return `balance ${money(m.balance)}`;
}
select(m: VendingMachine, code: string): string {
const slot = m.slots.get(code);
if (!slot) return `no slot ${code}`;
if (slot.count === 0) return `${slot.name} is sold out, pick another`;
if (m.balance < slot.price) return `${slot.name} costs ${money(slot.price)}, insert ${money(slot.price - m.balance)} more`;
slot.count--;
const change = m.balance - slot.price;
m.balance = 0;
m.state = new Idle();
return `dispense ${slot.name}, change ${money(change)}`;
}
cancel(m: VendingMachine): string {
const refund = m.balance;
m.balance = 0;
m.state = new Idle();
return `refund ${money(refund)}`;
}
}
class VendingMachine {
balance = 0;
state: State = new Idle();
constructor(readonly slots: Map<string, Slot>) {}
insert(cents: number): string {
if (!COINS.has(cents)) return `rejected coin ${cents}c`; // checked here once, before any state sees it
return this.state.insert(this, cents);
}
select(code: string): string { return this.state.select(this, code); }
cancel(): string { return this.state.cancel(this); }
}
const vm = new VendingMachine(new Map([["A1", { name: "Water", price: 100, count: 5 }], ["B2", { name: "Chips", price: 150, count: 2 }],
["C3", { name: "Gum", price: 75, count: 0 }]]));
const steps: [string, string | number | null][] = [["select", "A1"], ["insert", 100], ["select", "B2"], ["insert", 25], ["insert", 25],
["select", "B2"], ["insert", 3], ["insert", 100], ["select", "C3"], ["cancel", null]];
for (const [action, arg] of steps) {
const out = action === "select" ? vm.select(arg as string) : action === "insert" ? vm.insert(arg as number) : vm.cancel();
console.log(`${action}${arg !== null ? " " + arg : ""}: ${out} [${vm.state.name}]`);
}import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
class Slot {
final String name;
final int price;
int count;
Slot(String name, int price, int count) {
this.name = name;
this.price = price;
this.count = count;
}
}
/** Each state answers every action in its own way: no if/else on "what state am I in?". */
interface State {
String insert(VendingMachine m, int cents);
String select(VendingMachine m, String code);
String cancel(VendingMachine m);
}
class Idle implements State {
public String insert(VendingMachine m, int cents) {
m.balance += cents;
m.state = new HasMoney();
return "balance " + VendingMachine.money(m.balance);
}
public String select(VendingMachine m, String code) {
return "insert money first";
}
public String cancel(VendingMachine m) {
return "nothing to refund";
}
}
class HasMoney implements State {
public String insert(VendingMachine m, int cents) {
m.balance += cents;
return "balance " + VendingMachine.money(m.balance);
}
public String select(VendingMachine m, String code) {
Slot slot = m.slots.get(code);
if (slot == null) return "no slot " + code;
if (slot.count == 0) return slot.name + " is sold out, pick another";
if (m.balance < slot.price)
return slot.name + " costs " + VendingMachine.money(slot.price) + ", insert " + VendingMachine.money(slot.price - m.balance) + " more";
slot.count--;
int change = m.balance - slot.price;
m.balance = 0;
m.state = new Idle();
return "dispense " + slot.name + ", change " + VendingMachine.money(change);
}
public String cancel(VendingMachine m) {
int refund = m.balance;
m.balance = 0;
m.state = new Idle();
return "refund " + VendingMachine.money(refund);
}
}
class VendingMachine {
static final Set<Integer> COINS = Set.of(5, 10, 25, 100); // cents the machine accepts
final Map<String, Slot> slots;
int balance;
State state = new Idle();
VendingMachine(Map<String, Slot> slots) {
this.slots = slots;
}
synchronized String insert(int cents) {
if (!COINS.contains(cents)) return "rejected coin " + cents + "c"; // checked here once, before any state sees it
return state.insert(this, cents);
}
synchronized String select(String code) {
return state.select(this, code);
}
synchronized String cancel() {
return state.cancel(this);
}
static String money(int c) {
return "$" + (c / 100) + "." + (c % 100 < 10 ? "0" : "") + (c % 100);
}
}
public class Main {
public static void main(String[] args) {
Map<String, Slot> slots = new LinkedHashMap<>();
slots.put("A1", new Slot("Water", 100, 5));
slots.put("B2", new Slot("Chips", 150, 2));
slots.put("C3", new Slot("Gum", 75, 0));
VendingMachine vm = new VendingMachine(slots);
Object[][] steps = {{"select", "A1"}, {"insert", 100}, {"select", "B2"}, {"insert", 25}, {"insert", 25},
{"select", "B2"}, {"insert", 3}, {"insert", 100}, {"select", "C3"}, {"cancel", null}};
for (Object[] s : steps) {
String action = (String) s[0];
String out = action.equals("select") ? vm.select((String) s[1])
: action.equals("insert") ? vm.insert((Integer) s[1]) : vm.cancel();
System.out.println(action + (s[1] != null ? " " + s[1] : "") + ": " + out + " [" + vm.state.getClass().getSimpleName() + "]");
}
}
}Output (the same in all three languages)
select A1: insert money first [Idle] insert 100: balance $1.00 [HasMoney] select B2: Chips costs $1.50, insert $0.50 more [HasMoney] insert 25: balance $1.25 [HasMoney] insert 25: balance $1.50 [HasMoney] select B2: dispense Chips, change $0.00 [Idle] insert 3: rejected coin 3c [Idle] insert 100: balance $1.00 [HasMoney] select C3: Gum is sold out, pick another [HasMoney] cancel: refund $1.00 [Idle]
7. Concurrency and edge cases
- One customer at a time is the physical reality, but the software may still get events from several threads (coin acceptor, keypad, a remote admin). Synchronize the three public methods, as the Java version does.
- Dispense failures: if the motor jams after the balance is taken, refund the money. Change state only after the product is confirmed out.
- Power loss mid-transaction: persist the balance so it can be refunded on restart.
8. Follow-up questions
What interviewers ask once the core works, and a short answer for each.
- How would you return exact change from a coin inventory?
- Keep counts per coin; compute change greedily from the largest coin, and refuse the sale (or ask for exact money) if it can't be made.
- Add a maintenance mode?
- A Maintenance state where insert refunds immediately and select refuses; an admin method switches to and from it.
- Support card payments?
- A PaymentMethod abstraction: coins and cards both produce a credited balance; the states don't change.
9. Common mistakes
A big switch on state in every methodThat's what the state pattern removes; each new state then touches every method.
Changing state before dispensingIf dispensing fails you've already taken the money.
Floats for money0.1 + 0.2 isn't 0.3. Use cents.
10. Check yourself
What does the state pattern replace?
Each state becomes a class that answers every action.
Where is the coin validation?
Validate at the edge, once.
A Maintenance state is needed. What changes?
Existing states don't need to know about it.
Your turn
- Add a Maintenance state (10 min). Inserts are refunded; selects refused.
- Add a coin inventory (15 min). Make change from real coins, or refuse the sale.