Skip to content

Vending Machine#

Problem statement (interviewer prompt)

Design the software for a vending machine: insert coins / scan card, browse + select an item, validate stock, vend, return change. Model the state machine (Idle / Selecting / Dispensing / Refunding), inventory, payment methods, and the maintenance interface.

classDiagram
  class VendingMachine {
    +select(item)
    +pay(amount)
    +dispense()
    +cancel()
  }
  class Inventory
  class CashRegister
  class State
  VendingMachine --> Inventory
  VendingMachine --> CashRegister
  VendingMachine --> State
classDiagram
  direction TB

  class VendingMachine {
    -inv: Inventory
    -reg: CashRegister
    -state: VMState
    +select(slot)
    +insertCash(c: Coin)
    +cardPay(amount)
    +cancel()
    +dispense()
  }

  class VMState {
    <<interface>>
    +select(vm, slot)
    +pay(vm, amount)
    +cancel(vm)
    +dispense(vm)
  }
  class IdleState
  class SelectedState
  class PaidState
  class DispensingState
  class OutOfServiceState

  class Item {
    +sku
    +name
    +price
  }

  class Inventory {
    +stock: Map slot -> List Item
    +reserve(slot)
    +dispense(slot)
    +reload(items)
  }

  class CashRegister {
    -balance: Map Denom -> int
    +acceptCoin(c)
    +makeChange(amount) List~Coin~
    +canMakeChange(amount) bool
  }

  class PaymentMethod {
    <<interface>>
    +pay(amount) bool
  }
  class CashPayment
  class CardPayment
  class UPIPayment

  class Display
  class CoinReturn
  class Slot

  VendingMachine --> VMState
  VMState <|.. IdleState
  VMState <|.. SelectedState
  VMState <|.. PaidState
  VMState <|.. DispensingState
  VMState <|.. OutOfServiceState
  VendingMachine *-- Inventory
  VendingMachine *-- CashRegister
  VendingMachine *-- Display
  VendingMachine *-- CoinReturn
  Inventory *-- "many" Slot
  Slot *-- "many" Item
  VendingMachine --> PaymentMethod
  PaymentMethod <|.. CashPayment
  PaymentMethod <|.. CardPayment
  PaymentMethod <|.. UPIPayment

Vending machine state machine#

The whole interview hinges on the state machine, because every button press, coin drop, and hardware sensor must be valid for exactly one current state. Modelling the lifecycle as an explicit VMState interface (instead of nested ifs on a status enum) is what unlocks the State pattern and stops the VendingMachine god-object from sprouting dozens of conditional branches. The diagram below shows the happy path along with the four error states an interviewer will probe: InvalidCoin, OutOfStock, InsufficientFunds, and ProductJammed.

stateDiagram-v2
  direction TB
  [*] --> Idle
  Idle --> CoinInserted: insertCoin valid
  Idle --> InvalidCoin: insertCoin rejected
  InvalidCoin --> Idle: coin returned
  CoinInserted --> CoinInserted: insertCoin more
  CoinInserted --> ProductSelected: selectProduct in-stock
  CoinInserted --> OutOfStock: selectProduct empty
  CoinInserted --> ChangeReturned: cancel
  OutOfStock --> CoinInserted: pick another
  ProductSelected --> InsufficientFunds: price gt deposited
  InsufficientFunds --> CoinInserted: insert more
  InsufficientFunds --> ChangeReturned: cancel
  ProductSelected --> ProductDispensed: vend ok
  ProductSelected --> ProductJammed: motor stall
  ProductJammed --> ChangeReturned: refund issued
  ProductDispensed --> ChangeReturned: change due
  ProductDispensed --> Idle: no change due
  ChangeReturned --> Idle: coins collected

Three transitions in this diagram carry the most interview weight. First, the CoinInserted -> CoinInserted self-loop encodes that multiple coins are summed into a running deposit before any product is selected; the running total lives on a Session object, not on the state itself, so cancelling at any point returns the full deposit. Second, the ProductSelected -> ProductJammed -> ChangeReturned chain is the one path where the user has paid but received nothing; the refund must include the full deposit (not just change) and the slot is locked until maintenance clears it. Third, ProductDispensed -> Idle skips the change step only when the running deposit equals the product price to the last paisa, which is rare in cash flows but common with card or UPI payments.

State pattern in Java#

