Design a Vending Machine: State Machines in Practice

Here’s what makes the vending machine problem interesting to reason through: it looks like a simple if/else problem until you try to add one new state.

Most engineers, when they first sketch this, write something like: if the machine has no money inserted, reject the selection. If it has money and the item is in stock, dispense it. If it has money and the item is out of stock, refund. Straightforward. Then the interviewer says “add a maintenance mode where a technician can restock items.” And you realize your dispense() method now needs to check three different conditions before deciding what to do. Then they say “add a refunding state where the machine is mid-refund and won’t accept new money.” Now insert_money() also has branching logic. Every method starts checking every possible mode.

This is the problem the State pattern was built to solve. Instead of methods that ask “what state am I in,” each state object defines the behavior for that state directly. Adding a new state means adding a new class, not auditing every existing method for a new branch.

Let me walk through how I’d design this from scratch.

Requirements

Functional:

  • Accept coin and note insertions, track the total amount inserted
  • Allow item selection by code
  • Dispense item and return change if the inserted amount covers the item price
  • Refund the inserted amount if the user cancels before selecting
  • Reject interactions that don’t make sense in the current state (inserting money while dispensing, selecting before inserting money)

Non-functional:

  • Adding a new state (maintenance mode, refunding state) should require adding a class, not modifying existing ones
  • Inventory and pricing should be modifiable without touching state logic

Core Entities

VendingMachine is the context in the State pattern. It holds a reference to the current state and delegates all behavior to it. The machine also owns the inventory and the current amount inserted. It never asks “what state am I in?” directly.

VendingMachineState is the abstract base class. Every concrete state implements the same interface: insert_money, select_item, dispense, cancel. Most states will reject most of these with a message, and that’s intentional. It makes invalid transitions explicit and self-documenting.

Inventory tracks item counts and prices by item code. It is a pure data object with query methods. State objects call into it, but it has no awareness of states.

Item is a simple value object: a name, a price, a count.

The four states are: IdleState (waiting for money), HasMoneyState (money inserted, waiting for selection), DispensingState (dispensing in progress), OutOfServiceState (machine is empty or broken).

Class Design

+------------------------+          +---------------------------+
|    VendingMachine      |--------->|   VendingMachineState     |
|------------------------|          |---------------------------|
| - state                |          | + insert_money(amount)    |
| - inventory: Inventory |          | + select_item(code)       |
| - amount_inserted: int |          | + dispense()              |
| - current_item: Item   |          | + cancel()                |
|------------------------|          +---------------------------+
| + insert_money(amount) |               |        |        |
| + select_item(code)    |           IdleState  HasMoney  Dispensing
| + dispense()           |                             OutOfService
| + cancel()             |
+------------------------+

+------------------------+
|      Inventory         |
|------------------------|
| - items: dict[str,Item]|
|------------------------|
| + get(code) -> Item    |
| + has_stock(code)->bool|
| + decrement(code)      |
| + is_empty() -> bool   |
+------------------------+

The key relationship: VendingMachine holds the current state and passes itself (machine) into every state method call. This lets state objects transition the machine to a new state by calling machine.set_state(...). States don’t talk to each other directly, they transition through the machine.

Key Implementation

from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional


@dataclass
class Item:
    name: str
    price: int  # in cents, avoids float rounding issues
    count: int


class Inventory:
    def __init__(self, items: dict[str, Item]) -> None:
        self._items = items

    def get(self, code: str) -> Optional[Item]:
        return self._items.get(code)

    def has_stock(self, code: str) -> bool:
        item = self._items.get(code)
        return item is not None and item.count > 0

    def decrement(self, code: str) -> None:
        if not self.has_stock(code):
            raise ValueError(f"No stock for {code}")
        self._items[code].count -= 1

    def is_empty(self) -> bool:
        return all(item.count == 0 for item in self._items.values())


class VendingMachineState(ABC):
    @abstractmethod
    def insert_money(self, machine: VendingMachine, amount: int) -> None:
        pass

    @abstractmethod
    def select_item(self, machine: VendingMachine, code: str) -> None:
        pass

    @abstractmethod
    def dispense(self, machine: VendingMachine) -> None:
        pass

    @abstractmethod
    def cancel(self, machine: VendingMachine) -> None:
        pass


class IdleState(VendingMachineState):
    """Waiting for the user to insert money. No item selected yet."""

    def insert_money(self, machine: VendingMachine, amount: int) -> None:
        machine.amount_inserted += amount
        print(f"Inserted {amount}c. Total: {machine.amount_inserted}c")
        machine.set_state(HasMoneyState())

    def select_item(self, machine: VendingMachine, code: str) -> None:
        print("Please insert money first.")

    def dispense(self, machine: VendingMachine) -> None:
        print("Please insert money and select an item first.")

    def cancel(self, machine: VendingMachine) -> None:
        print("Nothing to cancel.")


