Design a Vending Machine

Mediumobject-oriented design · design patterns · state pattern · inventory · java⏱ 13 min readAsked at Amazon, Microsoft, Google, Adobe, Goldman SachsUpdated

Design a coin-operated vending machine with the State pattern, slot-based inventory, an escrow for inserted coins, and a change maker that never sells an item it cannot make change for.

Requirements

The vending machine is the textbook State-pattern question. The interviewer wants to see a clean state machine, correct handling of money (refunds, change, no lost coins) and a model of inventory that a real operator could maintain.

Functional

  • Products sit in slots identified by a code (A1, B2, ...). Each slot holds one product type and a quantity.
  • The customer inserts coins (nickel, dime, quarter, dollar coin) and the machine shows the balance.
  • The customer selects a slot. The machine dispenses the product and returns change if:
    • the slot exists and is not empty,
    • the balance covers the price, and
    • the machine can make exact change from the coins it holds.
  • The customer can cancel at any time before a sale and gets back exactly the coins they inserted.
  • An operator can take the machine out of service to restock products and refill coins.

Non-functional

  • Money must never be lost or created: every coin is in the escrow, the cash box or returned to the customer.
  • Adding a new state (e.g. "card payment pending") should not require editing every other state.
  • One customer at a time, but the operator console and the customer panel may call in concurrently.

Out of scope (confirm first)

  • Card or mobile payment, bill acceptors, telemetry to a central server, temperature control.
Tip

Clarify the "cannot make change" case up front. Many candidates dispense the product and then discover there is no change, which means either the customer or the operator loses money.

Core Entities

Entity Responsibility Key fields
Coin (enum) Accepted denominations and their value cents
Product (record) Name and price name, priceCents
Slot One shelf position holding a product and a count code, product, quantity
Inventory Looks up slots, restocks, checks if everything is sold out Map<String, Slot>
CoinBox Coins the machine owns; plans and pays change EnumMap<Coin, Integer>
VendingState Behaviour of the machine in one mode interface
ReadyState, HasMoneyState, OutOfServiceState Concrete states -
Sale (record) Result of a successful purchase product, change
VendingMachine Context object: holds current state, escrow and balance; delegates every customer action to the state state, escrow, inventory, coinBox

The escrow is the most important modelling decision. Real machines hold inserted coins in a separate tray until the sale completes. That makes cancel trivial (return the escrow as-is) and keeps the cash box consistent: coins move into it only when a sale commits.

Class Diagram

classDiagram
  class Coin {
    <<enumeration>>
    NICKEL
    DIME
    QUARTER
    DOLLAR
    +int cents
  }
  class Product {
    +String name
    +int priceCents
  }
  class Slot {
    -String code
    -Product product
    -int quantity
    +isEmpty() boolean
    +takeOne() void
    +restock(int) void
  }
  class Inventory {
    -Map slots
    +find(String) Optional
    +allSoldOut() boolean
  }
  class CoinBox {
    -EnumMap counts
    +planChange(int, List) Optional
    +deposit(List) void
    +withdraw(List) void
  }
  class VendingState {
    <<interface>>
    +insertCoin(VendingMachine, Coin) boolean
    +select(VendingMachine, String) Optional
    +cancel(VendingMachine) List
  }
  class ReadyState
  class HasMoneyState
  class OutOfServiceState
  class VendingMachine {
    -VendingState state
    -List escrow
    -Inventory inventory
    -CoinBox coinBox
    +insertCoin(Coin) boolean
    +select(String) Optional
    +cancel() List
    +enterService() void
    +exitService() void
  }
  class Sale {
    +Product product
    +List change
  }

  VendingState <|.. ReadyState
  VendingState <|.. HasMoneyState
  VendingState <|.. OutOfServiceState
  VendingMachine --> VendingState
  VendingMachine *-- Inventory
  VendingMachine *-- CoinBox
  Inventory "1" *-- "many" Slot
  Slot --> Product
  VendingMachine ..> Sale : returns
  CoinBox --> Coin

The state machine itself:

