What L5 looks like
- Derives the requirements from the steps, so adding a recipe is trivial and can't be inconsistent.
- Makes check-and-deduct atomic under a lock, and explains the race between two orders or between an order and a refill.
- Uses an explicit state machine with a fault state. Low-stock alerts fire on threshold crossings, not on every brew.
Requirements
- The machine serves drinks from a menu of recipes (espresso, americano, latte, cappuccino...). A recipe is an ordered list of brewing steps (grind, extract with hot water, steam milk), and the steps define how much of each ingredient it uses.
- Ingredients sit in containers (water, beans, milk). Each container has a capacity, a current level and a low-level threshold.
- Before brewing, the machine checks every ingredient the recipe needs. If any is short, it refuses the drink, says what is missing and consumes nothing. The menu shows only the drinks it can make right now.
- When a container drops to its low threshold, an operator gets one alert. Refills are capped at capacity. One brew chamber, so one drink at a time. A second order during a brew is rejected, not queued.
- Adding a recipe means writing data (name + steps); the machine class doesn't change. If a step fails mid-brew (hardware fault), the machine goes out of service until an operator resets it.
- Out of scope: payment, cup sizes, customisation UI, hardware drivers, cleaning cycles, order queues, persistence.
Core objects
-
Ingredient(enum): the ingredient kinds the machine stocks. Each value names its unit. -
Container: holds one ingredient, with capacity, level and low threshold. Refill caps at capacity. -
Inventory: owns the containers. Checks and deducts a recipe's needs in one atomic step. Reports threshold crossings. -
BrewStep(ABC): one step of a recipe. Says what it consumes and runs its action.Grind,ExtractandSteamMilkare the concrete steps. -
Recipe: a named, ordered list of steps. Its requirements are the sum of what its steps consume. -
RecipeBook: registry of recipes by name. Rejects duplicates and empty recipes. -
CoffeeMachine: the facade, with aMachineStateofIDLE,BREWINGorOUT_OF_SERVICE. Takes orders, guards the state machine, runs steps, notifies low-stock listeners. -
InsufficientIngredients,MachineBusy: errors that explain a refusal.
Class design
{"type":"excalidraw","elements":[
{"type":"rectangle","id":"machine","x":400,"y":0,"width":220,"height":140,"label":{"text":"CoffeeMachine\nstate\nmenu(), brew(name)\nsubscribe(listener)\nreset()"}},
{"type":"rectangle","id":"inv","x":0,"y":10,"width":260,"height":120,"label":{"text":"Inventory\nshortfall(needs)\nreserve(needs)\nrefill(ingredient, amount)"}},
{"type":"rectangle","id":"container","x":20,"y":260,"width":220,"height":120,"label":{"text":"Container\nlevel, low_threshold\nrefill(amount)\nis_low()"}},
{"type":"rectangle","id":"book","x":780,"y":25,"width":200,"height":90,"label":{"text":"RecipeBook\nregister(recipe)\nget(name)"}},
{"type":"rectangle","id":"recipe","x":780,"y":260,"width":200,"height":90,"label":{"text":"Recipe\nsteps\nrequirements()"}},
{"type":"rectangle","id":"step","x":780,"y":480,"width":200,"height":120,"label":{"text":"<<abstract>>\nBrewStep\nconsumes()\nrun(log)"}},
{"type":"rectangle","id":"grind","x":520,"y":720,"width":180,"height":70,"label":{"text":"Grind"}},
{"type":"rectangle","id":"extract","x":790,"y":720,"width":180,"height":70,"label":{"text":"Extract"}},
{"type":"rectangle","id":"steam","x":1060,"y":720,"width":180,"height":70,"label":{"text":"SteamMilk"}},
{"type":"arrow","id":"m-inv","x":400,"y":70,"width":-140,"height":0,"start":{"id":"machine"},"end":{"id":"inv"},"label":{"text":"uses"}},
{"type":"arrow","id":"m-book","x":620,"y":70,"width":160,"height":0,"start":{"id":"machine"},"end":{"id":"book"},"label":{"text":"uses"}},
{"type":"arrow","id":"inv-container","x":130,"y":130,"width":1,"height":130,"start":{"id":"inv"},"end":{"id":"container"},"label":{"text":"has"}},
{"type":"arrow","id":"book-recipe","x":880,"y":115,"width":1,"height":145,"start":{"id":"book"},"end":{"id":"recipe"},"label":{"text":"has"}},
{"type":"arrow","id":"recipe-step","x":880,"y":350,"width":1,"height":130,"start":{"id":"recipe"},"end":{"id":"step"},"label":{"text":"has"}},
{"type":"arrow","id":"grind-step","x":610,"y":720,"width":230,"height":-120,"start":{"id":"grind"},"end":{"id":"step"},"label":{"text":"extends"}},
{"type":"arrow","id":"extract-step","x":880,"y":720,"width":1,"height":-120,"start":{"id":"extract"},"end":{"id":"step"},"label":{"text":"extends"}},
{"type":"arrow","id":"steam-step","x":1150,"y":720,"width":-230,"height":-120,"start":{"id":"steam"},"end":{"id":"step"},"label":{"text":"extends"}}
]}
Key flows
Brew a drink
-
CoffeeMachine.brew("latte")looks up the recipe inRecipeBook. An unknown name raisesKeyError. - Under the machine lock, if the state is not
IDLE, the machine raisesMachineBusy. - Still under the lock,
Inventory.reserve(recipe.requirements())deducts every ingredient, or none. A shortfall raisesInsufficientIngredientswith the missing amounts, and the state staysIDLE. - The state becomes
BREWINGand the lock is released, somenu()and refills don't wait on a 30-second brew. EachBrewStep.run()then executes in order and appends to the brew log. - On success the state returns to
IDLE. On a step error it goes toOUT_OF_SERVICE; the ingredients count as used, because they were dispensed. Either way, listeners get one call for each container that crossed its low threshold.
-
Show the menu: lists the recipes whose requirements have an empty
Inventory.shortfall. -
Refill:
Inventory.refilltakes the inventory lock, caps at capacity and returns the amount actually added. It is allowed in any state. -
Add a recipe: build a
Recipefrom steps and callRecipeBook.register. The requirements come from the steps, so the stock check needs no change.
| From | Event | To |
|---|---|---|
| IDLE | order accepted, ingredients reserved | BREWING |
| IDLE | order refused (short / unknown) | IDLE |
| BREWING | all steps done | IDLE |
| BREWING | step fails | OUT_OF_SERVICE |
| OUT_OF_SERVICE | operator reset()
|
IDLE |
Code
from __future__ import annotations
import threading
from abc import ABC, abstractmethod
from collections import Counter
from dataclasses import dataclass
from enum import Enum
from typing import Callable
class Ingredient(Enum):
WATER, BEANS, MILK = "water_ml", "beans_g", "milk_ml"
class MachineState(Enum):
IDLE, BREWING, OUT_OF_SERVICE = "idle", "brewing", "out_of_service"
class MachineBusy(Exception): ...
class InsufficientIngredients(Exception):
def __init__(self, missing: dict[Ingredient, int]) -> None:
super().__init__(f"not enough: {missing}")
self.missing = missing
@dataclass
class Container:
ingredient: Ingredient
capacity: int
level: int = 0
low_threshold: int = 0
def refill(self, amount: int) -> int:
added = max(0, min(amount, self.capacity - self.level))
self.level += added
return added
def is_low(self) -> bool: return self.level <= self.low_threshold
class Inventory:
def __init__(self, containers: list[Container]) -> None:
self._containers = {c.ingredient: c for c in containers}
self._lock = threading.Lock()
def _missing(self, needs: dict[Ingredient, int]) -> dict[Ingredient, int]:
level = lambda i: self._containers[i].level if i in self._containers else 0 # no milk tank -> 0
return {i: q - level(i) for i, q in needs.items() if level(i) < q}
def shortfall(self, needs: dict[Ingredient, int]) -> dict[Ingredient, int]:
with self._lock: return self._missing(needs)
def reserve(self, needs: dict[Ingredient, int]) -> dict[Ingredient, int]: # all or nothing
with self._lock:
if missing := self._missing(needs):
raise InsufficientIngredients(missing)
crossed: dict[Ingredient, int] = {}
for ing, qty in needs.items():
c = self._containers[ing]
was_low = c.is_low()
c.level -= qty
if c.is_low() and not was_low:
crossed[ing] = c.level
return crossed
def refill(self, ing: Ingredient, amount: int) -> int:
with self._lock: return self._containers[ing].refill(amount)
class BrewStep(ABC):
def consumes(self) -> dict[Ingredient, int]: return {}
@abstractmethod
def run(self, log: list[str]) -> None: ...
@dataclass
class Grind(BrewStep):
grams: int
def consumes(self) -> dict[Ingredient, int]: return {Ingredient.BEANS: self.grams}
def run(self, log: list[str]) -> None: log.append(f"grind {self.grams}g")
@dataclass
class Extract(BrewStep):
water_ml: int
seconds: int = 25
def consumes(self) -> dict[Ingredient, int]: return {Ingredient.WATER: self.water_ml}
def run(self, log: list[str]) -> None: log.append(f"extract {self.water_ml}ml for {self.seconds}s")
@dataclass
class SteamMilk(BrewStep):
ml: int
def consumes(self) -> dict[Ingredient, int]: return {Ingredient.MILK: self.ml}
def run(self, log: list[str]) -> None: log.append(f"steam {self.ml}ml milk")
@dataclass(frozen=True)
class Recipe:
name: str
steps: tuple[BrewStep, ...]
def requirements(self) -> dict[Ingredient, int]: # summed, so two water steps add up
return dict(sum((Counter(s.consumes()) for s in self.steps), Counter()))
class RecipeBook:
def __init__(self) -> None:
self._recipes: dict[str, Recipe] = {}
def register(self, recipe: Recipe) -> None:
if recipe.name in self._recipes or not recipe.steps:
raise ValueError(f"duplicate or empty recipe {recipe.name!r}")
self._recipes[recipe.name] = recipe
def get(self, name: str) -> Recipe: return self._recipes[name] # KeyError if unknown
def all(self) -> list[Recipe]: return list(self._recipes.values())
class CoffeeMachine:
def __init__(self, inventory: Inventory, recipes: RecipeBook) -> None:
self.inventory, self.recipes = inventory, recipes
self.state = MachineState.IDLE
self._lock = threading.Lock()
self._listeners: list[Callable[[Ingredient, int], None]] = []
def subscribe(self, fn: Callable[[Ingredient, int], None]) -> None: self._listeners.append(fn)
def menu(self) -> list[str]:
return [r.name for r in self.recipes.all() if not self.inventory.shortfall(r.requirements())]
def brew(self, name: str) -> list[str]:
recipe = self.recipes.get(name)
with self._lock:
if self.state is not MachineState.IDLE:
raise MachineBusy(self.state.value)
crossed = self.inventory.reserve(recipe.requirements()) # raises -> stays IDLE
self.state = MachineState.BREWING
log: list[str] = []
next_state = MachineState.OUT_OF_SERVICE
try:
for step in recipe.steps:
step.run(log)
next_state = MachineState.IDLE
finally:
with self._lock:
self.state = next_state
for ing, level in crossed.items():
for fn in self._listeners:
fn(ing, level)
return log
def reset(self) -> None:
with self._lock:
if self.state is MachineState.OUT_OF_SERVICE:
self.state = MachineState.IDLE
if __name__ == "__main__":
inv = Inventory([Container(Ingredient.WATER, 1000, 300, 100),
Container(Ingredient.BEANS, 500, 30, 20), Container(Ingredient.MILK, 800, 150, 100)])
book = RecipeBook()
book.register(Recipe("espresso", (Grind(18), Extract(40))))
book.register(Recipe("latte", (Grind(18), Extract(40), SteamMilk(150))))
machine = CoffeeMachine(inv, book)
machine.subscribe(lambda ing, lvl: print(f"ALERT: {ing.name} low ({lvl})"))
print(machine.brew("latte")) # BEANS and MILK cross their thresholds -> two alerts
try:
machine.brew("latte")
except InsufficientIngredients as e:
print("refused:", e.missing) # BEANS short by 6, MILK by 150
inv.refill(Ingredient.BEANS, 1000) # capped at capacity (500)
print(machine.menu()) # ['espresso']
Design patterns
-
Strategy (
BrewStep): each step type wraps one action and what it consumes. Recipes compose steps, so a new drink is data, and a new kind of step is one new class. Requirements are computed from the steps, so the stock check and the brew procedure can't drift apart. -
State:
MachineStatewith guarded transitions. Brewing starts only fromIDLE, and a fault needs a reset. With three states, an enum is enough. State classes would be overkill. - Observer: low-stock listeners (operator app, LED, telemetry) subscribe without the machine knowing who they are. They fire once per threshold crossing, not on every brew.
-
Facade:
CoffeeMachineis the one entry point for the UI. It hides the inventory, the recipe book and the steps.
Extensions
-
Sizes and customisation (extra shot, oat milk): a
DrinkOrderapplies modifiers that rewrite the step tuple (scale(1.5), swapSteamMilkfor an oat-milk step). The stock check is unchanged, because it still sums the steps. -
Recipes from config or a server: a
StepFactorymaps{"type": "grind", "grams": 18}to step objects.RecipeBook.registeralready validates them. -
Order queue or several brew chambers: replace the
MachineBusyrefusal with a queue drained by one worker per chamber. Reserve the ingredients at enqueue time, so a later order can't take stock from a queued one. -
Payment and refunds: charge before
reserve, and refund ifreserverefuses or a step faults. APricingstrategy for each recipe keeps the money logic out of the brewing code.
Interviewer expectations
Mid-level (L4)
- Clean core classes: containers, recipe, machine. Recipes are data, not
if drink == "latte"branches. - Checks every ingredient before consuming any, and refuses with a clear reason.
- Working brew, refill and menu, including refill overflow and unknown recipes.
Senior (L5)
- Derives the requirements from the steps, so adding a recipe is trivial and can't be inconsistent.
- Makes check-and-deduct atomic under a lock, and explains the race between two orders or between an order and a refill.
- Uses an explicit state machine with a fault state. Low-stock alerts fire on threshold crossings, not on every brew.
Staff (L6+)
- Drives the scoping: what happens to the ingredients when a brew fails mid-way, queue versus reject, and where the hardware boundary sits (steps as the driver interface).
- Shows sizes, config-loaded recipes, multiple chambers and payments fitting in without changes to the core classes.
- Covers operability: telemetry for each step, fault codes, and tests against a fake hardware layer.
Make it yours
Remix it: write each part yourself, grade it against this answer, then let the coach ask you follow-ups.
