Design a vending machine

0
Remix

Model a vending machine with products, slots, coins and notes: select, pay, give change and handle sold-out items, using clear states.

AmazonCodingEasy

What L5 looks like

  • The State pattern, with each state owning its rules and safe defaults.
  • Escrow, so cancel and failed change refund the exact coins and never corrupt the cash box.
  • Sees that greedy change fails with limited stock, and checks change before dispensing.
  • Thread safety between customer and operator calls.
Interviewer expectations for every level ↓

Requirements

  • Slots (A1, A2, ...) each hold one product with a price and a remaining count.
  • A customer selects a slot, then inserts coins and notes until the price is covered.
  • Paid in full: dispense the product and return change from the machine's limited coin stock.
  • If exact change is impossible, cancel the sale and return every coin inserted.
  • Unknown and sold-out slots are rejected at selection. Money inserted before a selection is handed back.
  • The customer can cancel any time before dispensing and gets back exactly what they inserted.
  • An operator can restock slots and take the machine out of service.
  • One customer at a time, but operator and customer calls can race, so state changes are serialized.
  • Out of scope: cards and mobile pay, multi-item baskets, promotions, telemetry, motor and coin-validator hardware (assumed to report a valid denomination).

Core objects

  • Denomination (enum): accepted coins and notes, in integer cents (no floating-point money).
  • Product (name, price) and Slot (code, product, quantity; knows if it is sold out).
  • CashBox: count of each denomination the machine owns; plans change from that limited stock.
  • Result: what the customer gets back from an action: product, coins, display message.
  • State (abstract): what select, insert and cancel mean right now, with safe defaults.
  • IdleState, HasSelectionState, OutOfServiceState: the concrete states.
  • VendingMachine: the context. Owns slots, cash box, escrow and current state; holds the lock.

Class design

{"type":"excalidraw","elements":[
 {"type":"rectangle","id":"product","x":0,"y":0,"width":180,"height":70,"label":{"text":"Product\nname, price"}},
 {"type":"rectangle","id":"slot","x":0,"y":230,"width":180,"height":95,"label":{"text":"Slot\ncode, quantity\nis_sold_out()"}},
 {"type":"rectangle","id":"vm","x":340,"y":180,"width":240,"height":190,"label":{"text":"VendingMachine\nslots, escrow,\nselected, state\nselect(), insert(),\ncancel(), restock(),\nset_in_service(),\ncomplete_sale()"}},
 {"type":"rectangle","id":"cash","x":760,"y":220,"width":240,"height":110,"label":{"text":"CashBox\ncounts\nadjust(coins, sign)\nplan_change(amount)"}},
 {"type":"rectangle","id":"state","x":340,"y":480,"width":240,"height":110,"label":{"text":"State (abstract)\nselect(vm, code)\ninsert(vm, d)\ncancel(vm)"}},
 {"type":"rectangle","id":"idle","x":20,"y":760,"width":240,"height":70,"label":{"text":"IdleState"}},
 {"type":"rectangle","id":"hassel","x":340,"y":760,"width":240,"height":70,"label":{"text":"HasSelectionState"}},
 {"type":"rectangle","id":"oos","x":660,"y":760,"width":240,"height":70,"label":{"text":"OutOfServiceState"}},
 {"type":"arrow","id":"vm-slot","x":340,"y":275,"width":-160,"height":0,"start":{"id":"vm"},"end":{"id":"slot"},"label":{"text":"has"}},
 {"type":"arrow","id":"vm-cash","x":580,"y":275,"width":180,"height":0,"start":{"id":"vm"},"end":{"id":"cash"},"label":{"text":"has"}},
 {"type":"arrow","id":"vm-state","x":460,"y":370,"width":1,"height":110,"start":{"id":"vm"},"end":{"id":"state"},"label":{"text":"uses"}},
 {"type":"arrow","id":"slot-product","x":90,"y":230,"width":1,"height":-160,"start":{"id":"slot"},"end":{"id":"product"},"label":{"text":"has"}},
 {"type":"arrow","id":"idle-state","x":183,"y":760,"width":209,"height":-170,"start":{"id":"idle"},"end":{"id":"state"},"label":{"text":"extends"}},
 {"type":"arrow","id":"hassel-state","x":460,"y":760,"width":1,"height":-170,"start":{"id":"hassel"},"end":{"id":"state"},"label":{"text":"extends"}},
 {"type":"arrow","id":"oos-state","x":737,"y":760,"width":-209,"height":-170,"start":{"id":"oos"},"end":{"id":"state"},"label":{"text":"extends"}}
]}

Key flows

Select. VendingMachine.select(code) takes the lock and delegates to the state. IdleState and HasSelectionState (reselection) call vm.choose(code): an unknown or sold-out slot returns a message and leaves the state unchanged; otherwise selected = slot and the state becomes HasSelectionState.

