Back to Notes

Design a Vending Machine

1. Problem & Why It's Asked

The canonical State pattern problem. The naive solution — a pile of booleans (has_money, item_selected, is_dispensing) with if chains checking combinations — allows invalid states to exist in principle (what does is_dispensing=True, has_money=False even mean?). The correct solution makes invalid combinations structurally impossible by giving each state its own class. This is the single most common "why does this pattern exist" teaching example in OOD.

2. Requirements

Functional: accept coins/cash, let a user select an item, dispense the item and correct change, handle insufficient funds and out-of-stock items, allow cancellation with a refund.

Non-functional: invalid operations (e.g., selecting an item before inserting money) must be rejected cleanly, not silently misbehave; extensible to new states (e.g., a maintenance/out-of-service state) without touching unrelated states' logic.

3. State Diagram

stateDiagram-v2
    [*] --> Idle
    Idle --> HasMoney: insert_coin
    HasMoney --> HasMoney: insert_coin
    HasMoney --> Dispensing: select_item (sufficient funds, in stock)
    HasMoney --> Idle: cancel (refund)
    HasMoney --> HasMoney: select_item (insufficient funds - rejected, stays)
    Dispensing --> Idle: dispense complete (+ change)

4. Class Diagram

classDiagram
    class VendingMachineState {
        <<interface>>
        +insert_coin(machine, amount)
        +select_item(machine, item_id)
        +cancel(machine)
    }
    class IdleState {
        +insert_coin(machine, amount)
        +select_item(machine, item_id)
        +cancel(machine)
    }
    class HasMoneyState {
        +insert_coin(machine, amount)
        +select_item(machine, item_id)
        +cancel(machine)
    }
    class DispensingState {
        +insert_coin(machine, amount)
        +select_item(machine, item_id)
        +cancel(machine)
    }
    class Item {
        +str id
        +float price
        +int stock
    }
    class VendingMachine {
        -VendingMachineState state
        -float balance
        -dict~str,Item~ inventory
        +set_state(state)
        +insert_coin(amount)
        +select_item(item_id)
        +cancel()
    }
    VendingMachineState <|.. IdleState
    VendingMachineState <|.. HasMoneyState
    VendingMachineState <|.. DispensingState
    VendingMachine --> VendingMachineState
    VendingMachine "1" --> "*" Item

5. Implementation

from abc import ABC, abstractmethod
from dataclasses import dataclass


@dataclass
class Item:
    id: str
    price: float
    stock: int


class VendingMachineState(ABC):
    @abstractmethod
    def insert_coin(self, machine: "VendingMachine", amount: float) -> None: ...
    @abstractmethod
    def select_item(self, machine: "VendingMachine", item_id: str) -> None: ...
    @abstractmethod
    def cancel(self, machine: "VendingMachine") -> None: ...


class IdleState(VendingMachineState):
    def insert_coin(self, machine, amount):
        machine.balance += amount
        machine.set_state(machine.has_money_state)

    def select_item(self, machine, item_id):
        print("Insert money first")

    def cancel(self, machine):
        print("Nothing to cancel")


class HasMoneyState(VendingMachineState):
    def insert_coin(self, machine, amount):
        machine.balance += amount   # stay in this state, just add funds

    def select_item(self, machine, item_id):
        item = machine.inventory.get(item_id)
        if item is None or item.stock == 0:
            print("Item unavailable")
            return
        if machine.balance < item.price:
            print(f"Insufficient funds — need {item.price - machine.balance:.2f} more")
            return                                    # deliberately stays in HasMoneyState
        machine.set_state(machine.dispensing_state)
        machine.dispense(item)

    def cancel(self, machine):
        machine.refund(machine.balance)
        machine.balance = 0
        machine.set_state(machine.idle_state)


class DispensingState(VendingMachineState):
    def insert_coin(self, machine, amount):
        print("Please wait, dispensing in progress")

    def select_item(self, machine, item_id):
        print("Please wait, dispensing in progress")

    def cancel(self, machine):
        print("Cannot cancel mid-dispense")


