0

Design a coffee maker

by · 1 day ago· ⑂ 0 remixes

Question: Design a coffee maker

Companies: Amazon

Difficulty: Easy

Problem statement

Model a coffee machine with recipes, ingredient containers and brewing steps; refuse drinks when ingredients run low and make adding recipes easy.

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, Extract and SteamMilk are 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 a MachineState of IDLE, BREWING or OUT_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

  1. CoffeeMachine.brew("latte") looks up the recipe in RecipeBook. An unknown name raises KeyError.
  2. Under the machine lock, if the state is not IDLE, the machine raises MachineBusy.
  3. Still under the lock, Inventory.reserve(recipe.requirements()) deducts every ingredient, or none. A shortfall raises InsufficientIngredients with the missing amounts, and the state stays IDLE.
  4. The state becomes BREWING and the lock is released, so menu() and refills don't wait on a 30-second brew. Each BrewStep.run() then executes in order and appends to the brew log.
  5. On success the state returns to IDLE. On a step error it goes to OUT_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.refill takes the inventory lock, caps at capacity and returns the amount actually added. It is allowed in any state.
  • Add a recipe: build a Recipe from steps and call RecipeBook.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: MachineState with guarded transitions. Brewing starts only from IDLE, 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: CoffeeMachine is 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 DrinkOrder applies modifiers that rewrite the step tuple (scale(1.5), swap SteamMilk for an oat-milk step). The stock check is unchanged, because it still sums the steps.
  • Recipes from config or a server: a StepFactory maps {"type": "grind", "grams": 18} to step objects. RecipeBook.register already validates them.
  • Order queue or several brew chambers: replace the MachineBusy refusal 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 if reserve refuses or a step faults. A Pricing strategy 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.

Sign in to comment, vote or remix.