class HasMoneyState(VendingMachineState):
    """Money has been inserted. Waiting for item selection."""

    def insert_money(self, machine: VendingMachine, amount: int) -> None:
        # Accepting additional money before selection is fine
        machine.amount_inserted += amount
        print(f"Added {amount}c. Total: {machine.amount_inserted}c")

    def select_item(self, machine: VendingMachine, code: str) -> None:
        item = machine.inventory.get(code)
        if item is None:
            print(f"No item with code '{code}'.")
            return
        if not machine.inventory.has_stock(code):
            print(f"'{item.name}' is out of stock.")
            return
        if machine.amount_inserted < item.price:
            print(f"Insufficient funds. Need {item.price}c, have {machine.amount_inserted}c.")
            return
        machine.current_item = item
        machine.set_state(DispensingState())
        machine.dispense()

    def dispense(self, machine: VendingMachine) -> None:
        print("Please select an item first.")

    def cancel(self, machine: VendingMachine) -> None:
        refund = machine.amount_inserted
        machine.amount_inserted = 0
        machine.set_state(IdleState())
        print(f"Cancelled. Refunding {refund}c.")


class DispensingState(VendingMachineState):
    """Dispensing is in progress. Block all interactions until complete."""

    def insert_money(self, machine: VendingMachine, amount: int) -> None:
        print("Please wait, dispensing in progress.")

    def select_item(self, machine: VendingMachine, code: str) -> None:
        print("Please wait, dispensing in progress.")

    def dispense(self, machine: VendingMachine) -> None:
        item = machine.current_item
        machine.inventory.decrement(item.name)
        change = machine.amount_inserted - item.price
        machine.amount_inserted = 0
        machine.current_item = None
        print(f"Dispensing '{item.name}'.")
        if change > 0:
            print(f"Returning {change}c change.")
        # Check if inventory is now exhausted
        if machine.inventory.is_empty():
            machine.set_state(OutOfServiceState())
            print("Machine is now out of service (no items remaining).")
        else:
            machine.set_state(IdleState())

    def cancel(self, machine: VendingMachine) -> None:
        print("Cannot cancel while dispensing.")


class OutOfServiceState(VendingMachineState):
    """Machine has no stock. All interactions are blocked."""

    def insert_money(self, machine: VendingMachine, amount: int) -> None:
        print("Machine is out of service. Money not accepted.")

    def select_item(self, machine: VendingMachine, code: str) -> None:
        print("Machine is out of service.")

    def dispense(self, machine: VendingMachine) -> None:
        print("Machine is out of service.")

    def cancel(self, machine: VendingMachine) -> None:
        print("Machine is out of service.")


class VendingMachine:
    def __init__(self, inventory: Inventory) -> None:
        self.inventory = inventory
        self.amount_inserted: int = 0
        self.current_item: Optional[Item] = None
        # Start out of service if there's no stock at initialization
        self._state: VendingMachineState = (
            OutOfServiceState() if inventory.is_empty() else IdleState()
        )

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

    def insert_money(self, amount: int) -> None:
        self._state.insert_money(self, amount)

    def select_item(self, code: str) -> None:
        self._state.select_item(self, code)

    def dispense(self) -> None:
        self._state.dispense(self)

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

Why State Pattern Over if/else Chains

The clearest way to see this is to trace what “add a RefundingState” looks like under both approaches.

With if/else, every method that touches state needs a new branch. insert_money gets if self.state == REFUNDING: print("Refund in progress"). So does select_item. So does cancel. That’s four methods to audit, each growing independently. The business logic for the refunding behavior is scattered across four different places.

With the State pattern, adding RefundingState means writing one new class with four methods that each do the right thing for that state. You don’t touch any of the existing state classes. The machine transitions into RefundingState from wherever makes sense, and out of it when the refund completes. The behavior for refunding lives in exactly one place.

The pattern makes the state transition graph visible. Reading the code, you can see exactly which states transition to which. With if/else, the transitions are implicit in the conditions scattered across multiple methods, and you need to mentally reconstruct the graph to understand the system.

Design Decisions and Trade-offs

Passing machine into every state method. The alternative is for each state to hold a reference to the machine at construction time. I prefer the method-parameter approach because states become truly stateless objects. You could use IdleState as a singleton, which reduces allocations on every state transition. The machine owns all the mutable data (amount inserted, current item), so the state objects themselves are pure behavior containers.

inventory.decrement takes the item name, not the code. This is a bug I’d actually write in a first draft and then catch. The inventory is indexed by code, not name. The dispense method should call machine.inventory.decrement(code), which means DispensingState.dispense needs access to the code. The cleanest fix is to store the code on machine.current_item_code alongside current_item. I left the simpler version above to illustrate the kind of detail that matters in a real implementation.

Change calculation in integer cents. Floating-point arithmetic on prices is a well-known trap. 0.1 + 0.2 != 0.3 in IEEE 754. Tracking amounts in integer cents sidesteps this entirely.

OutOfServiceState vs. raising. An alternative is to just raise an exception when the machine runs out of stock. But in a real system, “out of service” is an observable state that the machine should report to a monitoring system, not a programming error. Modeling it as a proper state makes it easy to add behavior later, like alerting an operator or displaying a message on screen.


If this sparked a question or you see a design decision you’d make differently, I’d genuinely like to hear it. Reach out on Twitter or LinkedIn.

Tags:

#lld #machine-coding #object-oriented-design #python #state-machine

Related Posts

Let's Connect! 💬

Whether you're looking to hire, want to collaborate on a project, or just want to chat about tech—I'd love to hear from you!