class VendingMachine:
    def __init__(self, inventory: dict[str, Item]):
        self.idle_state = IdleState()
        self.has_money_state = HasMoneyState()
        self.dispensing_state = DispensingState()
        self.state: VendingMachineState = self.idle_state
        self.balance = 0.0
        self.inventory = inventory

    def set_state(self, state: VendingMachineState) -> None:
        self.state = state

    def insert_coin(self, amount: float) -> None:
        self.state.insert_coin(self, amount)

    def select_item(self, item_id: str) -> None:
        self.state.select_item(self, item_id)

    def cancel(self) -> None:
        self.state.cancel(self)

    def dispense(self, item: Item) -> None:
        item.stock -= 1
        change = self.balance - item.price
        self.balance = 0
        print(f"Dispensing {item.id}, change: {change:.2f}")
        self.set_state(self.idle_state)

    def refund(self, amount: float) -> None:
        print(f"Refunding {amount:.2f}")

6. Why This Beats the Boolean-Flags Version

The rejected alternative:

# What NOT to do — boolean soup
class VendingMachine:
    def __init__(self):
        self.has_money = False
        self.item_selected = False
        self.is_dispensing = False

    def select_item(self, item_id):
        if not self.has_money:
            print("insert money")
        elif self.is_dispensing:
            print("busy")
        # ... every method needs to re-check every flag combination

Every method has to defensively re-check every other flag, and nothing stops has_money=False, is_dispensing=True from existing as a reachable (buggy) combination if a code path forgets to reset a flag. The State pattern's VendingMachine.state field can only ever point at exactly one state object at a time — invalid combinations aren't just avoided by convention, they're unrepresentable.

7. Concurrency

  • A single VendingMachine instance modeling one physical machine is inherently single-user at a time (only one person interacts with a physical machine simultaneously) — but if this models a fleet of machines behind one software service (e.g., a mobile-app-controlled vending network), each physical machine's state transitions should be locked independently (per-machine lock), not globally — the same per-entity-lock reasoning as [[Parking Lot]] and [[Rate Limiter (OOD)]].
  • dispense() mutating item.stock concurrently with another request checking item.stock == 0 in select_item has the same check-then-act race as the Parking Lot's spot allocation — needs the same lock-around-check-and-claim treatment for a shared inventory accessed concurrently.

8. Edge Cases

  • Exact change unavailable: dispense() above assumes the machine can always make change; a real machine needs a coin inventory and should refuse the dispense (refund instead) if it can't return correct change — this is a legitimate follow-up ("what if the machine only has quarters left and owes $0.15?").
  • Insert coins mid-transaction, then cancel: HasMoneyState.cancel refunds the full accumulated balance, not just the last coin — verified by the implementation above tracking balance as a running total, not per-coin.
  • Item goes out of stock while a user has already inserted money but before selecting: select_item's stock check happens at selection time, not at coin-insertion time — correct, since stock could change between insertion and selection (another concurrent buyer, or a restock).

9. Extensibility

  • Maintenance/out-of-service state: add a MaintenanceState implementing the same VendingMachineState interface, where every method prints "out of service" — zero changes to IdleState/HasMoneyState/DispensingState.
  • Card payment instead of coins: insert_coin becomes one of several add_funds entry points (insert_coin, tap_card), both ultimately calling the same balance-mutation logic in HasMoneyState — the state machine's transition logic doesn't change, only how funds enter it.
  • Multi-item basket (select several items before paying): requires a genuinely new state (SelectingState) between Idle and payment — a good test of whether a candidate understands that new requirements sometimes mean new states, not just new methods on existing states.

10. Interview Drills

  1. Why is the State pattern better here than an enum + switch statement in one class? — An enum + switch still centralizes all transition logic in one method that must handle every state×action combination, growing combinatorially as states/actions are added. The State pattern distributes each state's valid actions into its own class — adding a state means adding one new class, not editing every existing switch statement.
  2. A user inserts money, then the machine loses power mid-dispense. What state should it recover into, and why? — This is a durability question, not just an in-memory design one: balance and state need to be persisted (not just held in Python object attributes) so a restart can recover into DispensingState (or a dedicated RecoveringState) and either complete the dispense or refund — silently resetting to IdleState on restart would swallow the user's money.
  3. How would you test that invalid transitions are actually rejected? — Unit test each state's methods for the actions that shouldn't do anything in that state (e.g., DispensingState.insert_coin should not increase balance) and assert the state doesn't change — the State pattern makes these tests simple because each state class can be tested in isolation.

Cross-links

[[OOD Design Patterns]] · [[SOLID Principles]] · [[Parking Lot]] · [[Elevator System]]