flowchart LR
  Ready -- "insert coin" --> HasMoney
  HasMoney -- "insert coin" --> HasMoney
  HasMoney -- "select: short of money, sold out, no change" --> HasMoney
  HasMoney -- "select: sale ok" --> Ready
  HasMoney -- "cancel: refund escrow" --> Ready
  Ready -- "operator: service" --> OutOfService
  OutOfService -- "operator: resume" --> Ready
  HasMoney -- "sale empties machine" --> OutOfService

Design Patterns Used

Pattern Where Why it is justified
State VendingState + three states Every customer action means something different per mode: inserting a coin in OutOfService is rejected, in Ready it starts a session. Each state class answers all actions for one mode, so there are no if (state == ...) chains
Facade / context VendingMachine The keypad, coin slot and operator console talk to one object; the current state is an internal detail
Value objects Coin, Product, Sale Immutable, comparable and safe to hand back to callers
Two-phase commit (in miniature) planChange then withdraw Check that every part of the sale can succeed before changing any state

State vs. a big switch. A switch on an enum is acceptable for three states. Argue for the pattern by pointing at growth: card payment, age verification for some products, a "dispensing" state that waits for a drop sensor. Each becomes one new class implementing the same interface, and existing states change only where a transition into the new state is added.

Insight

Greedy change (always take the largest coin) fails when the machine is low on coins. Owing 30 cents with quarters and three dimes but no nickels, greedy takes a quarter and gets stuck at 5 cents, yet three dimes would work. The change maker below tries the largest coin first and backtracks, which is cheap because there are only four denominations.

Java Implementation

A single Main.java (Java 17+). All money is in integer cents.

java
import java.util.*;

enum Coin {
    NICKEL(5), DIME(10), QUARTER(25), DOLLAR(100);

    final int cents;
    Coin(int cents) { this.cents = cents; }
}

record Product(String name, int priceCents) {}

record Sale(Product product, List<Coin> change) {}

class Slot {
    private final String code;
    private final Product product;
    private int quantity;

    Slot(String code, Product product, int quantity) {
        this.code = code;
        this.product = product;
        this.quantity = quantity;
    }

    String getCode() { return code; }
    Product getProduct() { return product; }
    int getQuantity() { return quantity; }
    boolean isEmpty() { return quantity == 0; }

    void takeOne() {
        if (quantity == 0) throw new IllegalStateException(code + " is empty");
        quantity--;
    }

    void restock(int count) { quantity += count; }

    @Override public String toString() { return code + ":" + product.name() + "x" + quantity; }
}

class Inventory {
    private final Map<String, Slot> slots = new LinkedHashMap<>();

    void addSlot(String code, Product product, int quantity) { slots.put(code, new Slot(code, product, quantity)); }
    Optional<Slot> find(String code) { return Optional.ofNullable(slots.get(code)); }
    boolean allSoldOut() { return slots.values().stream().allMatch(Slot::isEmpty); }

    void restock(String code, int count) {
        find(code).orElseThrow(() -> new IllegalArgumentException("No slot " + code)).restock(count);
    }

    @Override public String toString() { return slots.values().toString(); }
}

class CoinBox {
    private static final Coin[] LARGEST_FIRST = { Coin.DOLLAR, Coin.QUARTER, Coin.DIME, Coin.NICKEL };
    private final EnumMap<Coin, Integer> counts = new EnumMap<>(Coin.class);

    CoinBox() { for (Coin c : Coin.values()) counts.put(c, 0); }

    void deposit(List<Coin> coins) { coins.forEach(c -> counts.merge(c, 1, Integer::sum)); }

    void withdraw(List<Coin> coins) {
        for (Coin c : coins) {
            if (counts.get(c) == 0) throw new IllegalStateException("No " + c + " left");
            counts.merge(c, -1, Integer::sum);
        }
    }