The sketch below pins down the VendingState interface and three concrete states. The VendingMachine context simply forwards every user event to state.method(this, arg), and each state decides which transition to fire by calling vm.setState(...). New states (for example MaintenanceState for restock or OutOfServiceState for a power-on self-test failure) plug in without touching existing code.

public interface VendingState {
    void insertCoin(VendingMachine vm, Coin coin);
    void selectProduct(VendingMachine vm, String slot);
    void dispense(VendingMachine vm);
    void cancel(VendingMachine vm);
}

public final class IdleState implements VendingState {
    public void insertCoin(VendingMachine vm, Coin coin) {
        if (!vm.register().isValid(coin)) {
            vm.coinReturn().eject(coin);
            return;                                // stay in Idle
        }
        vm.session().add(coin);
        vm.setState(new HasMoneyState());
    }
    public void selectProduct(VendingMachine vm, String slot) { /* ignored */ }
    public void dispense(VendingMachine vm) { /* ignored */ }
    public void cancel(VendingMachine vm) { /* no-op */ }
}

public final class HasMoneyState implements VendingState {
    public void insertCoin(VendingMachine vm, Coin coin) {
        vm.session().add(coin);                    // self-loop, accumulate
    }
    public void selectProduct(VendingMachine vm, String slot) {
        if (!vm.inventory().hasStock(slot)) { vm.display().show("OUT OF STOCK"); return; }
        if (vm.session().deposit() < vm.inventory().priceOf(slot)) {
            vm.display().show("INSERT MORE COINS");
            return;
        }
        vm.inventory().reserve(slot);
        vm.setState(new DispensingState(slot));
    }
    public void cancel(VendingMachine vm) {
        vm.coinReturn().eject(vm.session().drain());
        vm.setState(new IdleState());
    }
    public void dispense(VendingMachine vm) { /* ignored */ }
}

public final class DispensingState implements VendingState {
    private final String slot;
    public DispensingState(String slot) { this.slot = slot; }
    public void dispense(VendingMachine vm) {
        try {
            vm.inventory().dispense(slot);
            int change = vm.session().deposit() - vm.inventory().priceOf(slot);
            vm.coinReturn().eject(vm.register().makeChange(change));
            vm.setState(new IdleState());
        } catch (HardwareException e) {
            vm.coinReturn().eject(vm.session().drain());     // full refund
            vm.inventory().lockSlot(slot);                   // ops must clear
            vm.setState(new IdleState());
        }
    }
    public void insertCoin(VendingMachine vm, Coin c) { vm.coinReturn().eject(c); }
    public void selectProduct(VendingMachine vm, String s) { /* ignored */ }
    public void cancel(VendingMachine vm) { /* too late */ }
}

Two non-obvious details: the Session (not the state object) carries the running deposit so cancelling collapses to a single object swap, and the dispense failure path issues a full refund and locks the slot rather than just refunding change. That separation is what lets a low-paid technician clear the slot later without touching cash logic.

Design patterns used#

  • State is the dominant pattern. Each phase of the vend lifecycle is its own VendingState implementation, and the VendingMachine context just forwards events to the current state. Adding MaintenanceState, OutOfServiceState, or RefrigerationFaultState does not modify any existing class.
  • Strategy drives the change-making algorithm. ChangeStrategy.split(amount, coinInventory) is typically a greedy denomination breakdown (largest coins first that the cassette still has), but an interviewer may ask for a dynamic-programming variant that minimises coin count when the greedy answer fails; swapping the strategy is one constructor argument.
  • Observer handles inventory and hardware notifications. The Inventory is the subject; LowStockAlerter, RestockEmailer, and MaintenanceDashboard are observers that subscribe to onSlotBelowThreshold(slot) events. The same channel carries onCoinHopperLow(denom) from the CashRegister.
  • Singleton is the right fit for the CoinDispenser and Inventory controllers because the JVM owns exactly one physical hopper and one rack per machine. Making either non-singleton invites a second instance that overwrites the first hopper's counters.
  • Command wraps each Transaction (a coin insertion, a product reservation, a dispense) so it can be journalled, replayed, and reversed by id; this is what makes journal-based recovery after power loss possible.

