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
- A customer can see an item's price if it is in stock.
- A customer inserts money (possibly in several steps).
- A customer selects a product; if the balance covers it, the machine dispenses it and returns change.
- A customer can cancel and get their money back.
- 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:
VendingMachinehas manyItems (aggregation: items are added and restocked from outside).VendingMachinehas a currentState.
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,
ifchecks are fine. If states multiply (maintenance mode, card payment pending), move each state into its own class implementinginsert_money,select_productandcancel, 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 like0.1 + 0.2. - Change availability. A real machine has a finite coin inventory; returning change may fail. Model a
CoinInventoryand check it before dispensing. - Payment methods. Put card/cash/mobile behind a
PaymentMethodinterface. - 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.