Course Content
Object-Oriented Design Interview
14 sections · 29 lessons
Vending Machine: the class model, state transitions in code, and extensions
The previous lesson settled on the State pattern. This one turns that decision into a model and code: the classes around the states, the transition table that proves every combination was considered, and the dispense transition where money actually moves. It ends with the extension questions, each of which the design should absorb as a new state or a new class.
The class diagram
Six classes plus one class per state. Model the machine in words first, then draw.
One sentence per class
VendingMachine — holds the current state and the current transaction, and forwards events to the state.State — decides what one event does while the machine is in one phase.Inventory — tracks how many of each item are in each slot.Item — a product's code, name, and price.CashRegister — holds the coins available for change and makes change from them.Escrow — holds the coins inserted for the current transaction until it commits or refunds.Every one passes the one-sentence test from Phase 4: assigning responsibilities.
The structure
What to notice: VendingMachine holds one State reference and five state classes implement it. The escrow sits between the user and the register, which is what makes a mid-transaction refund possible.
The three decisions in that diagram
1. VendingMachine is a context, not a god class. It holds references and forwards. Its insertMoney is one line: currentState.insertMoney(this, coin). All the logic is in states, inventory, and the register.
2. Escrow is separate from CashRegister. This is the modelling decision worth defending. Coins in escrow belong to the user; coins in the register belong to the machine and are available as change. Merging them means a refund might hand back the coins the machine needed for someone else's change, and a crash mid-transaction leaves money that nobody can attribute. Two small classes make the invariant from Requirements structurally true.
3. States are stateless. Each state class holds no fields — the machine holds the data and passes itself in. That means one instance per state can be shared, created once at startup. Say this: "The states are stateless, so I'll create one of each and reuse them rather than allocating on every transition."
The state transitions, in code
Write two states in full and the transition table for the rest. Two is enough to show the pattern working; writing all five is a waste of the fifteen minutes.
One change from the State interface sketched in the previous lesson: insertMoney now takes a Coin rather than a Money amount. Once Escrow exists it has to hold the physical coins, so it can hand back exactly what was inserted; everything else about the interface is the same.
The transition table, written out
Nothing is left implicit. Rows are states, columns are events, cells are the action and the resulting state.
selectItem(code) | insertMoney(c) | dispense() | cancel() | |
|---|---|---|---|---|
| IDLE | if in stock → ITEM_SELECTED; else reject | return coin, stay | reject "select an item" | no-op |
| ITEM_SELECTED | change selection, stay | add to escrow; if enough → PAID | reject "insufficient funds" | refund escrow → IDLE |
| PAID | reject "payment complete" | return coin, stay | release item, give change → DISPENSING | refund escrow → IDLE |
| DISPENSING | reject "busy" | return coin, stay | reject "already dispensing" | reject "too late" |
| OUT_OF_SERVICE | reject | return coin | reject | no-op |
Twenty cells. Drawing this table on the board and filling it in is one of the highest-value five minutes available in this problem, because it proves you have considered every combination — including the eleven that are refusals.
Two states in full
1public class IdleState implements State {23 public void selectItem(VendingMachine m, String code) {4 Item item = m.getInventory().get(code);5 if (item == null) { m.display("Invalid code"); return; }6 if (m.getInventory().countOf(code) == 0) { m.display("Sold out"); return; }7 if (!m.getRegister().canMakeChangeFor(item.getPrice())) {8 m.display("Exact change only"); // still allowed, but warn up front9 }10 m.setSelectedItem(item);11 m.display("Please insert " + item.getPrice());12 m.changeState(m.itemSelectedState());13 }1415 public void insertMoney(VendingMachine m, Coin coin) {16 m.returnCoin(coin); // nothing selected — hand it straight back17 m.display("Select an item first");18 }1920 public void dispense(VendingMachine m) { m.display("Select an item first"); }2122 public void cancel(VendingMachine m) { /* nothing to cancel */ }23}1public class ItemSelectedState implements State {23 public void insertMoney(VendingMachine m, Coin coin) {4 m.getEscrow().add(coin); // held, not committed5 Money owed = m.getSelectedItem().getPrice().minus(m.getEscrow().total());6 if (owed.isPositive()) {7 m.display("Insert " + owed + " more");8 } else {9 m.display("Press dispense");10 m.changeState(m.paidState());11 }12 }1314 public void cancel(VendingMachine m) {15 m.returnCoins(m.getEscrow().releaseToUser()); // full refund from escrow16 m.clearSelection();17 m.changeState(m.idleState());18 }1920 public void selectItem(VendingMachine m, String code) {21 m.setSelectedItem(m.getInventory().get(code)); // allowed: changing your mind22 }2324 public void dispense(VendingMachine m) { m.display("Insufficient payment"); }25}Notice what each method is: three to six lines, one concern, no branching on state. The if (owed.isPositive()) is a branch on data, not on state, which is the distinction that matters.
The dispense transition, where the money moves
This is the one to write carefully, because it is where the invariant from Requirements is either preserved or broken.
1public class PaidState implements State {2 public void dispense(VendingMachine m) {3 Item item = m.getSelectedItem();4 Money change = m.getEscrow().total().minus(item.getPrice());56 if (!m.getRegister().canMakeChange(change)) { // check BEFORE committing7 m.returnCoins(m.getEscrow().releaseToUser());8 m.display("Cannot give change — money returned");9 m.clearSelection();10 m.changeState(m.idleState());11 return;12 }1314 m.changeState(m.dispensingState()); // reject further input now15 try {16 m.getDispenser().release(item.getSlotCode()); // hardware, can fail17 m.getInventory().decrement(item.getSlotCode());18 m.getEscrow().commitToRegister(m.getRegister()); // money is ours only now19 m.returnCoins(m.getRegister().makeChange(change));20 m.changeState(m.idleState());21 } catch (DispenseFailedException e) {22 m.returnCoins(m.getEscrow().releaseToUser()); // still the user's money23 m.changeState(m.outOfServiceState());24 }25 }26 // selectItem, insertMoney, cancel: reject27}Three ordering decisions, each worth saying out loud:
- Change is checked before anything is committed. Discovering you cannot make change after the item has dropped is the failure that loses money.
- The state changes to DISPENSING before the hardware call, so a button pressed during those two seconds is rejected rather than starting a second transaction.
- Escrow commits after the item is released, not before. If the motor jams, the coins are still the user's and go back. This ordering is the entire reason
Escrowis its own class.
Extensions
1. Change-making, and when it is impossible
Giving change is the coin change problem. Given denominations and a target, produce the coins.
The greedy approach — take the largest coin that fits, repeat — is correct for the ordinary currency systems used in most countries, where each denomination is a multiple that makes greedy safe. It is not correct in general: with denominations {1, 3, 4} and a target of 6, greedy gives 4+1+1 (three coins) while the optimal is 3+3 (two coins). Worse, with {1, 5, 6} and stock limits, greedy can fail to find a solution that exists.
The practical answer for an interview:
1public Optional<List<Coin>> makeChange(Money amount) {2 List<Coin> result = new ArrayList<>();3 Money remaining = amount;4 for (Coin coin : denominationsHighToLow) { // greedy, respecting stock5 while (remaining.isAtLeast(coin.value()) && countOf(coin) > 0) {6 result.add(coin);7 remaining = remaining.minus(coin.value());8 }9 }10 return remaining.isZero() ? Optional.of(result) : Optional.empty();11}Then say the honest caveat: "Greedy works for standard denominations. If we needed a guarantee across arbitrary denominations with limited stock, this becomes a bounded-knapsack problem and I'd use dynamic programming over the amount in minor units — that is a coding-round answer, not a design-round one." Naming the limit without spending ten minutes on it is exactly right.
When change is impossible, the machine refuses before committing (the PaidState.dispense code above) and displays "exact change only" at selection time so the user is warned early.
2. Concurrent access
A physical vending machine has one coin slot and one keypad, so there is one user. But the events do not arrive on one thread: the coin sensor, the keypad, and the dispense-complete signal from the motor are all separate hardware interrupts.
The clean answer is a single-threaded event loop: hardware pushes events onto a queue and one thread drains it, so state transitions are serialised by construction. If the interviewer prefers locks, synchronized on the machine's event methods gives the same guarantee more crudely.
Raise this yourself:
"There is one user, but three sources of events — coin sensor, keypad, and the motor's completion signal. I'd put those on a queue drained by one thread so transitions are serialised. Otherwise a coin arriving during the dispense transition can be added to an escrow that is being committed."
3. Restocking and maintenance
Restocking is a state, not a method: OutOfServiceState with an operator key. While in it, coin insertion returns coins immediately and selections are refused. The operator can restock inventory and refill the change hopper, then transition back to idle.
The interesting sub-question: what happens if an operator opens the machine mid-transaction? Refund from escrow first, then enter service mode. Same invariant.
4. Card payments and their asynchrony
A card payment introduces a state the cash design does not have: awaiting authorisation. The machine has asked a payment terminal and does not know the answer yet.
Three things follow, and mentioning all three is a strong finish:
- A timeout is required. If the terminal never responds — a network failure — the machine cannot wait forever. After some seconds it cancels the authorisation and returns to idle.
- The reply is a new event,
authorisationApprovedorauthorisationDeclined, soStategrows two methods. Every existing state must reject them, which the compiler will enforce. - Reversals are needed. If authorisation succeeds and the motor then jams, the machine must void the authorisation. That is the card equivalent of returning coins from escrow — the same invariant, a different mechanism.