Insert and complete the sale.

  1. HasSelectionState.insert(d) appends d to escrow: money held, not yet the machine's.
  2. If paid < price, the display shows the amount still owed.
  3. Otherwise vm.complete_sale() re-checks stock (an operator call may have changed it), moves escrow into the CashBox so inserted coins can be used as change, and asks plan_change(paid - price).
  4. No plan: take the escrow back out and refund it. Plan found: remove the change coins, decrement the slot, clear escrow, go to IdleState.

Cancel. Returns the escrow coins exactly as inserted. The cash box is never touched, so cancel cannot fail.

Change-making. Greedy is optimal for unlimited US coins but fails with limited stock: owing 30 with one quarter and three dimes, greedy takes the quarter and is stuck. plan_change tries the largest count of each denomination first and backtracks; with five denominations this is tiny.

{"type":"excalidraw","elements":[
 {"type":"ellipse","id":"start","x":0,"y":50,"width":110,"height":50,"label":{"text":"start"}},
 {"type":"rectangle","id":"idle","x":160,"y":0,"width":260,"height":150,"label":{"text":"Idle"}},
 {"type":"rectangle","id":"has","x":820,"y":0,"width":260,"height":150,"label":{"text":"HasSelection"}},
 {"type":"rectangle","id":"oos","x":160,"y":330,"width":260,"height":80,"label":{"text":"OutOfService"}},
 {"type":"arrow","id":"init","x":110,"y":75,"width":50,"height":0,"start":{"id":"start"},"end":{"id":"idle"}},
 {"type":"arrow","id":"select","x":420,"y":20,"width":400,"height":0,"start":{"id":"idle"},"end":{"id":"has"},"label":{"text":"select (valid, in stock)"}},
 {"type":"arrow","id":"paid","x":820,"y":75,"width":-400,"height":0,"start":{"id":"has"},"end":{"id":"idle"},"label":{"text":"paid >= price / dispense + change"}},
 {"type":"arrow","id":"refund","x":820,"y":130,"width":-400,"height":0,"start":{"id":"has"},"end":{"id":"idle"},"label":{"text":"no change or cancel / refund"}},
 {"type":"arrow","id":"insert","x":860,"y":0,"width":180,"height":0,"points":[[0,0],[0,-80],[180,-80],[180,0]],"start":{"id":"has"},"end":{"id":"has"},"label":{"text":"insert (paid < price) / reselect"}},
 {"type":"arrow","id":"op-off","x":210,"y":150,"width":1,"height":180,"start":{"id":"idle"},"end":{"id":"oos"},"label":{"text":"operator"}},
 {"type":"arrow","id":"op-on","x":370,"y":330,"width":1,"height":-180,"start":{"id":"oos"},"end":{"id":"idle"},"label":{"text":"operator"}},
 {"type":"arrow","id":"op-refund","x":860,"y":150,"width":-440,"height":220,"start":{"id":"has"},"end":{"id":"oos"},"label":{"text":"operator / refund"}}
]}

Code

from __future__ import annotations
import threading
from abc import ABC
from dataclasses import dataclass, field
from enum import Enum

class Denomination(Enum):  # cents; DOLLAR and FIVE are notes
    NICKEL = 5; DIME = 10; QUARTER = 25; DOLLAR = 100; FIVE = 500

@dataclass
class Product:
    name: str
    price: int

@dataclass
class Slot:
    code: str
    product: Product
    quantity: int
    def is_sold_out(self) -> bool: return self.quantity <= 0

@dataclass
class Result:
    product: Product | None = None
    change: list[Denomination] = field(default_factory=list)
    message: str = ""

