Object-Oriented Design Interview

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

Text
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

VendingMachine- currentState: State- selectedItem: Item- inventory: Inventory- register: CashRegister- escrow: Escrow+ selectItem(code)+ insertMoney(coin)+ dispense()+ cancel()+ changeState(State)«interface»State+ selectItem(machine, code)+ insertMoney(machine, coin)+ dispense(machine)+ cancel(machine)Inventory- slots: Map+ get(code) Item+ countOf(code) int+ decrement(code)+ restock(code, qty)CashRegister- coinCounts: Map+ canMakeChange(amount)+ makeChange(amount) List+ commit(coins)Escrow- heldCoins: List+ add(coin)+ total() Money+ releaseToUser() List+ commitToRegister(CashRegister)delegates tocommits intonotationassociation — knows aboutdepends on — uses transientlyEscrow exists so the machine can always givethe money back: coins are held apart until thesale actually completes.
The machine holds a State and forwards every button press to it, so adding a state never edits an if-chain.

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 five states, and the legal movesIdleItemSelectedHasMoneyDispensingIdle againRefund returns HasMoney to Idle; a sold-out slot bounces ItemSelected straight back.
Writing two states in full and tabling the rest proves the pattern without spending the fifteen minutes.

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()
IDLEif in stock → ITEM_SELECTED; else rejectreturn coin, stayreject "select an item"no-op
ITEM_SELECTEDchange selection, stayadd to escrow; if enough → PAIDreject "insufficient funds"refund escrow → IDLE
PAIDreject "payment complete"return coin, stayrelease item, give change → DISPENSINGrefund escrow → IDLE
DISPENSINGreject "busy"return coin, stayreject "already dispensing"reject "too late"
OUT_OF_SERVICErejectreturn coinrejectno-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

Java
public class IdleState implements State {    public void selectItem(VendingMachine m, String code) {        Item item = m.getInventory().get(code);        if (item == null) { m.display("Invalid code"); return; }        if (m.getInventory().countOf(code) == 0) { m.display("Sold out"); return; }        if (!m.getRegister().canMakeChangeFor(item.getPrice())) {            m.display("Exact change only");        // still allowed, but warn up front        }        m.setSelectedItem(item);        m.display("Please insert " + item.getPrice());        m.changeState(m.itemSelectedState());    }    public void insertMoney(VendingMachine m, Coin coin) {        m.returnCoin(coin);                        // nothing selected — hand it straight back        m.display("Select an item first");    }    public void dispense(VendingMachine m) { m.display("Select an item first"); }    public void cancel(VendingMachine m) { /* nothing to cancel */ }}
Java
public class ItemSelectedState implements State {    public void insertMoney(VendingMachine m, Coin coin) {        m.getEscrow().add(coin);                          // held, not committed        Money owed = m.getSelectedItem().getPrice().minus(m.getEscrow().total());        if (owed.isPositive()) {            m.display("Insert " + owed + " more");        } else {            m.display("Press dispense");            m.changeState(m.paidState());        }    }    public void cancel(VendingMachine m) {        m.returnCoins(m.getEscrow().releaseToUser());     // full refund from escrow        m.clearSelection();        m.changeState(m.idleState());    }    public void selectItem(VendingMachine m, String code) {        m.setSelectedItem(m.getInventory().get(code));    // allowed: changing your mind    }    public void dispense(VendingMachine m) { m.display("Insufficient payment"); }}

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.

Java
public class PaidState implements State {    public void dispense(VendingMachine m) {        Item item = m.getSelectedItem();        Money change = m.getEscrow().total().minus(item.getPrice());        if (!m.getRegister().canMakeChange(change)) {          // check BEFORE committing            m.returnCoins(m.getEscrow().releaseToUser());            m.display("Cannot give change — money returned");            m.clearSelection();            m.changeState(m.idleState());            return;        }        m.changeState(m.dispensingState());                    // reject further input now        try {            m.getDispenser().release(item.getSlotCode());       // hardware, can fail            m.getInventory().decrement(item.getSlotCode());            m.getEscrow().commitToRegister(m.getRegister());    // money is ours only now            m.returnCoins(m.getRegister().makeChange(change));            m.changeState(m.idleState());        } catch (DispenseFailedException e) {            m.returnCoins(m.getEscrow().releaseToUser());       // still the user's money            m.changeState(m.outOfServiceState());        }    }    // selectItem, insertMoney, cancel: reject}

Three ordering decisions, each worth saying out loud:

  1. Change is checked before anything is committed. Discovering you cannot make change after the item has dropped is the failure that loses money.
  2. 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.
  3. 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 Escrow is 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.

Four follow-ups, one of them algorithmicVending extensionsChange-making, refusalConcurrent accessRestock and serviceAsync card payment
Change-making is coin change: greedy works for real denominations, and you should say why.

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:

Java
public Optional<List<Coin>> makeChange(Money amount) {    List<Coin> result = new ArrayList<>();    Money remaining = amount;    for (Coin coin : denominationsHighToLow) {           // greedy, respecting stock        while (remaining.isAtLeast(coin.value()) && countOf(coin) > 0) {            result.add(coin);            remaining = remaining.minus(coin.value());        }    }    return remaining.isZero() ? Optional.of(result) : Optional.empty();}

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, authorisationApproved or authorisationDeclined, so State grows 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.