Skip to content
System design course
Ch.4 · Designing real systems·how to build it ·8 min read

Building a vending machine

Implement the State pattern in code, plus the greedy change-making algorithm and the exact-change and concurrency edge cases interviewers probe.


The State interface

Every state implements the same four actions; the machine delegates to its current state and never branches on flags:

class State:                                   # interface
    def insert_money(self, machine, amount): ...
    def select(self, machine, code): ...
    def dispense(self, machine): ...
    def cancel(self, machine): ...

class VendingMachine:
    def __init__(self, inventory):
        self.inventory = inventory             # code -> (product, price, qty)
        self.balance = 0
        self.selected = None
        self.state = NoMoneyState()
    # public API just forwards to the current state
    def insert_money(self, amt): self.state.insert_money(self, amt)
    def select(self, code):      self.state.select(self, code)
    def cancel(self):            self.state.cancel(self)

The states

Each state handles actions appropriate to it and rejects the rest, transitioning the machine as needed:

class NoMoneyState(State):
    def insert_money(self, m, amt):
        m.balance += amt
        m.state = HasMoneyState()
    def select(self, m, code):
        print("Insert money first")           # rejected in this state
    def cancel(self, m):
        print("Nothing to cancel")

class HasMoneyState(State):
    def insert_money(self, m, amt):
        m.balance += amt                       # add more
    def select(self, m, code):
        product, price, qty = m.inventory[code]
        if qty == 0:
            m.state = SoldOutState(); print("Sold out")
        elif m.balance < price:
            print(f"Need {price - m.balance} more")
        else:
            m.selected = code
            m.state = DispenseState()
            m.state.dispense(m)                # proceed immediately
    def cancel(self, m):
        refund, m.balance = m.balance, 0
        m.state = NoMoneyState()
        print("Refunded", refund)

class DispenseState(State):
    def dispense(self, m):
        product, price, qty = m.inventory[m.selected]
        change = m.balance - price
        if not m.can_make_change(change):      # exact-change guard
            m.cancel(); print("Can't make change"); return
        m.inventory[m.selected] = (product, price, qty - 1)
        m.give_change(change)
        m.balance, m.selected = 0, None
        m.state = SoldOutState() if qty - 1 == 0 else NoMoneyState()
        print("Dispensed", product)

The shape to notice: no method anywhere branches on “do we have money?” — the state class itself encodes that, so each method is short and the illegal paths are handled locally.

Change-making (greedy, and its caveat)

Returning change is the coin change problem. With standard currency denominations, greedy (take the largest coin ≤ remaining, repeat) is optimal and simple:

def make_change(amount, coins_available):      # coins_available: denom -> count
    result = {}
    for denom in sorted(coins_available, reverse=True):
        take = min(amount // denom, coins_available[denom])
        if take:
            result[denom] = take
            amount -= take * denom
    return result if amount == 0 else None      # None → can't make exact change

The caveat worth stating: greedy is only optimal for canonical coin systems (like USD/EUR). For arbitrary denominations it can fail, and you’d fall back to the DP coin-change algorithm. Returning None drives the exact-change-only state.

Edge cases interviewers probe

  • Insufficient funds — stay in HasMoney, ask for more (handled above).
  • Exact change unavailable — refuse and refund (the can_make_change guard); show an “exact change only” indicator when the coin reserve is low.
  • Sold out — transition to SoldOutState; refund any inserted money.
  • Cancel mid-transaction — always refund the full balance.
  • Concurrency — a single physical machine is effectively single-threaded for vends, but if modeling a networked machine, guard the decrement-inventory-and-dispense as an atomic critical section so two requests can’t dispense the last item.

The takeaway

The graded signal: a clean State pattern (behavior per state, no flag soup), a correct greedy change with the canonical-currency caveat, and explicit handling of exact-change and sold-out. With both warm-ups done, you’ve shown the OOD fundamentals — the rest of the chapter moves to distributed scale, where these modeling instincts still anchor every data model and component.