Free preview

Why this matters: the vending machine's implementation act has one grading question — do illegal states stay unrepresentable in the actual code? Every seam claimed in the design should be visible: states as structure, one transition layer, three ledgers that never blur, and the change-maker consulted before anything drops.

Money first: denominations and ledgers

java
enum Coin { ONE(1), TWO(2), FIVE(5), TEN(10), TWENTY(20); final int value; } // A bag of coins by denomination — used for ALL THREE ledgers. class CoinBundle { Map<Coin, Integer> counts = new EnumMap<>(Coin.class); int total() { /* sum of value * count */ } void add(CoinBundle other) { /* merge counts */ } void remove(CoinBundle other) { /* subtract; never below zero */ } }

One type, three instances with three meanings: inserted (this purchase, refundable), changeFloat (the machine's change stock), cashBox (collected revenue). Reusing the type while keeping the instances separate is the point — say it as you write it.

The change-maker

java
class ChangeMaker { // Greedy: correct for canonical denomination sets like ours. // Returns the exact coins to dispense, or empty if unmakeable. Optional<CoinBundle> make(int amount, CoinBundle from) { CoinBundle result = new CoinBundle(); for (Coin c : Coin.largestFirst()) { int take = Math.min(amount / c.value, from.count(c)); result.put(c, take); amount -= take * c.value; } return amount == 0 ? Optional.of(result) : Optional.empty(); } }

The comment carries the correctness caveat from lesson 02 — greedy is denomination-dependent — and the return type carries the refusal path: empty means the sale must not proceed. Feasibility and execution are the same call, so they cannot disagree.

States and the machine

java
interface State { // Each state implements ONLY its legal events; the default // implementations reject with a clear signal. default State onCoin(Machine m, Coin c) { return reject(m); } default State onSelection(Machine m, String slot) { return reject(m); } default State onCancel(Machine m) { return reject(m); } } class Machine { State state = new Idle(); CoinBundle inserted = new CoinBundle(); final CoinBundle changeFloat, cashBox; final Inventory inventory; // slot -> {product, price, stock} final ChangeMaker changeMaker; final Hardware hw; // commands OUT: dispense, returnCoins void handle(Event e) { state = e.applyTo(state, this); } // ONE transition layer }

Every event funnels through handle — the single transition layer promised in the design. The Hardware interface is the outbound boundary: the machine issues commands, tests substitute a fake.

The collecting state, where the round is won

java
class Collecting implements State { public State onCoin(Machine m, Coin c) { m.inserted.put(c, 1); return this; // stay collecting } public State onCancel(Machine m) { m.hw.returnCoins(m.inserted); // full refund m.inserted = new CoinBundle(); return new Idle(); } public State onSelection(Machine m, String slot) { Item item = m.inventory.get(slot); if (item == null || item.stock == 0) { m.hw.showSoldOut(slot); return this; } int price = item.price, paid = m.inserted.total(); if (paid < price) { m.hw.showBalance(price - paid); return this; } // The dispense invariant: change must be makeable BEFORE commitment. // Change comes from float + the coins just inserted. CoinBundle available = m.changeFloat.plus(m.inserted); Optional<CoinBundle> change = m.changeMaker.make(paid - price, available); if (change.isEmpty()) { m.hw.returnCoins(m.inserted); // refuse, refund m.inserted = new CoinBundle(); m.hw.showCannotMakeChange(); return new Idle(); } return new Dispensing(slot, change.get()); } }

Walk an interviewer through the selection path slowly — it is the whole problem in one method: sold-out exit, insufficient-balance continuation, and the three-part dispense invariant with the refusal branch, all before Dispensing exists. One subtlety worth voicing: change is made from float plus the just-inserted coins — the 20 the buyer inserted is legitimately part of what can come back as change.

Dispensing: the point of no return

java
class Dispensing implements State { Dispensing(String slot, CoinBundle change) { // entered only when the invariant held; no user events accepted here } State run(Machine m) { m.inventory.decrement(slot); m.changeFloat.add(m.inserted); m.changeFloat.remove(change); m.cashBox.addValue(/* price */); m.hw.dispense(slot); m.hw.returnCoins(change); m.inserted = new CoinBundle(); return new Idle(); } }

No onCancel here — dispensing accepts no user events, which the State pattern makes structural: the method simply doesn't exist to be called incorrectly. The ledger movements happen in one place, in order, and the machine returns to idle clean.

Key takeaway

The implementation keeps the design's promises structurally: one CoinBundle type for three never-blurred ledgers, a change-maker whose feasibility answer and coin selection are the same call, states that implement only their legal events with a single transition layer, and the dispense invariant checked in full — stock, balance, makeable change — before the point of no return. The most senior line in the file is the Optional that turns "can't make change" into a refusal instead of a shortchange.

Enjoying the preview?

Create a free account to unlock the rest of this course, the in-browser judge, and live AI mock interviews.

Sign up free to continue