bigonotes
CS ConceptsLLD

Vending Machine

Design a vending machine as a small state machine with inventory, incremental payment, change, cancel and restock.

4 min readLast updated LLD

The vending machine is the classic warm-up LLD problem. It's small enough to finish in an interview, but it tests whether you can model state cleanly: what the machine allows when it's idle, when money is in, and while it's dispensing.

Requirements#

Start with who uses the machine and what they can do.

Actors

  • Customer: inserts money, selects a product, cancels.
  • Restocker: refills products.

Functional requirements

  1. A customer can see an item's price if it is in stock.
  2. A customer inserts money (possibly in several steps).
  3. A customer selects a product; if the balance covers it, the machine dispenses it and returns change.
  4. A customer can cancel and get their money back.
  5. A restocker can add new products and top up existing ones.

Clarifications

  • Several kinds of items, simple fixed pricing.
  • One item per purchase.
  • Failure cases: out of stock, insufficient balance, unknown item, cancellation.

Out of scope: real card/bank integration, UI, concurrency.

Core entities#

The machine needs only an item type, a state enum and itself:

Entity Holds
Item item_id, count, price, size
State (enum) IDLE, MONEY_INSERTED, DISPENSING
VendingMachine items (id → Item), current state, balance

Relationships#

The machine is the orchestrator:

  • VendingMachine has many Items (aggregation: items are added and restocked from outside).
  • VendingMachine has a current State.
Vending machine states: IDLE goes to MONEY_INSERTED on insert money, stays there on more money, goes to DISPENSING on a valid select, and back to IDLE after dispensing or on cancel

Key flows#

Each public method is allowed only in certain states:

Action Allowed in Effect
insert_money IDLE, MONEY_INSERTED Add to balance → MONEY_INSERTED
select_product MONEY_INSERTED (balance ≥ price, in stock) → DISPENSING, decrement count, compute change → IDLE
cancel IDLE, MONEY_INSERTED Refund balance → IDLE
restock Any Add quantity to an existing item

Code#

With only three states, plain if checks keep the code readable:

from enum import Enum
from typing import Dict, Tuple


class State(Enum):
    IDLE = "IDLE"
    MONEY_INSERTED = "MONEY_INSERTED"
    DISPENSING = "DISPENSING"


class ItemSize(Enum):
    SMALL = "SMALL"
    MEDIUM = "MEDIUM"
    LARGE = "LARGE"


class Item:
    def __init__(self, item_id: str, count: int, price: float,
                 size: ItemSize):
        self.item_id = item_id
        self.count = count
        self.price = price
        self.size = size


class VendingMachine:
    def __init__(self):
        self.items: Dict[str, Item] = {}
        self.state: State = State.IDLE
        self.balance: float = 0.0

    def add_item(self, item: Item) -> None:
        """Introduce a new product.

        Use restock() to top up an existing one.
        """
        if item.item_id in self.items:
            raise ValueError(
                "Item already exists. Use restock to add quantity.")
        self.items[item.item_id] = item

    def insert_money(self, money: float) -> float:
        # Only DISPENSING blocks input; money can accumulate coin by coin.
        if self.state == State.DISPENSING:
            raise ValueError("Cannot insert money now.")
        if money <= 0:
            raise ValueError("Money inserted must be positive.")
        self.balance += money
        self.state = State.MONEY_INSERTED
        return self.balance

    def select_product(self, item_id: str) -> Tuple[Item, float]:
        if self.state == State.DISPENSING:
            raise ValueError("Currently dispensing, try again.")
        if item_id not in self.items:
            raise ValueError("Item not found.")

        item = self.items[item_id]  # trust our own inventory, not the caller
        if item.count <= 0:
            raise ValueError("Item out of stock.")
        if self.balance < item.price:
            raise ValueError("Insufficient balance. Add more money.")

        # Critical section: not thread-safe (see trade-offs)
        self.state = State.DISPENSING
        item.count -= 1
        change = self.balance - item.price
        self.balance = 0.0
        self.state = State.IDLE
        return item, change

    def cancel(self) -> float:
        if self.state == State.DISPENSING:
            raise ValueError("Currently dispensing, try again.")
        refund = self.balance
        self.balance = 0.0
        self.state = State.IDLE
        return refund

    def restock(self, item_id: str, quantity: int) -> bool:
        if item_id not in self.items:
            raise ValueError("Item not found.")
        if quantity <= 0:
            raise ValueError("Restock quantity must be positive.")
        self.items[item_id].count += quantity
        return True


if __name__ == "__main__":
    m = VendingMachine()
    m.add_item(Item("A1", count=1, price=1.50, size=ItemSize.SMALL))

    assert m.insert_money(1.00) == 1.00
    assert m.insert_money(1.00) == 2.00       # incremental insert
    assert m.state == State.MONEY_INSERTED

    item, change = m.select_product("A1")     # successful vend
    assert item.item_id == "A1" and change == 0.50
    assert m.items["A1"].count == 0
    assert m.state == State.IDLE and m.balance == 0.0

    m.insert_money(0.50)
    try:
        m.select_product("A1")                # now out of stock
        assert False, "should have raised"
    except ValueError as e:
        assert "out of stock" in str(e).lower()
    assert m.cancel() == 0.50                 # money comes back

    print("All checks passed.")

Extensions and trade-offs#

This design is deliberately minimal. Here is where it grows:

  • State pattern. With three states, if checks are fine. If states multiply (maintenance mode, card payment pending), move each state into its own class implementing insert_money, select_product and cancel, and let the machine delegate to its current state object.
  • Money as floats. Real systems store cents as integers (or use Decimal) to avoid rounding errors like 0.1 + 0.2.
  • Change availability. A real machine has a finite coin inventory; returning change may fail. Model a CoinInventory and check it before dispensing.
  • Payment methods. Put card/cash/mobile behind a PaymentMethod interface.
  • Concurrency. The check-then-act in select_product (check stock and balance, then decrement) would need a lock if two requests could hit the machine at once.

Key takeaways#

  • Model the machine's state explicitly with an enum; every public method first checks whether it's allowed.
  • Validate against your own inventory, not caller-supplied objects.
  • Reset balance and state together so the machine never ends half-way.
  • The State pattern is the natural refactor once states grow.
  • Use integer cents for money in real code.

Found a mistake? Tell me on LinkedIn ↗

Further reading#