Design a Vending Machine
Design a Vending Machine
The vending machine is the canonical STATE-pattern interview question, for one precise reason: almost every candidate models it correctly on the whiteboard as three bubbles — Idle, HasMoney, Dispensing — and then, the moment they start typing, throws that diagram away and writes one enum status field with an if/else in every method. The bubbles were right. The code that follows them is where the bugs live: dispense without payment, coins accepted while the machine is sold out, a double-dispense on one payment. This lab has you build a vending machine from an empty file, in Java and Go, break the naive version on purpose, and see exactly why the State pattern isn't decoration — it's the thing that makes those two bugs impossible to write, not just easy to avoid. See also the State Pattern concept page for the theory side — do this lab first and that page will read like a victory lap.
1. The trap
You're asked to design a vending machine. You sketch three states on the whiteboard — Idle, HasMoney, Dispensing — and everyone nods. Then you start coding, and the "obvious" translation is one status field and a method per button:
// Abridged trap sketch -- illustrative only, not the full reference
// (the full compiled version, with both bugs live, is in Movement 5)
enum Status { IDLE, HAS_MONEY, DISPENSING, SOLD_OUT }
class NaiveVendingMachine {
Status status = Status.IDLE;
int balanceCents = 0;
String selected;
void insertCoin(int cents) {
if (status == Status.DISPENSING) return; // ...forgot SOLD_OUT
balanceCents += cents;
status = Status.HAS_MONEY;
}
void dispense() {
if (selected == null) return; // ...forgot to check balance >= price
// ships the product regardless of whether balanceCents actually covers it
}
}
This compiles. It demos fine on the happy path: insert a coin, select a product, dispense, done. The bugs are not typos — they're the structural consequence of the design. Every method has to independently re-derive, from a bare status field plus whatever ad-hoc flags accumulated around it, which of the other three-plus states it's legal to run from. insertCoin needs to remember to reject SOLD_OUT. dispense needs to remember to check the balance, not just "is something selected." selectProduct needs to remember to check stock and not clobber an in-flight dispense. Four states × four events is sixteen legality questions, and the enum+if version answers each one with a hand-written guard that has to be right every single time, forever, including the day someone adds a fifth event (a refund() button) or a sixth state (a Maintenance mode). Miss one guard and you get exactly what Movement 5 shows: a customer pays $0 and gets a soda, or the machine keeps taking coins after the shelf is empty. The bubbles on the whiteboard were the right model. The bug is that "one enum plus scattered if-checks" throws that model away and replaces it with sixteen independent chances to get a transition wrong.
2. Scope it like a senior
Before writing a line, pin the contract down. Questions worth asking out loud in an interview:
- Payment: coins only, or coins/notes/card? We build coins for the core mechanism (M1–M4) — it's the smallest thing that exercises balance accumulation and change-making — then design the machine so a new payment type (card, say) is an added strategy, never a change to any state class (proven in Movement 6/7, not just claimed).
- Change-making: does the machine give change, and what if it can't? Yes — and "insert exact change only" lights aren't a cop-out, they're the honest answer to a real edge: a coin reserve that can't make the exact amount owed. We build greedy change-making (M3) and the abort-and-refund path for when greedy can't cover it.
- One product or many, and does price vary per product? Many, each with its own price and stock count (M2) — this is what makes "sold out" a per-product concern, not just a whole-machine concern, and it's where a lot of naive designs quietly assume a single product and never revisit it.
- Refunds / cancel: when is a customer allowed to back out? Any time before the machine commits to a specific product and starts moving mechanically — after that point, cancel is illegal, not just discouraged (M4). That's a real physical constraint (the coin path and the vend motor are physically separate systems; once the motor is running you cannot electronically un-spend a customer's money mid-turn) and it becomes a state-machine rule, not a comment.
- Concurrency: it's one physical machine — so what's the actual invariant? One coin slot, one keypad, one dispense chute. The invariant isn't "many threads race for a lock" (there's exactly one customer at a time in front of a real machine) — it's "every event (insert coin / select / dispense / cancel) is processed atomically, one at a time, in the order it physically happened." We model that with a single lock around event dispatch (
synchronized/sync.Mutex), which also gives us the honest answer to "what about a technician restocking it while a purchase is in flight?" (Movement 7).
Answering these up front is what "senior" looks like: you're negotiating the spec, not guessing at it.
3. Reason to the design
Attempt 0 — the trap, in full. One status enum, balance and selection as loose fields, an if/else ladder in every method deciding what's legal. We just watched this fail in Movement 1 — not because anyone was careless, but because "is this transition legal" is a question answered sixteen separate times, once per state×event pair, each one a fresh chance to forget a check.
Attempt 1 — centralize the legality table. The next instinct is usually: keep the one status enum, but centralize the checks into a single Map<Status, Set<Event>> of "legal events per state," consulted at the top of every method before the enum-specific logic runs. This is a real improvement — it turns "did I forget a check" into "is the table right," which is easier to audit in one place. But it only answers whether a transition is legal; the what happens logic (accumulate balance, check stock, compute change, decide the next state) still lives back in the if/else ladder, unchanged. You've deduplicated the guard, not the mess behind it.
Attempt 2 — give each state its own object. Take the next step: instead of one class answering "what do I do for event X in state Y," make each state its own class that implements the same interface — one method per event — and only writes real logic for the events that are legal FROM that state. Every other method is a one-line rejection, because that's true by construction: SoldOutState doesn't have an if (status != SOLD_OUT) return guard anywhere, because there is no other status it could possibly be — you are only ever running SoldOutState's code when the machine actually is in that state. The context (the vending machine itself) holds a reference to whichever state object is current and forwards every event to it, then lets that state decide what the next state is. Nothing outside the four state classes needs to know the full legality table at all — it emerges from which class is currently plugged in.
Why this beats attempt 1, not just attempt 0: the centralized table tells you an event is illegal; the State pattern makes the illegal branch not exist in the state where it's illegal. There is no line of code anywhere that says "if state is IDLE, reject selectProduct" — IdleState.selectProduct() simply doesn't contain dispensing logic, full stop. Adding a fifth event later means adding one method to the VendingState interface and implementing it in each of the (still four) state classes — the compiler forces you to touch every state, so you cannot silently forget one, which is exactly the failure mode that produced both bugs in Movement 1.
4. Build it — milestones
Build a vending machine with this contract, then grow it milestone by milestone. Attempt each yourself before reading the reference implementation — the reveal is positioned after the design reasoning on purpose.
- Core contract: four events —
insertCoin(cents),selectProduct(code),dispense(),cancel()— each returns a human-readable outcome (accepted / rejected / dispensed), never silently no-ops. - M1 — states + transitions.
Idle → HasMoneyoninsertCoin;HasMoney → Dispensingon a validselectProduct;Dispensing → Idleondispense. Every event illegal from the current state is rejected with a clear reason — not silently ignored, not a crash. - M2 — inventory + pricing + sold-out. Multiple products, each with its own price and stock count.
selectProductrejects a specific out-of-stock product (stay inHasMoney, let the customer try another). When every product hits zero stock, the whole machine transitions to a globalSoldOutstate that rejects eveninsertCoin— the exact bug from Movement 1. - M3 — change-making. On dispense, compute change owed (
balance - price) via greedy coin selection from a reserve of denominations. The edge that matters: if the reserve can't make exact change, abort the sale, refund the full balance, and leave stock untouched — never dispense with the wrong change, and never silently round. - M4 — refund on cancel / insufficient funds.
cancel()is legal only before the machine commits to a product (i.e., fromIdleorHasMoney) — it refunds the full balance and returns toIdle. Once committed (Dispensing),cancel()is illegal, matching the physical constraint that the vend motor is already moving.
Reference implementation — Java (State pattern: one interface, four state classes, one context)
Five files, one package. VendingState is the interface; IdleState / HasMoneyState / DispensingState / SoldOutState are singletons (no per-instance data — all mutable state lives in the context); VendingMachine is the context that forwards every event to state and applies whatever transition the state decides on. Every method returns a message so the demo output below is the literal proof of what happened — not an assertion you have to trust.
// VendingState.java
package vending;
interface VendingState {
String insertCoin(VendingMachine m, int cents);
String selectProduct(VendingMachine m, String code);
String dispense(VendingMachine m);
String cancel(VendingMachine m);
}
// Product.java
package vending;
final class Product {
final String code;
final int priceCents;
int stock;
Product(String code, int priceCents, int stock) {
this.code = code;
this.priceCents = priceCents;
this.stock = stock;
}
}
// IdleState.java
package vending;
final class IdleState implements VendingState {
static final IdleState INSTANCE = new IdleState();
private IdleState() {}
public String insertCoin(VendingMachine m, int cents) {
m.addBalance(cents);
m.setState(HasMoneyState.INSTANCE);
return "accepted " + cents + "c, balance=" + m.getBalance() + "c -> HAS_MONEY";
}
public String selectProduct(VendingMachine m, String code) {
return "REJECTED: insert coins before selecting a product (state=IDLE)";
}
public String dispense(VendingMachine m) {
return "REJECTED: nothing to dispense (state=IDLE)";
}
public String cancel(VendingMachine m) {
return "REJECTED: nothing to cancel (state=IDLE)";
}
}
// HasMoneyState.java
package vending;
final class HasMoneyState implements VendingState {
static final HasMoneyState INSTANCE = new HasMoneyState();
private HasMoneyState() {}
public String insertCoin(VendingMachine m, int cents) {
m.addBalance(cents);
return "accepted " + cents + "c, balance=" + m.getBalance() + "c (still HAS_MONEY)";
}
public String selectProduct(VendingMachine m, String code) {
Product p = m.product(code);
if (p == null) return "REJECTED: unknown product " + code;
if (p.stock <= 0) return "REJECTED: " + code + " is sold out -- pick another or cancel";
int shortfall = p.priceCents - m.getBalance();
if (shortfall > 0) return "REJECTED: insufficient funds, insert " + shortfall + "c more";
m.setSelected(code);
m.setState(DispensingState.INSTANCE);
return code + " selected, committed -> DISPENSING (call dispense())";
}
public String dispense(VendingMachine m) {
return "REJECTED: select a product before dispensing (state=HAS_MONEY)";
}
public String cancel(VendingMachine m) {
int refund = m.getBalance();
m.resetBalance();
m.setState(IdleState.INSTANCE);
return "cancelled, refunded " + refund + "c -> IDLE";
}
}
// DispensingState.java
package vending;
import java.util.Map;
final class DispensingState implements VendingState {
static final DispensingState INSTANCE = new DispensingState();
private DispensingState() {}
public String insertCoin(VendingMachine m, int cents) {
return "REJECTED: cannot insert coins mid-dispense (state=DISPENSING)";
}
public String selectProduct(VendingMachine m, String code) {
return "REJECTED: already dispensing " + m.getSelected() + " (state=DISPENSING)";
}
public String dispense(VendingMachine m) {
Product p = m.product(m.getSelected());
int changeDue = m.getBalance() - p.priceCents;
Map<Integer, Integer> change = m.makeChange(changeDue);
if (change == null) {
// can't make exact change -- abort the sale, refund in full, stock untouched
int refund = m.getBalance();
m.resetBalance();
m.clearSelected();
m.setState(m.hasAnyStock() ? IdleState.INSTANCE : SoldOutState.INSTANCE);
return "ABORTED: cannot make " + changeDue + "c exact change -- refunded " + refund + "c -> IDLE";
}
p.stock -= 1;
m.commitChange(change);
m.resetBalance();
String dispensedCode = p.code;
m.clearSelected();
m.setState(m.hasAnyStock() ? IdleState.INSTANCE : SoldOutState.INSTANCE);
return "DISPENSED " + dispensedCode + ", change=" + changeDue + "c as " + change + " -> IDLE";
}
public String cancel(VendingMachine m) {
return "REJECTED: cannot cancel mid-dispense, already committed (state=DISPENSING)";
}
}
// SoldOutState.java
package vending;
final class SoldOutState implements VendingState {
static final SoldOutState INSTANCE = new SoldOutState();
private SoldOutState() {}
public String insertCoin(VendingMachine m, int cents) {
return "REJECTED: machine sold out, coin returned (state=SOLD_OUT)";
}
public String selectProduct(VendingMachine m, String code) {
return "REJECTED: machine sold out (state=SOLD_OUT)";
}
public String dispense(VendingMachine m) {
return "REJECTED: nothing to dispense (state=SOLD_OUT)";
}
public String cancel(VendingMachine m) {
return "REJECTED: nothing to cancel (state=SOLD_OUT)";
}
}
// VendingMachine.java -- the context
package vending;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeMap;
public final class VendingMachine {
private VendingState state = IdleState.INSTANCE;
private final Map<String, Product> catalog = new LinkedHashMap<String, Product>();
private int balanceCents = 0;
private String selectedCode;
// denom (cents) -> count on hand; reverse order so makeChange sees largest first
private final TreeMap<Integer, Integer> changeReserve = new TreeMap<Integer, Integer>(Collections.reverseOrder());
// -- setup, used by the harness/tests, not part of the state machine itself --
public void stock(String code, int priceCents, int qty) {
Product p = catalog.get(code);
if (p == null) {
p = new Product(code, priceCents, 0);
catalog.put(code, p);
}
p.stock += qty;
if (state instanceof SoldOutState && hasAnyStock()) {
state = IdleState.INSTANCE;
}
}
public void loadChange(int denomCents, int count) {
Integer cur = changeReserve.get(denomCents);
changeReserve.put(denomCents, (cur == null ? 0 : cur) + count);
}
// -- the four events; each just forwards to the current state. synchronized
// models the single physical machine: only one event is ever in flight --
public synchronized String insertCoin(int cents) { return state.insertCoin(this, cents); }
public synchronized String selectProduct(String code) { return state.selectProduct(this, code); }
public synchronized String dispense() { return state.dispense(this); }
public synchronized String cancel() { return state.cancel(this); }
public String currentState() { return state.getClass().getSimpleName(); }
// -- package-private hooks used only by VendingState implementations --
void setState(VendingState s) { this.state = s; }
void addBalance(int cents) { this.balanceCents += cents; }
void resetBalance() { this.balanceCents = 0; }
int getBalance() { return balanceCents; }
void setSelected(String code) { this.selectedCode = code; }
void clearSelected() { this.selectedCode = null; }
String getSelected() { return selectedCode; }
Product product(String code) { return catalog.get(code); }
boolean hasAnyStock() {
for (Product p : catalog.values()) {
if (p.stock > 0) return true;
}
return false;
}
/** Greedy coin change: largest denomination first. Returns null if the
* reserve cannot make EXACT change -- the edge naive implementations forget. */
Map<Integer, Integer> makeChange(int amountCents) {
Map<Integer, Integer> result = new LinkedHashMap<Integer, Integer>();
int remaining = amountCents;
for (Map.Entry<Integer, Integer> e : changeReserve.entrySet()) {
int denom = e.getKey();
int available = e.getValue();
int use = Math.min(available, remaining / denom);
if (use > 0) {
result.put(denom, use);
remaining -= use * denom;
}
}
return remaining == 0 ? result : null;
}
void commitChange(Map<Integer, Integer> change) {
for (Map.Entry<Integer, Integer> e : change.entrySet()) {
Integer cur = changeReserve.get(e.getKey());
changeReserve.put(e.getKey(), (cur == null ? 0 : cur) - e.getValue());
}
}
}
StateMachineDemo.java walks M1–M4 end to end (default package, import vending.VendingMachine;):
// StateMachineDemo.java (default package)
import vending.VendingMachine;
/** Walks M1 -> M4 against the State-pattern machine: legal happy paths only. */
public final class StateMachineDemo {
public static void main(String[] args) {
VendingMachine m = new VendingMachine();
m.stock("A1", 150, 2); // 2x $1.50 soda
m.stock("B2", 225, 1); // 1x $2.25 chips
m.loadChange(100, 3);
m.loadChange(25, 10);
m.loadChange(5, 20);
System.out.println("-- M1/M2: insert, select, dispense, change --");
System.out.println(m.insertCoin(100));
System.out.println(m.insertCoin(100)); // balance=200c
System.out.println(m.selectProduct("A1")); // price 150, commits -> DISPENSING
System.out.println(m.dispense()); // change = 50c
System.out.println();
System.out.println("-- M2: insufficient funds, then top up --");
System.out.println(m.insertCoin(100));
System.out.println(m.selectProduct("B2")); // price 225, shortfall 125 -> REJECTED
System.out.println(m.insertCoin(125));
System.out.println(m.selectProduct("B2")); // now commits
System.out.println(m.dispense());
System.out.println();
System.out.println("-- M2: sold-out product, pick a different one --");
System.out.println(m.insertCoin(150));
System.out.println(m.selectProduct("B2")); // B2 stock now 0 -> REJECTED sold out
System.out.println(m.selectProduct("A1")); // still 1 left -> commits
System.out.println(m.dispense());
System.out.println("state = " + m.currentState()); // both products at 0 -> SOLD_OUT
System.out.println();
System.out.println("-- M4: cancel refunds mid-transaction --");
m.stock("A1", 150, 1); // restock -> back to IDLE
System.out.println(m.insertCoin(100));
System.out.println(m.cancel()); // refunds 100c, back to IDLE
}
}
Actual captured output from java StateMachineDemo:
-- M1/M2: insert, select, dispense, change --
accepted 100c, balance=100c -> HAS_MONEY
accepted 100c, balance=200c (still HAS_MONEY)
A1 selected, committed -> DISPENSING (call dispense())
DISPENSED A1, change=50c as {25=2} -> IDLE
-- M2: insufficient funds, then top up --
accepted 100c, balance=100c -> HAS_MONEY
REJECTED: insufficient funds, insert 125c more
accepted 125c, balance=225c (still HAS_MONEY)
B2 selected, committed -> DISPENSING (call dispense())
DISPENSED B2, change=0c as {} -> IDLE
-- M2: sold-out product, pick a different one --
accepted 150c, balance=150c -> HAS_MONEY
REJECTED: B2 is sold out -- pick another or cancel
A1 selected, committed -> DISPENSING (call dispense())
DISPENSED A1, change=0c as {} -> IDLE
state = SoldOutState
-- M4: cancel refunds mid-transaction --
accepted 100c, balance=100c -> HAS_MONEY
cancelled, refunded 100c -> IDLE
Compiled clean with javac -Xlint:all (zero warnings) and executed before writing this page.
Reference implementation — Go (same State pattern: an interface + empty structs as states)
Go has no singleton-object idiom, so each state is a zero-size struct with value-receiver methods — functionally identical to the Java singletons, just spelled the Go way. All files share package main so the module builds as one binary.
// state.go
package main
// VendingState is the State interface: one method per event, each returning
// a human-readable outcome message. Concrete states implement only the legal
// transitions FROM that state -- everything else returns a rejection string.
// Illegal events are refused BY CONSTRUCTION, never a runtime check you hope
// you remembered to write.
type VendingState interface {
InsertCoin(m *VendingMachine, cents int) string
SelectProduct(m *VendingMachine, code string) string
Dispense(m *VendingMachine) string
Cancel(m *VendingMachine) string
}
// product.go
package main
// Product is a catalog entry: a price and remaining stock count.
type Product struct {
Code string
PriceCents int
Stock int
}
// idle.go
package main
import "fmt"
type idleState struct{}
func (idleState) InsertCoin(m *VendingMachine, cents int) string {
m.balanceCents += cents
m.state = hasMoneyState{}
return fmt.Sprintf("accepted %dc, balance=%dc -> HAS_MONEY", cents, m.balanceCents)
}
func (idleState) SelectProduct(m *VendingMachine, code string) string {
return "REJECTED: insert coins before selecting a product (state=IDLE)"
}
func (idleState) Dispense(m *VendingMachine) string {
return "REJECTED: nothing to dispense (state=IDLE)"
}
func (idleState) Cancel(m *VendingMachine) string {
return "REJECTED: nothing to cancel (state=IDLE)"
}
// hasmoney.go
package main
import "fmt"
type hasMoneyState struct{}
func (hasMoneyState) InsertCoin(m *VendingMachine, cents int) string {
m.balanceCents += cents
return fmt.Sprintf("accepted %dc, balance=%dc (still HAS_MONEY)", cents, m.balanceCents)
}
func (hasMoneyState) SelectProduct(m *VendingMachine, code string) string {
p, ok := m.catalog[code]
if !ok {
return fmt.Sprintf("REJECTED: unknown product %s", code)
}
if p.Stock <= 0 {
return fmt.Sprintf("REJECTED: %s is sold out -- pick another or cancel", code)
}
shortfall := p.PriceCents - m.balanceCents
if shortfall > 0 {
return fmt.Sprintf("REJECTED: insufficient funds, insert %dc more", shortfall)
}
m.selectedCode = code
m.state = dispensingState{}
return fmt.Sprintf("%s selected, committed -> DISPENSING (call Dispense())", code)
}
func (hasMoneyState) Dispense(m *VendingMachine) string {
return "REJECTED: select a product before dispensing (state=HAS_MONEY)"
}
func (hasMoneyState) Cancel(m *VendingMachine) string {
refund := m.balanceCents
m.balanceCents = 0
m.state = idleState{}
return fmt.Sprintf("cancelled, refunded %dc -> IDLE", refund)
}
// dispensing.go
package main
import "fmt"
type dispensingState struct{}
func (dispensingState) InsertCoin(m *VendingMachine, cents int) string {
return "REJECTED: cannot insert coins mid-dispense (state=DISPENSING)"
}
func (dispensingState) SelectProduct(m *VendingMachine, code string) string {
return fmt.Sprintf("REJECTED: already dispensing %s (state=DISPENSING)", m.selectedCode)
}
func (dispensingState) Dispense(m *VendingMachine) string {
p := m.catalog[m.selectedCode]
changeDue := m.balanceCents - p.PriceCents
change, ok := m.makeChange(changeDue)
if !ok {
// can't make exact change -- abort the sale, refund in full, stock untouched
refund := m.balanceCents
m.balanceCents = 0
m.selectedCode = ""
m.state = nextIdleOrSoldOut(m)
return fmt.Sprintf("ABORTED: cannot make %dc exact change -- refunded %dc -> IDLE", changeDue, refund)
}
p.Stock--
m.commitChange(change)
m.balanceCents = 0
dispensedCode := p.Code
m.selectedCode = ""
m.state = nextIdleOrSoldOut(m)
return fmt.Sprintf("DISPENSED %s, change=%dc as %v -> IDLE", dispensedCode, changeDue, change)
}
func (dispensingState) Cancel(m *VendingMachine) string {
return "REJECTED: cannot cancel mid-dispense, already committed (state=DISPENSING)"
}
func nextIdleOrSoldOut(m *VendingMachine) VendingState {
if m.hasAnyStock() {
return idleState{}
}
return soldOutState{}
}
// soldout.go
package main
type soldOutState struct{}
func (soldOutState) InsertCoin(m *VendingMachine, cents int) string {
return "REJECTED: machine sold out, coin returned (state=SOLD_OUT)"
}
func (soldOutState) SelectProduct(m *VendingMachine, code string) string {
return "REJECTED: machine sold out (state=SOLD_OUT)"
}
func (soldOutState) Dispense(m *VendingMachine) string {
return "REJECTED: nothing to dispense (state=SOLD_OUT)"
}
func (soldOutState) Cancel(m *VendingMachine) string {
return "REJECTED: nothing to cancel (state=SOLD_OUT)"
}
// machine.go -- the context
package main
import (
"fmt"
"sort"
"sync"
)
// VendingMachine is the context: it holds the current state and forwards
// every event to it. sync.Mutex models the single physical machine -- only
// one event is ever in flight, exactly like one coin slot and one chute.
type VendingMachine struct {
mu sync.Mutex
state VendingState
catalog map[string]*Product
balanceCents int
selectedCode string
changeReserve map[int]int
}
func NewVendingMachine() *VendingMachine {
return &VendingMachine{
state: idleState{},
catalog: make(map[string]*Product),
changeReserve: make(map[int]int),
}
}
func (m *VendingMachine) Stock(code string, priceCents, qty int) {
p, ok := m.catalog[code]
if !ok {
p = &Product{Code: code, PriceCents: priceCents}
m.catalog[code] = p
}
p.Stock += qty
if _, soldOut := m.state.(soldOutState); soldOut && m.hasAnyStock() {
m.state = idleState{}
}
}
func (m *VendingMachine) LoadChange(denomCents, count int) { m.changeReserve[denomCents] += count }
// the four events -- each just forwards to the current state under one lock.
func (m *VendingMachine) InsertCoin(cents int) string {
m.mu.Lock()
defer m.mu.Unlock()
return m.state.InsertCoin(m, cents)
}
func (m *VendingMachine) SelectProduct(code string) string {
m.mu.Lock()
defer m.mu.Unlock()
return m.state.SelectProduct(m, code)
}
func (m *VendingMachine) Dispense() string {
m.mu.Lock()
defer m.mu.Unlock()
return m.state.Dispense(m)
}
func (m *VendingMachine) Cancel() string {
m.mu.Lock()
defer m.mu.Unlock()
return m.state.Cancel(m)
}
func (m *VendingMachine) CurrentState() string {
switch m.state.(type) {
case idleState:
return "IdleState"
case hasMoneyState:
return "HasMoneyState"
case dispensingState:
return "DispensingState"
case soldOutState:
return "SoldOutState"
default:
return fmt.Sprintf("%T", m.state)
}
}
func (m *VendingMachine) hasAnyStock() bool {
for _, p := range m.catalog {
if p.Stock > 0 {
return true
}
}
return false
}
// makeChange is greedy coin change: largest denomination first. Returns
// (nil, false) if the reserve cannot make EXACT change -- the edge naive
// implementations forget.
func (m *VendingMachine) makeChange(amount int) (map[int]int, bool) {
denoms := make([]int, 0, len(m.changeReserve))
for d := range m.changeReserve {
denoms = append(denoms, d)
}
sort.Sort(sort.Reverse(sort.IntSlice(denoms)))
result := make(map[int]int)
remaining := amount
for _, d := range denoms {
avail := m.changeReserve[d]
use := remaining / d
if use > avail {
use = avail
}
if use > 0 {
result[d] = use
remaining -= use * d
}
}
if remaining != 0 {
return nil, false
}
return result, true
}
func (m *VendingMachine) commitChange(change map[int]int) {
for d, n := range change {
m.changeReserve[d] -= n
}
}
runStateMachineDemo() walks M1–M4 end to end, mirroring the Java StateMachineDemo exactly (it's one of the three functions demo.go's main() calls — the full demo.go is in Movement 5, alongside the naive machine it contrasts with):
// runStateMachineDemo -- part of demo.go, called from main()
func runStateMachineDemo() {
fmt.Println("== State-pattern machine: M1-M4 happy paths ==")
m := NewVendingMachine()
m.Stock("A1", 150, 2) // 2x $1.50 soda
m.Stock("B2", 225, 1) // 1x $2.25 chips
m.LoadChange(100, 3)
m.LoadChange(25, 10)
m.LoadChange(5, 20)
fmt.Println("-- M1/M2: insert, select, dispense, change --")
fmt.Println(m.InsertCoin(100))
fmt.Println(m.InsertCoin(100))
fmt.Println(m.SelectProduct("A1"))
fmt.Println(m.Dispense())
fmt.Println()
fmt.Println("-- M2: insufficient funds, then top up --")
fmt.Println(m.InsertCoin(100))
fmt.Println(m.SelectProduct("B2"))
fmt.Println(m.InsertCoin(125))
fmt.Println(m.SelectProduct("B2"))
fmt.Println(m.Dispense())
fmt.Println()
fmt.Println("-- M2: sold-out product, pick a different one --")
fmt.Println(m.InsertCoin(150))
fmt.Println(m.SelectProduct("B2"))
fmt.Println(m.SelectProduct("A1"))
fmt.Println(m.Dispense())
fmt.Println("state =", m.CurrentState())
fmt.Println()
fmt.Println("-- M4: cancel refunds mid-transaction --")
m.Stock("A1", 150, 1)
fmt.Println(m.InsertCoin(100))
fmt.Println(m.Cancel())
}
Actual captured output from go run . — identical shape to the Java run, modulo Go's map-printing format:
== State-pattern machine: M1-M4 happy paths ==
-- M1/M2: insert, select, dispense, change --
accepted 100c, balance=100c -> HAS_MONEY
accepted 100c, balance=200c (still HAS_MONEY)
A1 selected, committed -> DISPENSING (call Dispense())
DISPENSED A1, change=50c as map[25:2] -> IDLE
-- M2: insufficient funds, then top up --
accepted 100c, balance=100c -> HAS_MONEY
REJECTED: insufficient funds, insert 125c more
accepted 125c, balance=225c (still HAS_MONEY)
B2 selected, committed -> DISPENSING (call Dispense())
DISPENSED B2, change=0c as map[] -> IDLE
-- M2: sold-out product, pick a different one --
accepted 150c, balance=150c -> HAS_MONEY
REJECTED: B2 is sold out -- pick another or cancel
A1 selected, committed -> DISPENSING (call Dispense())
DISPENSED A1, change=0c as map[] -> IDLE
state = SoldOutState
-- M4: cancel refunds mid-transaction --
accepted 100c, balance=100c -> HAS_MONEY
cancelled, refunded 100c -> IDLE
Passes gofmt -l (clean), go vet ./... (clean), go build ./... (clean) — verified before publishing.
5. Break it — the test that fails
Movement 1's trap wasn't hand-waving — here is the actual naive class, and the actual two illegal transitions it allows, run against the actual State-pattern machine above for direct comparison. Full file, copy-paste compilable:
// NaiveVendingMachine.java (default package)
import java.util.HashMap;
import java.util.Map;
/**
* Movement 1's "trap": one enum status + a couple of ad-hoc maps, with an
* if/else in every method re-deriving which transitions are "supposed" to
* be legal. It compiles. It works on the happy path. The bugs below are the
* natural consequence of every method reasoning about legality from scratch.
*/
public final class NaiveVendingMachine {
enum Status { IDLE, HAS_MONEY, DISPENSING, SOLD_OUT }
private Status status = Status.IDLE;
private int balanceCents = 0;
private String selected;
private final Map<String, Integer> price = new HashMap<String, Integer>();
private final Map<String, Integer> stock = new HashMap<String, Integer>();
public NaiveVendingMachine() {
price.put("A1", 150);
stock.put("A1", 1);
price.put("A2", 200);
stock.put("A2", 0); // A2 starts sold out
}
/** BUG #1: only excludes DISPENSING -- forgets SOLD_OUT, so coins are
* silently accepted (and status silently flips to HAS_MONEY) even when
* the machine has nothing left to sell. */
public String insertCoin(int cents) {
if (status == Status.DISPENSING) {
return "rejected: mid-dispense";
}
balanceCents += cents;
status = Status.HAS_MONEY;
return "accepted " + cents + "c, balance=" + balanceCents + "c, status=" + status;
}
public String selectProduct(String code) {
Integer have = stock.get(code);
if (have == null || have <= 0) {
status = Status.SOLD_OUT;
return "sold out: " + code;
}
// BUG #2: never checks balanceCents against price.get(code) here --
// the author assumed dispense() would check it. It doesn't.
selected = code;
return code + " selected";
}
/** BUG #2 (continued): only checks that something is selected -- never
* checks status == HAS_MONEY, never checks balanceCents >= price. Call
* this right after selectProduct() with a zero balance and it dispenses. */
public String dispense() {
if (selected == null) return "rejected: nothing selected";
int cost = price.get(selected);
stock.put(selected, stock.get(selected) - 1);
int change = balanceCents - cost; // can go NEGATIVE -- nobody paid enough
String result = "DISPENSED " + selected + ", change=" + change
+ "c (balance was " + balanceCents + "c, cost=" + cost + "c)";
balanceCents = 0;
selected = null;
status = Status.IDLE;
return result;
}
public String status() { return status.toString(); }
}
// BreakItDemo.java (default package)
import vending.VendingMachine;
/**
* Run the exact same two illegal sequences against the naive enum+if
* machine and the State-pattern machine. The naive one corrupts itself
* silently; the State machine rejects both, by construction, with no
* special-case guard bolted on anywhere.
*/
public final class BreakItDemo {
public static void main(String[] args) {
System.out.println("== naive enum+if machine ==");
NaiveVendingMachine naive = new NaiveVendingMachine();
System.out.println("[bug 1: dispense without payment]");
System.out.println(naive.selectProduct("A1")); // balance is still 0
System.out.println(naive.dispense()); // DISPENSES with change=-150c !!
System.out.println();
System.out.println("[bug 2: accept coins when sold out]");
System.out.println(naive.selectProduct("A2")); // A2 has 0 stock -> SOLD_OUT
System.out.println(naive.insertCoin(100)); // silently ACCEPTED, status flips back
System.out.println("naive.status() = " + naive.status());
System.out.println();
System.out.println("== State-pattern machine (same two illegal sequences) ==");
VendingMachine sm = new VendingMachine();
sm.stock("A1", 150, 1);
sm.stock("A2", 200, 0);
sm.loadChange(100, 5);
sm.loadChange(25, 10);
System.out.println("[attempt: dispense without payment]");
System.out.println(sm.selectProduct("A1")); // REJECTED: insert coins first (state=IDLE)
System.out.println(sm.dispense()); // REJECTED: nothing selected (state=IDLE)
System.out.println();
System.out.println("[drain the only stocked item the LEGAL way, to reach SOLD_OUT honestly]");
System.out.println(sm.insertCoin(150));
System.out.println(sm.selectProduct("A1"));
System.out.println(sm.dispense());
System.out.println("state after drain = " + sm.currentState());
System.out.println();
System.out.println("[attempt: accept coins when sold out]");
System.out.println(sm.insertCoin(100)); // REJECTED: machine sold out, coin returned
}
}
Run it — actual output, captured verbatim from java BreakItDemo:
== naive enum+if machine ==
[bug 1: dispense without payment]
A1 selected
DISPENSED A1, change=-150c (balance was 0c, cost=150c)
[bug 2: accept coins when sold out]
sold out: A2
accepted 100c, balance=100c, status=HAS_MONEY
naive.status() = HAS_MONEY
== State-pattern machine (same two illegal sequences) ==
[attempt: dispense without payment]
REJECTED: insert coins before selecting a product (state=IDLE)
REJECTED: nothing to dispense (state=IDLE)
[drain the only stocked item the LEGAL way, to reach SOLD_OUT honestly]
accepted 150c, balance=150c -> HAS_MONEY
A1 selected, committed -> DISPENSING (call dispense())
DISPENSED A1, change=0c as {} -> IDLE
state after drain = SoldOutState
[attempt: accept coins when sold out]
REJECTED: machine sold out, coin returned (state=SOLD_OUT)
The naive machine dispensed a $1.50 product for $0 and printed a change of -150c — a negative change amount is not a display quirk, it's a receipt telling you the machine just gave away real inventory for nothing. Then it accepted a coin from a customer standing in front of a machine that has already told itself (via status = SOLD_OUT) that it has nothing left to sell. Neither bug required a race, a rare interleaving, or bad luck — both reproduce on every single run, single-threaded, because the naive design never had a mechanism to make them structurally impossible; it only had a mechanism to make them likely to be checked, if the author remembered. The State-pattern machine rejects the identical two attempts with a clear reason and zero corrupted state, and it does so without a single line of code anywhere that says "if sold out, reject coins" — SoldOutState.insertCoin() simply has no other behavior to fall back to.
The Go version reproduces the identical shape of both bugs and both rejections deterministically under go run . (from the consolidated demo.go — Go allows only one main() per binary, so all three movements' demos run from one entry point, clearly delimited by section headers in the output):
== naive enum+if machine ==
[bug 1: dispense without payment]
A1 selected
DISPENSED A1, change=-150c (balance was 0c, cost=150c)
[bug 2: accept coins when sold out]
sold out: A2
accepted 100c, balance=100c, status=1
naive.status = 1
== State-pattern machine (same two illegal sequences) ==
[attempt: dispense without payment]
REJECTED: insert coins before selecting a product (state=IDLE)
REJECTED: nothing to dispense (state=IDLE)
[drain the only stocked item the LEGAL way, to reach SOLD_OUT honestly]
accepted 150c, balance=150c -> HAS_MONEY
A1 selected, committed -> DISPENSING (call Dispense())
DISPENSED A1, change=0c as map[] -> IDLE
state after drain = SoldOutState
[attempt: accept coins when sold out]
REJECTED: machine sold out, coin returned (state=SOLD_OUT)
(naive.status prints as the raw iota int 1 in Go rather than Java's enum name — that's a language-idiom difference, not a different bug; it's still the same silent HAS_MONEY flip after a sold-out product was selected.) The naive struct, full source, same shape as the Java class above just spelled with Go idioms (a small naiveStatus int with iota constants instead of an enum):
// naive.go
package main
import "fmt"
// naiveVendingMachine mirrors Movement 1's trap: one status field and a
// couple of maps, with each method re-deriving what's "supposed" to be
// legal from scratch.
type naiveStatus int
const (
naiveIdle naiveStatus = iota
naiveHasMoney
naiveDispensing
naiveSoldOut
)
type naiveVendingMachine struct {
status naiveStatus
balanceCents int
selected string
price map[string]int
stock map[string]int
}
func newNaiveVendingMachine() *naiveVendingMachine {
return &naiveVendingMachine{
status: naiveIdle,
price: map[string]int{"A1": 150, "A2": 200},
stock: map[string]int{"A1": 1, "A2": 0}, // A2 starts sold out
}
}
// BUG #1: only excludes naiveDispensing -- forgets naiveSoldOut, so coins
// are silently accepted even when the machine has nothing left to sell.
func (n *naiveVendingMachine) insertCoin(cents int) string {
if n.status == naiveDispensing {
return "rejected: mid-dispense"
}
n.balanceCents += cents
n.status = naiveHasMoney
return fmt.Sprintf("accepted %dc, balance=%dc, status=%d", cents, n.balanceCents, n.status)
}
func (n *naiveVendingMachine) selectProduct(code string) string {
have := n.stock[code]
if have <= 0 {
n.status = naiveSoldOut
return "sold out: " + code
}
// BUG #2: never checks n.balanceCents against n.price[code].
n.selected = code
return code + " selected"
}
// BUG #2 (continued): only checks that something is selected -- never
// checks status or balance. Call right after selectProduct() with a zero
// balance and it dispenses anyway.
func (n *naiveVendingMachine) dispense() string {
if n.selected == "" {
return "rejected: nothing selected"
}
cost := n.price[n.selected]
n.stock[n.selected]--
change := n.balanceCents - cost // can go NEGATIVE
result := fmt.Sprintf("DISPENSED %s, change=%dc (balance was %dc, cost=%dc)",
n.selected, change, n.balanceCents, cost)
n.balanceCents = 0
n.selected = ""
n.status = naiveIdle
return result
}
And the Go demo entry point (Go permits exactly one main() per binary, so it drives all three movements' demos from one place):
// demo.go
package main
import "fmt"
func main() {
runStateMachineDemo()
fmt.Println()
runBreakItDemo()
fmt.Println()
runChangeAlgorithmsDemo()
}
func runBreakItDemo() {
fmt.Println("== naive enum+if machine ==")
naive := newNaiveVendingMachine()
fmt.Println("[bug 1: dispense without payment]")
fmt.Println(naive.selectProduct("A1"))
fmt.Println(naive.dispense())
fmt.Println()
fmt.Println("[bug 2: accept coins when sold out]")
fmt.Println(naive.selectProduct("A2"))
fmt.Println(naive.insertCoin(100))
fmt.Printf("naive.status = %d\n", naive.status)
fmt.Println()
fmt.Println("== State-pattern machine (same two illegal sequences) ==")
sm := NewVendingMachine()
sm.Stock("A1", 150, 1)
sm.Stock("A2", 200, 0)
sm.LoadChange(100, 5)
sm.LoadChange(25, 10)
fmt.Println("[attempt: dispense without payment]")
fmt.Println(sm.SelectProduct("A1"))
fmt.Println(sm.Dispense())
fmt.Println()
fmt.Println("[drain the only stocked item the LEGAL way, to reach SOLD_OUT honestly]")
fmt.Println(sm.InsertCoin(150))
fmt.Println(sm.SelectProduct("A1"))
fmt.Println(sm.Dispense())
fmt.Println("state after drain =", sm.CurrentState())
fmt.Println()
fmt.Println("[attempt: accept coins when sold out]")
fmt.Println(sm.InsertCoin(100))
}
(runStateMachineDemo, shown in Movement 4, and runChangeAlgorithmsDemo, shown in Movement 6, are the other two functions this main() calls — every function referenced anywhere on this page is defined somewhere on this page, nothing is hand-waved.)
6. Optimise — with trade-offs
| Approach | Illegal transitions | Adding a new state | Adding a new event | Use when |
|---|---|---|---|---|
| State pattern (this lab) | Structurally impossible where the method doesn't exist — nothing to forget | New class implementing the interface; existing states untouched | New interface method; compiler forces every state to implement it (or explicitly no-op it) — can't silently skip one | ≥3 states or the transition table is likely to grow (new payment types, new machine modes) — the default choice for this problem |
| enum + switch/if-else (Movement 1's trap) | Possible and easy — each of the (states × events) legality checks is a separate hand-written guard | Touch every switch statement in every method to add the new case | Touch every state's branch, in every method, by hand — nothing forces completeness | 2 states, one event, a genuinely trivial machine that will never grow — otherwise it's a trap, not a shortcut |
Explicit transition table (Map<State, Map<Event, State>>) | The allowed next-state question is data-driven and easy to audit in one place — but the side effects (charge balance, decrement stock, compute change) still need code per transition, defeating some of the win | Add rows/entries to the table — no new class | Add a column; still need to write the side-effect code somewhere and wire it to the table lookup | The state graph itself is complex and worth visualizing/validating (e.g. as data loaded from config) but each transition's side effect is simple or shared — a middle ground, not a universal upgrade over State |
The real judgment call: State and the transition table aren't really competitors — the transition table is a data-driven variant of the same idea (legality lives in one place, not scattered), while raw enum+switch is the thing both of them are correctly replacing. Reach for the explicit table when the interesting complexity is the shape of the graph itself (which transitions exist, useful to visualize or validate against a spec) and the per-transition logic is thin; reach for full State-pattern classes (this lab) when the interesting complexity is what happens during each transition — computing change, checking stock, refunding — because that logic needs a real method body, not a table cell.
Greedy vs. DP change-making — where greedy provably breaks. M3's makeChange is greedy: take as many of the largest denomination as fit, then the next, and so on. That's optimal (fewest coins) for a canonical coin system — and US currency (25/10/5/1) is canonical, so greedy is not just simple, it's provably correct there. It is not generally optimal, and the standard counterexample is denominations {1, 3, 4} needing amount 6:
// ChangeAlgorithms.java -- greedy vs DP, same problem, run and captured
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public final class ChangeAlgorithms {
static List<Integer> greedy(int amount, int[] denomsDesc) {
List<Integer> used = new ArrayList<Integer>();
int remaining = amount;
for (int d : denomsDesc) {
while (remaining >= d) { used.add(d); remaining -= d; }
}
if (remaining != 0) throw new IllegalStateException("greedy could not make exact change");
return used;
}
/** Minimum-coins DP: dp[a] = fewest coins to make amount a. */
static List<Integer> dpMinCoins(int amount, int[] denoms) {
int[] dp = new int[amount + 1];
int[] lastCoin = new int[amount + 1];
Arrays.fill(dp, Integer.MAX_VALUE);
dp[0] = 0;
for (int a = 1; a <= amount; a++) {
for (int d : denoms) {
if (d <= a && dp[a - d] != Integer.MAX_VALUE && dp[a - d] + 1 < dp[a]) {
dp[a] = dp[a - d] + 1;
lastCoin[a] = d;
}
}
}
if (dp[amount] == Integer.MAX_VALUE) throw new IllegalStateException("no exact change possible");
List<Integer> used = new ArrayList<Integer>();
int a = amount;
while (a > 0) { used.add(lastCoin[a]); a -= lastCoin[a]; }
return used;
}
public static void main(String[] args) {
int[] canonical = {25, 10, 5, 1};
List<Integer> g1 = greedy(41, canonical);
List<Integer> d1 = dpMinCoins(41, canonical);
System.out.println("-- Canonical system: US cents {25,10,5,1}, amount=41 --");
System.out.println("greedy: " + g1 + " (" + g1.size() + " coins)");
System.out.println("dp: " + d1 + " (" + d1.size() + " coins) -- same answer, greedy is optimal here");
System.out.println();
int[] nonCanonicalDesc = {4, 3, 1};
List<Integer> g2 = greedy(6, nonCanonicalDesc);
List<Integer> d2 = dpMinCoins(6, nonCanonicalDesc);
System.out.println("-- Non-canonical system: {1,3,4}, amount=6 --");
System.out.println("greedy: " + g2 + " (" + g2.size() + " coins) -- SUBOPTIMAL");
System.out.println("dp: " + d2 + " (" + d2.size() + " coins) -- optimal");
}
}
Actual output, java ChangeAlgorithms:
-- Canonical system: US cents {25,10,5,1}, amount=41 --
greedy: [25, 10, 5, 1] (4 coins)
dp: [25, 10, 5, 1] (4 coins) -- same answer, greedy is optimal here
-- Non-canonical system: {1,3,4}, amount=6 --
greedy: [4, 1, 1] (3 coins) -- SUBOPTIMAL
dp: [3, 3] (2 coins) -- optimal
Same comparison, Go (runChangeAlgorithmsDemo, called from demo.go's main() in Movement 5):
// changealgo.go
package main
import "fmt"
// greedyChange picks the largest denomination first. Returns (nil, false)
// if it can't make exact change -- the gap naive implementations skip.
func greedyChange(amount int, denomsDesc []int) ([]int, bool) {
var used []int
remaining := amount
for _, d := range denomsDesc {
for remaining >= d {
used = append(used, d)
remaining -= d
}
}
return used, remaining == 0
}
// dpMinCoins is the minimum-coins DP: guarantees the optimal count for ANY
// coin system -- canonical or not -- unlike greedy.
func dpMinCoins(amount int, denoms []int) ([]int, bool) {
const inf = 1 << 30
dp := make([]int, amount+1)
last := make([]int, amount+1)
for i := 1; i <= amount; i++ {
dp[i] = inf
}
for a := 1; a <= amount; a++ {
for _, d := range denoms {
if d <= a && dp[a-d] != inf && dp[a-d]+1 < dp[a] {
dp[a] = dp[a-d] + 1
last[a] = d
}
}
}
if dp[amount] == inf {
return nil, false
}
var used []int
for a := amount; a > 0; {
used = append(used, last[a])
a -= last[a]
}
return used, true
}
func runChangeAlgorithmsDemo() {
fmt.Println("-- Canonical system: US cents {25,10,5,1}, amount=41 --")
g, _ := greedyChange(41, []int{25, 10, 5, 1})
d, _ := dpMinCoins(41, []int{25, 10, 5, 1})
fmt.Printf("greedy: %v (%d coins)\n", g, len(g))
fmt.Printf("dp: %v (%d coins) -- same answer, greedy is optimal here\n", d, len(d))
fmt.Println()
fmt.Println("-- Non-canonical system: {1,3,4}, amount=6 --")
g2, _ := greedyChange(6, []int{4, 3, 1})
d2, _ := dpMinCoins(6, []int{4, 3, 1})
fmt.Printf("greedy: %v (%d coins) -- SUBOPTIMAL\n", g2, len(g2))
fmt.Printf("dp: %v (%d coins) -- optimal\n", d2, len(d2))
}
Output is identical in shape to the Java run (Go prints slices with spaces, not commas): greedy: [25 10 5 1] (4 coins) / dp: [25 10 5 1] (4 coins) for the canonical case, and greedy: [4 1 1] (3 coins) -- SUBOPTIMAL / dp: [3 3] (2 coins) -- optimal for {1,3,4} — same numbers, same conclusion, verified with go run . before publishing.
Greedy grabs the 4 first (largest fit), leaving 2, which only decomposes as two 1-coins — three coins total. The optimal answer, 3+3, is invisible to a greedy scan because greedy never reconsiders a choice once made. The minimum coin change DP tries every denomination at every amount and keeps the best, which costs O(amount × denominations) time and space instead of greedy's O(denominations) — a real cost, paid only where it's needed: vending machines the world over almost universally run on canonical currencies, so shipping greedy in production and reserving DP for the interview whiteboard (or for a currency you can't vouch for) is the correct, not lazy, choice.
Strategy for payment — a sketch, not part of the compiled core (this lab pays in coins only; card/note support is the natural M5 extension): a PaymentMethod interface with int amountTenderedCents(), implemented by CoinPayment, CardPayment, NotePayment. HasMoneyState.insertCoin-equivalent would call payment.amountTenderedCents() and add it to the balance, never caring which concrete payment type it was — exactly the same Strategy shape as the Strategy Pattern and the parking lot's pluggable FareStrategy. This is the answer to Movement 7's "how do you add a new payment type" — the states never change; only a new class implementing PaymentMethod is added.
7. Defend under drilling
An interviewer will push on the design. These are the follow-ups that come up almost every time, with the answer a staff engineer gives — short, concrete, no hedging.
- "Why State over a switch — isn't that just more classes for the same logic?" It's the same logic, but with a different failure mode when someone forgets a case. A switch's missing case fails silently — the enum+if machine in Movement 5 just... dispensed for free, no error, no warning. A missing case in State pattern means a state class that doesn't implement an interface method, which doesn't compile. You're trading a few extra files for turning "an interviewer/reviewer has to notice a missing guard by reading every branch" into "the compiler refuses to build." That trade is almost always worth it once you have more than a couple of states.
- "When does greedy change-making break, concretely?" Any non-canonical coin system — the textbook example is denominations
{1, 3, 4}needing 6: greedy gives 4+1+1 (3 coins), the optimal answer is 3+3 (2 coins), proven in Movement 6. US coins happen to be canonical, so production vending machines can ship greedy safely; the moment you can't prove your denominations are canonical (a points/credits system with odd conversion rates, say), fall back to the DP formulation, which is always correct at the cost ofO(amount × denominations)instead of greedy's near-linear scan. - "How do you add a new payment type — card, say — without touching any of the four state classes?" A
PaymentMethodStrategy (Movement 6): each state'sinsertCoin-equivalent event asks the payment object "how much did you just tender" and adds that to balance — it never branches on payment type. AddCardPaymentas a new class implementing the same interface; zero state classes change. If you find yourself editingHasMoneyStateto add anif (paymentType == CARD), that's the same mistake as Movement 1 all over again, just relocated. - "It's one physical machine, but a technician restocks it while a purchase might be in progress — how do you model that?" Restock is just another event contending for the same single lock every customer event goes through (
synchronized/sync.Mutexin the reference impl) — it either waits for an in-flight dispense to finish or, if you want the technician not to block a paying customer, you shard the lock per product slot (restocking slot A never blocks a purchase from slot B), the exact same single-lock-vs-sharded-lock trade-off the parking-lot lab makes for its allocator. The invariant that must never break either way: stock can never be decremented and the change reserve can never be read mid-restock-write — same "one critical section" discipline, just scoped smaller if you shard it. - "What breaks at 100× the scale — say, a chain of 500 machines reporting to a central system?" The single in-process lock and the local coin/stock counters stop being the right model the instant "the machine" becomes "many machines plus a backend." The state machine itself (Idle/HasMoney/Dispensing/SoldOut) still holds locally — that part doesn't change, because it's still modeling one physical box. What changes is everything around it: inventory becomes a replicated count with eventual consistency (a machine can be briefly wrong about "sold out" if the restock signal hasn't propagated yet, which is an acceptable trade for availability), and the coin/change reserve becomes a per-machine resource that reports up rather than a globally-consistent number. The interesting new failure mode isn't "two threads pop the same spot" (this lab's single-machine concurrency answer already covers that) — it's "the central system's view of a machine's stock is stale by however long the last heartbeat took," which is a CAP-style trade-off, not a bug to eliminate.
8. You can now defend
- You can implement a vending machine as a real State pattern — one interface, one class per state, one context that forwards events — in both Java and Go, and explain precisely why illegal transitions are impossible to write in the states where they're illegal, not just checked for.
- You've broken the naive enum+if version on purpose — dispense without payment, coins accepted while sold out — and watched the State-pattern version reject the identical two attempts cleanly, with no corrupted balance and no special-case guard added anywhere to fix it.
- You can place State pattern, enum+switch, and an explicit transition table on a trade-off spectrum and argue when each earns its keep — and you can prove, with a concrete counterexample (
{1,3,4}, amount 6), exactly when greedy change-making stops being optimal and why DP fixes it at a real, nameable cost. - You can extend the machine with a new payment type via Strategy without touching a single state class, and you can escalate the single-machine concurrency model (one lock, one physical box) to the distributed version (many machines, eventually-consistent inventory) and name the CAP-style trade-off that appears at that scale.
Re-authored/Deepened for this guide. Reference code compiled and executed (javac -Xlint:all + java; gofmt, go vet, go build, go run) before publishing; both break-it bugs reproduce deterministically on every run, in both languages. See also: the State Pattern concept page, the Strategy Pattern, and Minimum Coin Change (DP).
🤖 Don't fully get this? Learn it with Claude
Stuck on Design a Vending Machine? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Design a Vending Machine** (Hands-On Builds) and want to truly understand it. Explain Design a Vending Machine from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Design a Vending Machine** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Design a Vending Machine** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Design a Vending Machine** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.