    // Plans change using coins in the box plus the customer's pending coins; mutates nothing.
    Optional<List<Coin>> planChange(int amount, List<Coin> pending) {
        EnumMap<Coin, Integer> available = new EnumMap<>(counts);
        pending.forEach(c -> available.merge(c, 1, Integer::sum));
        List<Coin> out = new ArrayList<>();
        return search(0, amount, available, out) ? Optional.of(out) : Optional.empty();
    }

    private boolean search(int i, int remaining, Map<Coin, Integer> available, List<Coin> out) {
        if (remaining == 0) return true;
        if (i == LARGEST_FIRST.length) return false;
        Coin coin = LARGEST_FIRST[i];
        int max = Math.min(remaining / coin.cents, available.get(coin));
        for (int k = max; k >= 0; k--) {                 // most of this coin first, then back off
            for (int j = 0; j < k; j++) out.add(coin);
            if (search(i + 1, remaining - k * coin.cents, available, out)) return true;
            for (int j = 0; j < k; j++) out.remove(out.size() - 1);
        }
        return false;
    }

    @Override public String toString() { return counts.toString(); }
}

interface VendingState {
    String label();
    boolean insertCoin(VendingMachine vm, Coin coin);
    Optional<Sale> select(VendingMachine vm, String code);
    List<Coin> cancel(VendingMachine vm);
}

class ReadyState implements VendingState {
    public String label() { return "READY"; }

    public boolean insertCoin(VendingMachine vm, Coin coin) {
        vm.addToEscrow(coin);
        vm.setState(new HasMoneyState());
        return true;
    }

    public Optional<Sale> select(VendingMachine vm, String code) {
        vm.display("Insert coins first");
        return Optional.empty();
    }

    public List<Coin> cancel(VendingMachine vm) { return List.of(); }
}

class HasMoneyState implements VendingState {
    public String label() { return "HAS_MONEY"; }

    public boolean insertCoin(VendingMachine vm, Coin coin) {
        vm.addToEscrow(coin);
        return true;
    }

    public Optional<Sale> select(VendingMachine vm, String code) {
        Optional<Slot> found = vm.getInventory().find(code);
        if (found.isEmpty()) { vm.display("Unknown code " + code); return Optional.empty(); }
        Slot slot = found.get();
        if (slot.isEmpty()) { vm.display(code + " is sold out, choose another item"); return Optional.empty(); }
        int price = slot.getProduct().priceCents();
        if (vm.balance() < price) {
            vm.display("Insert " + (price - vm.balance()) + "c more for " + slot.getProduct().name());
            return Optional.empty();
        }
        Optional<List<Coin>> change = vm.getCoinBox().planChange(vm.balance() - price, vm.getEscrow());
        if (change.isEmpty()) {
            vm.display("Cannot make change for " + code + ", choose another item or cancel");
            return Optional.empty();
        }
        // Every check passed: commit escrow, pay change, dispense.
        vm.getCoinBox().deposit(vm.getEscrow());
        vm.getCoinBox().withdraw(change.get());
        slot.takeOne();
        vm.clearEscrow();
        vm.setState(vm.getInventory().allSoldOut() ? new OutOfServiceState() : new ReadyState());
        return Optional.of(new Sale(slot.getProduct(), change.get()));
    }

    public List<Coin> cancel(VendingMachine vm) {
        List<Coin> refund = new ArrayList<>(vm.getEscrow());
        vm.clearEscrow();
        vm.setState(new ReadyState());
        return refund;
    }
}

class OutOfServiceState implements VendingState {
    public String label() { return "OUT_OF_SERVICE"; }

    public boolean insertCoin(VendingMachine vm, Coin coin) {
        vm.display("Out of service, coin returned");
        return false;
    }

    public Optional<Sale> select(VendingMachine vm, String code) {
        vm.display("Out of service");
        return Optional.empty();
    }

    public List<Coin> cancel(VendingMachine vm) { return List.of(); }
}

class VendingMachine {
    private final Inventory inventory = new Inventory();
    private final CoinBox coinBox = new CoinBox();
    private final List<Coin> escrow = new ArrayList<>();
    private VendingState state = new ReadyState();

