The 100 problems, solved / Commerce and money / 082 · Design a coffee machine

Design a coffee machine

Recipes as data, ingredient inventory, concurrent orders on a few dispensers.

Concepts this design leans on

01The design, in the six-step framework

1. Requirements and use cases

A coffee machine has a menu of recipes, a stock of ingredients and a small number of dispensers. The interesting part is that several people press buttons at once while the machine has, say, three outlets.

  • An operator defines recipes as lists of ingredient quantities; adding a drink needs no code change.
  • A user orders a drink and either gets it or a clear reason (unknown drink, ingredient short).
  • The machine serves up to N drinks concurrently, one per outlet, and queues the rest.
  • The machine reserves ingredients for an order so two concurrent orders cannot both consume the last 50 ml of milk.
  • An operator refills ingredients and reads low-stock warnings.

Out of scope: payment, temperature control and the physical dispensing hardware. Non-functional constraints: thread safety on stock, bounded concurrency equal to the outlet count, recipes as data, and deterministic error messages for tests.

2. Core entities and relationships

  • Ingredient: a name and a unit; identity by name.
  • Recipe: an immutable mapping from ingredient to quantity, built with a builder so a menu file can describe it.
  • Inventory: quantities per ingredient behind a lock; reserve-all-or-nothing and refill.
  • Outlet: one dispenser; the pool of outlets is a semaphore.
  • Order: a recipe request with a state (queued, brewing, served, failed, reason).
  • StockRule: a specification that decides whether an order can be accepted (enough stock, drink enabled).
  • CoffeeMachine: the facade: menu, inventory, outlets and a worker pool that brews.
MenuMachineStockcreatesmenu**Recipename: strneeds: dict[str, int]RecipeBuilderadd(ingredient, qty)build()CoffeeMachinemenu: dictinventory: Inventoryoutlets: Semaphorequeue: Queueorder(drink)refill(ingredient, qty)Orderid: intrecipe: Recipestate: OrderStatereason: strStockRuleaccept(inventory, recipe)Inventoryqty: dictlock: Lockreserve(needs)refill(name, qty)low(threshold)Ingredientname: strunit: str

The machine composes its inventory and its orders. Recipes are aggregated: the menu can be swapped as a whole, and the same recipe object may appear in two menus.

3. Interfaces and key methods

class RecipeBuilder:
    def add(self, ingredient: str, qty: int) -> "RecipeBuilder": ...
    def build(self) -> Recipe:
        """Fails if any quantity is non-positive or the recipe is empty."""

class Inventory:
    def reserve(self, needs: dict[str, int]) -> bool:
        """Atomically deduct every ingredient or none; False if any is short."""
    def refill(self, name: str, qty: int) -> None: ...
    def low(self, threshold: int) -> list[str]: ...

class StockRule(Protocol):
    def accept(self, inventory: Inventory, recipe: Recipe) -> Optional[str]:
        """Return a rejection reason, or None to accept."""

class CoffeeMachine:
    def order(self, drink: str) -> Order:
        """Validate, reserve ingredients, enqueue for brewing; returns immediately with state QUEUED or FAILED."""
    def wait(self, order: Order, timeout: float) -> OrderState: ...
    def shutdown(self) -> None: ...

Invariants: an ingredient quantity never goes negative; an order in state BREWING holds exactly one outlet; the number of orders in BREWING never exceeds the outlet count; a FAILED order consumed no ingredients.

4. Design patterns and the choices behind them

Recipes through a builder

A recipe has many optional parts and must be validated as a whole. A builder collects the ingredients and checks them in build, which keeps Recipe immutable and lets a menu file drive the machine. A constructor with a dictionary argument is the simpler alternative and is fine until validation grows; mention that the builder is cheap insurance, not a requirement.

Reserve, not check-then-take

Two orders for the last cup of milk is the race this question exists to test. Checking stock and then deducting in two steps lets both pass the check. Inventory.reserve does both under one lock and rolls back nothing because it writes only after all checks pass. This is the all-or-nothing step an interviewer wants to hear named.

Outlets as a bounded worker pool

N outlets means N concurrent brews. A queue of accepted orders and N worker threads is the producer-consumer shape; the outlet count is the pool size, so there is no separate semaphore to get wrong. A rejected alternative is to refuse orders when all outlets are busy; queuing is friendlier and the queue length is a natural backpressure signal.

Acceptance as a specification

Whether an order is accepted depends on rules that change: stock, a disabled drink, a daily cap. Each is a specification returning a reason; the machine runs them in order. The order state is a small enum rather than state classes, because the states differ in data, not behaviour.

5. Core flow and code

  1. order("latte") looks up the recipe and runs each StockRule; a reason marks the order FAILED.
  2. It calls inventory.reserve; a shortfall marks the order FAILED with the ingredient name.
  3. The order is QUEUED and put on the queue; a free worker takes it, marks BREWING, simulates brewing, marks SERVED.
  4. wait blocks the caller on the order's event until it is served or failed.
from __future__ import annotations
import itertools, queue, threading, time
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional, Protocol