Common variants & follow-ups#

  • Cashless payment: card swipe, NFC tap, or QR-scanned UPI. A PaymentMethod interface with CoinPayment, CardPayment, and UpiPayment implementations replaces the coin hopper for the deposit step. The state machine collapses CoinInserted into a single PaymentAuthorised transition because the amount is exact by construction, which also means ChangeReturned is skipped on the happy path.
  • Multi-currency vending (airport, border kiosks) adds a Currency field on every Coin and a RateProvider strategy that converts the deposit to the machine's pricing currency before the selectProduct check. The FX margin is recorded as a separate journal entry for accounting.
  • Refrigerated vending for cold drinks, dairy, or pharmacy products adds a parallel temperature state machine: Cooling -> WithinRange -> WarmingFault. If the temperature crosses the safe threshold the product slot is locked and the user sees TEMP FAULT instead of an in-stock count; vending of perishables is blocked until maintenance resets the alarm.
  • Refill workflow is a privileged MaintenanceState reached by a key switch or a service-mode card. In maintenance mode insertCoin is rerouted to CashRegister.reload(denom, count) and selectProduct becomes Inventory.restock(slot, items). Audit logs are written for every restock.
  • Refund button: a physical or on-screen button that always works in CoinInserted and InsufficientFunds, and works in ProductSelected only before the motor has fired. The behaviour is identical to cancel() in the state diagram but exposed as a discoverable UI.

Edge cases#

  • Coin jam in the validator: the validator raises CoinJamException mid-insertion. The state stays in its current phase, the validator's optical path is paused, and the display shows RETURN COIN -> CALL OPS. The journal records the partial deposit so a technician can refund the user manually.
  • No exact change available: before transitioning to DispensingState, the machine asks CashRegister.canMakeChange(deposit - price). If the answer is false, the selectProduct call short-circuits with EXACT CHANGE ONLY and the user can either insert smaller coins, choose a different product priced to the deposit, or cancel for a full refund.
  • Product jam after coin accepted: the motor stalls, the optical drop sensor never fires, and the dispense path catches HardwareException. The refund is the entire deposit (not just change) because the user got nothing, and the slot is locked via Inventory.lockSlot(slot) so the next user is steered elsewhere until a technician clears the auger.
  • Power outage mid-dispense: the dispenser writes a journal entry before it energises the motor. On reboot the controller replays the journal, queries the drop sensor for its post-fault state, and either commits the sale (sensor confirms drop) or reverses the deposit (sensor empty). The reversal is idempotent on transaction.id, so replaying twice on a flaky boot is safe.
  • Simultaneous selection of out-of-stock vs in-stock product: on touchscreen machines the user can tap two slots in the same animation frame. The dispatcher serialises events on the machine's event queue, processes the first slot, and if it dispenses successfully drops the second tap. If the first slot is empty the second tap is processed normally; the state machine never sees overlapping selectProduct calls.

Glossary & fundamentals#

Concept What it is Fundamentals
State machine VM states mvcc-isolation-levels
Idempotency retry-safe pay idempotency-retries

Quick reference#

Functional#

  • Browse items, select.
  • Accept cash or card / UPI.
  • Make change.
  • Dispense item.
  • Refund on cancel.

Non-functional#

  • Reliable state transitions; never charge without dispense.
  • Refill / maintenance mode.

Patterns#

  • State, Strategy (payment), Singleton (machine), Observer (low stock).

Trade-offs#

  • Multiple payment methods complicate state; isolate per PaymentMethod.
  • No change available → reject coin or reduce options.

Refs#

  • Common LLD references (Grokking OOD).

FAQ#

Why use the State pattern in a vending machine?#

Behaviour of select, pay, and dispense changes per state (Idle, Selecting, Dispensing, Refunding). The State pattern moves transition logic into state classes instead of nested ifs.

How is change calculation handled?#

Greedy algorithm over coin denominations, biggest first, while tracking each denomination's stock. If exact change is impossible, the machine refuses the sale and returns the inserted coins.

How is inventory tracked?#

Each Slot has a product and a count. Selection checks count greater than zero; on dispense the count decrements atomically. Restocking is an admin-mode operation gated by auth.

What states does the machine cycle through?#

Idle when waiting for input, Selecting after a product is chosen, AwaitingPayment, Dispensing during vend, Refunding on cancel, and Maintenance for restock or repair.

How do you support multiple payment methods?#

An abstract PaymentMethod with CoinPayment, CardPayment, and NfcPayment subclasses. The state machine calls method.charge(amount) and listens for an approval event before dispensing.

Video walkthrough

Design a Vending Machine: LLD with State Design Pattern (Java) : via Concept && Coding