class CashBox:
    def __init__(self, counts: dict[Denomination, int]) -> None:
        self.counts = {d: counts.get(d, 0) for d in Denomination}

    def adjust(self, coins: list[Denomination], sign: int) -> None:
        for d in coins:
            self.counts[d] += sign

    def plan_change(self, amount: int) -> list[Denomination] | None:
        denoms = sorted(Denomination, key=lambda d: d.value, reverse=True)
        def solve(i: int, left: int) -> list[Denomination] | None:
            if left == 0:
                return []
            if i == len(denoms):
                return None
            d = denoms[i]
            for n in range(min(self.counts[d], left // d.value), -1, -1):
                rest = solve(i + 1, left - n * d.value)
                if rest is not None:
                    return [d] * n + rest
            return None
        return solve(0, amount)

class State(ABC):
    def select(self, vm: VendingMachine, code: str) -> Result:
        return Result(message="not available")
    def insert(self, vm: VendingMachine, d: Denomination) -> Result:
        return Result(change=[d], message="select a product first")
    def cancel(self, vm: VendingMachine) -> Result:
        return Result(message="nothing to cancel")

class IdleState(State):
    def select(self, vm, code): return vm.choose(code)

class HasSelectionState(State):
    def select(self, vm, code): return vm.choose(code)
    def cancel(self, vm): return vm.refund("cancelled", IdleState())
    def insert(self, vm: VendingMachine, d: Denomination) -> Result:
        vm.escrow.append(d)
        owed = vm.selected.product.price - vm.paid()
        return vm.complete_sale() if owed <= 0 else Result(message=f"insert {owed}")

class OutOfServiceState(State):
    def insert(self, vm, d): return Result(change=[d], message="out of service")

class VendingMachine:
    def __init__(self, slots: list[Slot], cash: CashBox) -> None:
        self.slots = {s.code: s for s in slots}
        self.cash, self.escrow = cash, []
        self.selected: Slot | None = None
        self.state: State = IdleState()
        self._lock = threading.Lock()

    def select(self, code: str) -> Result:
        with self._lock: return self.state.select(self, code)
    def insert(self, d: Denomination) -> Result:
        with self._lock: return self.state.insert(self, d)
    def cancel(self) -> Result:
        with self._lock: return self.state.cancel(self)
    def restock(self, code: str, n: int) -> None:
        with self._lock: self.slots[code].quantity += n
    def set_in_service(self, on: bool) -> Result:
        with self._lock:
            if not on:
                return self.refund("out of service", OutOfServiceState())
            self.state = IdleState()
            return Result(message="ready")

    # helpers below run with the lock held
    def paid(self) -> int:
        return sum(d.value for d in self.escrow)

    def choose(self, code: str) -> Result:
        slot = self.slots.get(code)
        if slot is None or slot.is_sold_out():
            return Result(message="sold out" if slot else "unknown slot")
        self.selected, self.state = slot, HasSelectionState()
        return Result(message=f"{slot.product.name}: insert {slot.product.price - self.paid()}")

    def refund(self, message: str, next_state: State) -> Result:
        coins, self.escrow, self.selected = self.escrow, [], None
        self.state = next_state
        return Result(change=coins, message=message)

    def complete_sale(self) -> Result:
        slot = self.selected
        if slot.is_sold_out():
            return self.refund("sold out", IdleState())
        self.cash.adjust(self.escrow, +1)
        change = self.cash.plan_change(self.paid() - slot.product.price)
        if change is None:
            self.cash.adjust(self.escrow, -1)
            return self.refund("no change available, use exact amount", IdleState())
        self.cash.adjust(change, -1)
        slot.quantity -= 1
        self.escrow, self.selected, self.state = [], None, IdleState()
        return Result(product=slot.product, change=change, message="enjoy")

if __name__ == "__main__":
    vm = VendingMachine([Slot("A1", Product("Cola", 125), 1)], CashBox({Denomination.QUARTER: 4}))
    print(vm.select("A1").message)                 # Cola: insert 125
    vm.insert(Denomination.DOLLAR)
    r = vm.insert(Denomination.DOLLAR)             # paid 200, owed 75
    print(r.message, [d.name for d in r.change])   # enjoy ['QUARTER', 'QUARTER', 'QUARTER']
    print(vm.select("A1").message)                 # sold out

Design patterns

  • State: each state class decides what select, insert and cancel mean, so there is no if state == ... chain, and a new state (for example Dispensing) touches no other state. The State defaults are safe (hand the coin back, show a message), so a state only overrides what it allows.
  • Escrow (transaction): inserted money stays apart from the cash box until the sale commits. Cancel and every failure path return the exact coins inserted. The cash box only changes on a successful sale.
  • Value objects: Product, Result and Denomination are plain data, and money is integer cents.

Extensions

  • Card or mobile payment: a PaymentMethod strategy. Cash keeps the escrow logic; card authorizes the price, dispenses, then captures, and voids the authorization on a jam.
  • Async hardware: add a DispensingState that rejects input while the motor runs. The motor callback goes to Idle on success, or refunds and marks the slot faulty on a jam. The lock covers state changes, not the motor wait.
  • Telemetry and restock alerts: the machine publishes SaleCompleted, SlotSoldOut and LowChange events to observers. A fleet service subscribes and plans restock routes; core logic is unchanged.
  • Promotions: price comes from a PricingPolicy given the slot and time. choose and complete_sale ask the policy; the state classes stay the same.

Interviewer expectations

Mid-level (L4)

  • Correct classes for product, slot, money and machine, with integer cents.
  • A clear select, pay, dispense, change flow that handles sold out and cancel.
  • An explicit notion of state, even as an enum with checks.

Senior (L5)

  • The State pattern, with each state owning its rules and safe defaults.
  • Escrow, so cancel and failed change refund the exact coins and never corrupt the cash box.
  • Sees that greedy change fails with limited stock, and checks change before dispensing.
  • Thread safety between customer and operator calls.

Staff (L6+)

  • Treats the sale as a transaction with hardware: jams, power loss mid-dispense, validator errors, and recovery from a durable log.
  • Puts payment, pricing and hardware drivers behind interfaces so one design fits a fleet of machine models.
  • Covers fleet operations: sales and low-change events, remote out-of-service, restock planning.

Make it yours

Remix it: write each part yourself, grade it against this answer, then let the coach ask you follow-ups.