    // Customer panel
    synchronized boolean insertCoin(Coin coin) { return state.insertCoin(this, coin); }
    synchronized Optional<Sale> select(String code) { return state.select(this, code); }
    synchronized List<Coin> cancel() { return state.cancel(this); }

    // Operator console: refund any customer in progress before servicing
    synchronized List<Coin> enterService() {
        List<Coin> refund = state.cancel(this);
        state = new OutOfServiceState();
        return refund;
    }

    synchronized void exitService() {
        if (!(state instanceof OutOfServiceState)) throw new IllegalStateException("Not in service mode");
        state = inventory.allSoldOut() ? new OutOfServiceState() : new ReadyState();
    }

    synchronized void restock(String code, int count) { requireService(); inventory.restock(code, count); }
    synchronized void loadCoins(Coin coin, int count) { requireService(); coinBox.deposit(Collections.nCopies(count, coin)); }

    private void requireService() {
        if (!(state instanceof OutOfServiceState)) throw new IllegalStateException("Enter service mode first");
    }

    // Helpers used by states (package-private, not part of the customer API)
    Inventory getInventory() { return inventory; }
    CoinBox getCoinBox() { return coinBox; }
    List<Coin> getEscrow() { return Collections.unmodifiableList(escrow); }
    void addToEscrow(Coin coin) { escrow.add(coin); display("Balance " + balance() + "c"); }
    void clearEscrow() { escrow.clear(); }
    int balance() { return escrow.stream().mapToInt(c -> c.cents).sum(); }
    void setState(VendingState next) { state = next; }
    void display(String message) { System.out.println("  [screen] " + message); }
    synchronized String status() { return state.label() + " " + inventory + " coins=" + coinBox; }
}

public class Main {
    static void buy(VendingMachine vm, String code) {
        vm.select(code).ifPresent(s -> System.out.println("  DISPENSED " + s.product().name() + ", change " + s.change()));
    }

    public static void main(String[] args) {
        VendingMachine vm = new VendingMachine();
        vm.enterService();
        vm.getInventory().addSlot("A1", new Product("Chips", 65), 2);
        vm.getInventory().addSlot("A2", new Product("Soda", 125), 1);
        vm.getInventory().addSlot("B1", new Product("Gum", 70), 0);
        vm.loadCoins(Coin.DIME, 1);
        vm.exitService();
        System.out.println("Start: " + vm.status());

        System.out.println("1) Select before paying");
        buy(vm, "A1");

        System.out.println("2) Three quarters for chips (65c)");
        for (int i = 0; i < 3; i++) vm.insertCoin(Coin.QUARTER);
        buy(vm, "A1");

        System.out.println("3) Dollar for chips, but no coins for 35c change");
        vm.insertCoin(Coin.DOLLAR);
        buy(vm, "A1");
        System.out.println("  refunded " + vm.cancel());

        System.out.println("4) Soda (125c) paid in two steps");
        vm.insertCoin(Coin.DOLLAR);
        buy(vm, "A2");
        vm.insertCoin(Coin.QUARTER);
        buy(vm, "A2");

        System.out.println("5) Operator restocks gum and loads dimes");
        vm.enterService();
        vm.insertCoin(Coin.QUARTER);
        vm.restock("B1", 2);
        vm.loadCoins(Coin.DIME, 3);
        vm.exitService();
        System.out.println("  " + vm.status());

        System.out.println("6) Dollar for gum (70c): 30c change with no nickels");
        vm.insertCoin(Coin.DOLLAR);
        buy(vm, "A2");
        buy(vm, "B1");
        System.out.println("End: " + vm.status());
    }
}

Demo output

text
Start: READY [A1:Chipsx2, A2:Sodax1, B1:Gumx0] coins={NICKEL=0, DIME=1, QUARTER=0, DOLLAR=0}
1) Select before paying
  [screen] Insert coins first
2) Three quarters for chips (65c)
  [screen] Balance 25c
  [screen] Balance 50c
  [screen] Balance 75c
  DISPENSED Chips, change [DIME]