class OrderState(Enum):
    QUEUED = "queued"; BREWING = "brewing"; SERVED = "served"; FAILED = "failed"


@dataclass(frozen=True)
class Ingredient:
    name: str
    unit: str = "ml"


@dataclass(frozen=True)
class Recipe:
    name: str
    needs: tuple[tuple[str, int], ...]     # frozen mapping ingredient -> qty


class RecipeBuilder:
    def __init__(self, name: str) -> None:
        self.name, self._needs = name, {}
    def add(self, ingredient: str, qty: int) -> RecipeBuilder:
        if qty <= 0:
            raise ValueError("quantity must be positive")
        self._needs[ingredient] = self._needs.get(ingredient, 0) + qty
        return self
    def build(self) -> Recipe:
        if not self._needs:
            raise ValueError("empty recipe")
        return Recipe(self.name, tuple(sorted(self._needs.items())))


class Inventory:
    def __init__(self) -> None:
        self.qty: dict[str, int] = {}
        self.lock = threading.Lock()

    def refill(self, name: str, qty: int) -> None:
        with self.lock:
            self.qty[name] = self.qty.get(name, 0) + qty

    def reserve(self, needs: tuple[tuple[str, int], ...]) -> Optional[str]:
        """Deduct all or nothing; return the first short ingredient, or None."""
        with self.lock:
            for name, n in needs:
                if self.qty.get(name, 0) < n:
                    return name
            for name, n in needs:
                self.qty[name] -= n
            return None

    def low(self, threshold: int) -> list[str]:
        with self.lock:
            return [n for n, q in self.qty.items() if q < threshold]


@dataclass
class Order:
    id: int
    recipe: Recipe
    state: OrderState = OrderState.QUEUED
    reason: str = ""
    done: threading.Event = field(default_factory=threading.Event, repr=False)


class StockRule(Protocol):
    def accept(self, inventory: Inventory, recipe: Recipe) -> Optional[str]: ...


class EnabledDrinks:
    def __init__(self, enabled: set[str]) -> None: self.enabled = enabled
    def accept(self, inventory: Inventory, recipe: Recipe) -> Optional[str]:
        return None if recipe.name in self.enabled else "drink disabled"


class CoffeeMachine:
    def __init__(self, menu: list[Recipe], inventory: Inventory, outlets: int, rules: list[StockRule]) -> None:
        self.menu = {r.name: r for r in menu}
        self.inventory, self.rules = inventory, rules
        self.queue: queue.Queue[Optional[Order]] = queue.Queue()
        self._ids = itertools.count(1)
        self.workers = [threading.Thread(target=self._brew_loop, daemon=True) for _ in range(outlets)]
        for w in self.workers:
            w.start()

    def order(self, drink: str) -> Order:
        recipe = self.menu.get(drink)
        order = Order(next(self._ids), recipe or Recipe(drink, ()))
        if recipe is None:
            return self._fail(order, "unknown drink")
        for rule in self.rules:
            reason = rule.accept(self.inventory, recipe)
            if reason:
                return self._fail(order, reason)
        short = self.inventory.reserve(recipe.needs)
        if short:
            return self._fail(order, f"{short} not enough")
        self.queue.put(order)
        return order

    def _fail(self, order: Order, reason: str) -> Order:
        order.state, order.reason = OrderState.FAILED, reason
        order.done.set()
        return order

    def _brew_loop(self) -> None:
        while True:
            order = self.queue.get()
            if order is None:
                return
            order.state = OrderState.BREWING
            time.sleep(0.01)                     # stands in for the dispenser
            order.state = OrderState.SERVED
            order.done.set()

    def wait(self, order: Order, timeout: float = 5.0) -> OrderState:
        order.done.wait(timeout)
        return order.state

    def shutdown(self) -> None:
        for _ in self.workers:
            self.queue.put(None)
        for w in self.workers:
            w.join()

6. Extensions, concurrency and what interviewers probe

  • Two orders, one cup of milk: both call reserve; the lock serialises them, the second sees the shortfall and fails before it is queued. Say explicitly that the check and the deduction share one critical section.
  • Refill while brewing is safe because refill takes the same lock and brewing never touches inventory again; ingredients were taken at acceptance.
  • Priority or VIP orders: swap queue.Queue for a priority queue keyed by (priority, order id); the workers do not change.
  • Low-stock alerts: an observer on Inventory fired inside reserve when a quantity drops under a threshold; keep the callback outside the lock to avoid holding it during I/O.
  • Test first: with 100 ml of milk and two latte orders needing 60 ml each, exactly one is served and the other fails naming milk; with three outlets and ten orders, never more than three are BREWING at once (count states in a loop); an unknown drink fails without touching stock.
  • Common mistake: deducting ingredients inside the worker thread. By then the order has been accepted and a user has walked away; the deduction belongs at acceptance time.

02Where this question was reported

  • LLD Mastery · Google-tagged (Easy); State pattern, inventory
  • Codemia · Codemia Easy
  • GitHub · awesome-low-level-design, Easy
  • LLD Canvas · Same shape as vending machine with ingredient stock