3) Dollar for chips, but no coins for 35c change
  [screen] Balance 100c
  [screen] Cannot make change for A1, choose another item or cancel
  refunded [DOLLAR]
4) Soda (125c) paid in two steps
  [screen] Balance 100c
  [screen] Insert 25c more for Soda
  [screen] Balance 125c
  DISPENSED Soda, change []
5) Operator restocks gum and loads dimes
  [screen] Out of service, coin returned
  READY [A1:Chipsx1, A2:Sodax0, B1:Gumx2] coins={NICKEL=0, DIME=3, QUARTER=4, DOLLAR=1}
6) Dollar for gum (70c): 30c change with no nickels
  [screen] Balance 100c
  [screen] A2 is sold out, choose another item
  DISPENSED Gum, change [DIME, DIME, DIME]
End: READY [A1:Chipsx1, A2:Sodax0, B1:Gumx1] coins={NICKEL=0, DIME=0, QUARTER=4, DOLLAR=2}

Walkthrough:

  • Step 2 uses the only dime in the machine as change, which sets up the next scenario.
  • Step 3 is the case candidates usually miss: 35 cents of change needs a dime or nickels, and there are none. The sale is refused before anything moves, and cancel returns the very dollar coin that was inserted, straight from the escrow.
  • Step 4 shows the machine staying in HasMoney while the balance is short, then completing once the extra quarter arrives.
  • Step 5: a coin inserted during service mode is rejected by OutOfServiceState.
  • Step 6: the backtracking change maker returns three dimes. A greedy one would have taken a quarter, been left owing 5 cents, and refused the sale.
  • The final coin box adds up: every coin inserted for a completed sale is in the box, and the only coins that left were change.

Concurrency Considerations

A vending machine serves one customer at a time, but there are still several independent inputs: the coin acceptor, the keypad, the cancel button and the operator console (or a remote management API). If two of them act at the same moment, a naive design can double-dispense or refund coins that were already moved to the cash box.

  • One lock for the whole machine. Every public method on VendingMachine is synchronized, so each action sees a consistent state, escrow and inventory. Contention is not a concern for a single physical machine, and a coarse lock is far easier to reason about than per-slot locks.
  • States are stateless objects. All mutable data lives in the context, so the states need no locking of their own.
  • Check, then commit. HasMoneyState.select performs every check (slot, stock, balance, change plan) before mutating anything. If the change plan fails, nothing has moved, so the customer can pick something else or cancel.
  • Service mode refunds first. enterService calls cancel on the current state, so a customer who walked away mid-session gets their coins back rather than having them silently absorbed.
Warning

Hardware can fail after the software commits (a spiral jams and the product does not drop). Production machines add a DispensingState that waits for a drop sensor; on timeout they roll back the sale by returning the escrowed amount and marking the slot faulty.

Extensions

Requirement Change
Card / mobile payments A PaymentMethod interface (CoinPayment, CardPayment); a CardAuthorizingState that waits for the processor before vending
Dispense confirmation DispensingState that completes on a sensor event or rolls back on timeout
Low-stock and low-change alerts Observer: Inventory and CoinBox publish events; a telemetry service subscribes
Dynamic pricing / promotions PricingStrategy (happy hour, combo discount) consulted in select
"Exact change only" light CoinBox exposes canMakeChangeUpTo(maxChange); the display shows the warning when false
Bill acceptor Add Bill values; cap the escrow so the machine never accepts more than it can change
Multiple products per slot / spirals Slot holds a queue of items with expiry dates; the oldest is dispensed first

Interview Tips

  • Draw the state diagram first; then each state class is just one row of that diagram.
  • Name the escrow explicitly. It explains how cancel refunds the exact coins and why the cash box is never wrong.
  • Mention that greedy change can fail with limited coins, and show the backtracking fix or a small DP.
  • Keep money in integer cents and say why (no floating-point rounding).
  • Discuss what happens when the hardware fails after the software commits; it shows you think about real devices.
  • Keep the operator flow (service mode, restock, refill coins) separate from the customer flow.

References

My notes saved in this browser