The idea
What it is
“Design a coffee machine.” It sounds like the vending machine again — pick a drink, it comes out. And if there is exactly one outlet, it very nearly is.
The difference is what a drink is made of. A vending machine hands you an item that already exists in a slot. A coffee machine builds your drink from tanks of water, milk, beans and sugar — and those tanks are shared by everything it makes. Order a latte and you have quietly changed what the espresso next to it can do.
The whole system in three sentences
A recipe is a map of ingredient → quantity. An outlet takes an order, and asks the shared inventory for exactly those amounts. The inventory must check availability and subtract in one indivisible step, or two outlets will both be told yes for the same milk.
What is actually being graded
- Are recipes data? A
class Latteand aclass Espressowith identical structure means adding a drink is a code change. A row in a map means it is configuration. - Is check-and-take atomic? The one question the problem exists to ask. Everything else here is bookkeeping.
- Is a failed brew all-or-nothing? Deducting water and beans and then discovering there is no milk leaves the machine short with nothing to show for it.
- Does it say which ingredient is missing? “Cannot make latte” is useless to whoever refills the machine. “Not enough milk” is a real error message.
- Does it run? Three outlets, a race you can point at, and a demo that prints the tank levels before and after.
Mechanics
How it works
Step 1 · Clarify — 4 minutes
- How many outlets? — the question that decides whether this is interesting. If they say one, ask “could there be N?”, because that is the whole design conversation.
- Can outlets serve at the same time? — say yes. It costs one lock and it is the difference between a toy and a system.
- Where do recipes come from? — configuration loaded at startup. Hardcoded recipes cannot be tested or varied.
- What happens when an ingredient runs out? — refuse and name the ingredient. Also worth asking: does the machine warn before it runs dry?
- Payment, cup detection, cleaning cycles, temperature? — out of scope. Say it in one sentence.
Do not let them talk you down to one outlet
A single-outlet coffee machine is Vending Machine with a different noun, and you will run out of things to say by minute 25. “Assume three outlets that can brew simultaneously” gives you the concurrency conversation, which is the only reason this problem is in the set.
Step 2 · A recipe is data, not a class
The instinct is class Latte, class Espresso, class Cappuccino, each with a make() method. Look at what those classes would actually contain: the same three fields with different numbers.
Step 3 · The race — the reason this problem exists
Three outlets, 200ml of milk, three lattes at 80ml each. There is enough for two. Here is what happens when each outlet checks and then takes:
This is check-then-act, and it is everywhere
“Is there enough? Then take it.” Between the question and the answer, someone else asked the same question and got the same answer. You have already met this exact bug: two gates grabbing one parking spot in Parking Lot, and two threads incrementing a counter in Atomic operations & CAS. It always looks different and it is always the same shape.
class Inventory {
private final Map<Ingredient, Integer> levels = new EnumMap<>(Ingredient.class);
/**
* Check every ingredient AND subtract every ingredient, with nothing in between.
* Either the whole recipe is deducted, or nothing is.
*/
public synchronized void consume(Map<Ingredient, Integer> recipe) {
for (var e : recipe.entrySet()) { // check ALL first
if (levels.get(e.getKey()) < e.getValue())
throw new NotEnoughIngredientException(e.getKey()); // names WHICH one
}
for (var e : recipe.entrySet()) // then take ALL
levels.merge(e.getKey(), -e.getValue(), Integer::sum);
}
}Note the two loops
Checking and taking in a single pass is a subtler bug: you would deduct water and beans, hit the milk, throw — and leave the machine short of two ingredients with no coffee made. Two loops means the method is all-or-nothing, which is the same principle as the rollback in ATM but achieved by ordering rather than compensation.
How coarse should the lock be?
One synchronized on the whole inventory means only one outlet can be taking ingredients at a time. Candidates worry this is too coarse and reach for a lock per tank. That is the wrong instinct here, and knowing why is worth a point.
The class diagram
consume() — a few microseconds of arithmetic. The slow part, the actual brewing, happens after it is released. Notation: Sequence diagrams.The follow-ups
- “Warn when an ingredient is running low.” → the inventory publishes a
LowIngredientevent after each consume; a display subscribes. Observer, and it keeps monitoring out of the brew path. - “Queue orders instead of refusing when busy.” → outlets become consumers pulling from a shared order queue. That is Producer–Consumer, and it is the natural next step up.
- “Add a new drink without redeploying.” → already free, because recipes are data. Load the recipe book from a file and say so.
- “Two sizes — small and large.” → scale the recipe quantities by a factor rather than adding
LargeLatterows. A small function, not new data. - “Refilling while people are brewing.” →
refill()must take the same lock asconsume(). Easy to forget, and a good thing to mention unprompted. - “Make it a real espresso bar with 20 outlets.” → the lock is still fine, because it only guards arithmetic. If you ever did need more, the move is per-ingredient atomics with a fixed acquisition order — mention it, do not build it.
How this round is lost
- No lock at all. The machine serves drinks it cannot make, and the interviewer will ask “what happens if two outlets order at once?” — every time.
- A class per beverage. Three classes with the same shape and different numbers, and a factory to edit for each new drink.
- Deducting as you check. A failed latte leaves the water and beans already spent.
- Holding the lock while brewing. Now a 30-second brew blocks every other outlet, and you have turned three outlets into one.
- A vague error. “Cannot make latte” tells the person refilling the machine nothing. Name the ingredient.
Interactive prototype
See it. Build it. Break it.
A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.
About this simulation
Three outlets, one shared set of tanks. Press ☕ All three want Latte — each latte needs 80ml of milk and the tank holds 200, so only two can be served. Now press ▶ Serve all in ⚠️ Unguarded mode and watch the sequence: all three check, all three see enough milk, all three pour, and the milk tank ends at −40ml. The machine served coffee it did not have. Switch to 🔒 Guarded, refill, and run the identical order: outlets go one at a time, the third one sees what the first two already took, and it is refused. 2 served, 1 refused, nothing below zero.
Hands-on
Try these yourself
Open the prototype above, predict what happens, then verify.
Serve three different drinks first
Leave the default picks (☕ Espresso, 🥛 Latte, 🍵 Tea) and press ▶ Serve all. All three succeed and every tank drops by the sum of the three recipes. Nothing dramatic — but notice that one order changed what the other two had available.
Create the race
Press ☕ All three want Latte. Each latte needs 80ml of milk; the tank holds 200. Now press ▶ Serve all in ⚠️ Unguarded mode and read the beats: every outlet checked — and every one said yes, then all three pour. Final milk level: −40ml. The over-served counter ticks to 1.
Run the identical order with a lock
Press 🚰 Refill tanks, switch to 🔒 Guarded, press ☕ All three want Latte again, and serve. Now the outlets go one at a time. The third one is refused — not enough milk — because it sees what the first two actually took. 2 served, 1 refused, milk at 40. Same order, same recipes, one lock apart.
Read the error message
In the refused outlet, the message names the ingredient — “refused: milk”, not “cannot make latte”. That distinction is what makes the difference between an error a technician can act on and one they cannot.
Add a drink in your head
Look at the recipe list on the right: three rows of name → ingredients. Adding a cappuccino is a fourth row. Now imagine the version with class Latte and class Espresso — where would cappuccino go, and how many files would you touch?
Build it from memory
Blank file, in this order: Ingredient enum → Recipe as a map → Inventory with a synchronized consume() that checks all then takes all → Outlet holding a reference to the same inventory → main() that starts three threads ordering lattes and prints the tank levels at the end. Run it. If milk ever goes negative, your consume() is not atomic.
In practice
When to use it — and what trips people up
The shape you just learned
Strip the coffee away and this is N workers drawing from one pool of limited resources. That shape shows up constantly, and the fix is always the same: make check-and-take one indivisible operation, and hold the lock for as short a time as you possibly can.
- Seat booking — many users, one seat map. BookMyShow is this problem with a bigger pool and money attached.
- Connection pools — many threads, a fixed set of connections. Check out, use, return. See Object Pool.
- Rate limiters — many requests, one token bucket. Take a token or be refused.
- Warehouse stock — many orders, one count per SKU, and a partial reservation is exactly the multi-ingredient problem.
- Any budget or quota — the arithmetic is trivial, and it is wrong the moment two callers do it at once.
The two-sentence version to say out loud
“Check and take have to be one atomic step, or two callers both pass the check. And since a recipe touches several ingredients, the check must cover all of them before any of them is deducted — otherwise a failure leaves the machine short with nothing made.” That is the whole design, and it is 20 seconds.
Where this design stops working
- When the machine is distributed. Several machines drawing from one central stock cannot use an in-process lock — you need the store itself to do the atomic decrement, or optimistic concurrency with a version check.
- When customers should queue rather than be refused. Refusing is correct for a machine with a physical panel; a coffee shop would take the order and wait for milk. That is Producer–Consumer and it is a different design.
- When brewing can fail halfway. The ingredients are already spent, and unlike the ATM you cannot un-pour milk. Real machines just log it — worth saying, because knowing which failures are compensable and which are not is the real skill.
If you only remember one thing
Lock the arithmetic, not the work. Take the ingredients inside a lock that lasts microseconds, then brew for thirty seconds outside it. That one sentence answers both “is it correct?” and “is it a bottleneck?”
What it gives you
- Recipes as data mean a new drink is one configuration row — no new class, no factory edit, no redeploy.
- A single synchronized consume() makes the machine correct under any number of concurrent outlets, in about four lines.
- Checking all ingredients before deducting any makes a failed brew all-or-nothing, so a refusal never leaves the tanks short.
- The lock covers only the arithmetic, so the slow brewing still happens fully in parallel across outlets.
- Errors name the specific missing ingredient, which is what makes the message useful to whoever refills the machine.
Common mistakes
- One lock on the whole inventory serialises every order at that instant; it is the right call at this scale but it is genuinely a single point of contention.
- The in-process lock does not survive the design being split across machines — a shared store would need its own atomic decrement.
- Refusing rather than queueing is a deliberate choice that suits a physical panel and would be wrong for a coffee shop.
- A brew that fails after ingredients are consumed cannot be compensated the way an ATM debit can, so some loss is unavoidable and simply logged.
- Recipes as flat quantity maps cannot express steps or ordering — the moment a drink needs “steam the milk for 20 seconds”, the model has to grow.
Reference
Code & further reading
A minimal reference implementation and pointers worth bookmarking.
import java.util.*;
import java.util.concurrent.*;
enum Ingredient { WATER, MILK, BEANS, SUGAR }
/** A recipe is DATA — adding a drink is a new entry, not a new class. */
record Recipe(String name, Map<Ingredient, Integer> needs) {
Recipe {
needs = Map.copyOf(needs);
if (needs.isEmpty()) throw new IllegalArgumentException("a recipe needs ingredients");
}
/** Small / large without new recipes. */
Recipe scaled(String newName, double factor) {
Map<Ingredient, Integer> out = new EnumMap<>(Ingredient.class);
needs.forEach((k, v) -> out.put(k, (int) Math.ceil(v * factor)));
return new Recipe(newName, out);
}
}
class RecipeBook {
private final Map<String, Recipe> byName = new LinkedHashMap<>();
void add(Recipe r) { byName.put(r.name(), r); }
Recipe find(String name) {
Recipe r = byName.get(name);
if (r == null) throw new IllegalArgumentException("unknown drink: " + name);
return r;
}
static RecipeBook standard() {
RecipeBook book = new RecipeBook();
book.add(new Recipe("espresso", Map.of(Ingredient.WATER, 50, Ingredient.BEANS, 20)));
book.add(new Recipe("latte", Map.of(Ingredient.WATER, 60, Ingredient.MILK, 80, Ingredient.BEANS, 20)));
book.add(new Recipe("tea", Map.of(Ingredient.WATER, 120, Ingredient.MILK, 30, Ingredient.SUGAR, 20)));
return book;
}
}
class NotEnoughIngredientException extends RuntimeException {
final Ingredient ingredient;
NotEnoughIngredientException(Ingredient i) {
super("not enough " + i); // names WHICH one — actionable for whoever refills
this.ingredient = i;
}
}
/** THE shared resource. Every outlet points at the same instance. */
class Inventory {
private final Map<Ingredient, Integer> levels = new EnumMap<>(Ingredient.class);
private final Map<Ingredient, Integer> capacity = new EnumMap<>(Ingredient.class);
private final List<Runnable> lowStockListeners = new CopyOnWriteArrayList<>();
Inventory(Map<Ingredient, Integer> initial) {
levels.putAll(initial);
capacity.putAll(initial);
}
/**
* Check EVERY ingredient, then take EVERY ingredient — with nothing in between.
* Two loops, so a failure leaves the tanks completely untouched.
*/
synchronized void consume(Recipe recipe) {
for (var e : recipe.needs().entrySet()) // pass 1: check all
if (levels.getOrDefault(e.getKey(), 0) < e.getValue())
throw new NotEnoughIngredientException(e.getKey());
for (var e : recipe.needs().entrySet()) // pass 2: take all
levels.merge(e.getKey(), -e.getValue(), Integer::sum);
checkLowStock();
}
/** Refilling must take the SAME lock — easy to forget. */
synchronized void refill(Ingredient ingredient, int quantity) {
levels.merge(ingredient, quantity, Integer::sum);
capacity.merge(ingredient, 0, (a, b) -> Math.max(a, levels.get(ingredient)));
}
synchronized Map<Ingredient, Integer> snapshot() { return new EnumMap<>(levels); }
void onLowStock(Runnable listener) { lowStockListeners.add(listener); }
private void checkLowStock() {
levels.forEach((ing, level) -> {
if (level < capacity.get(ing) * 0.2) lowStockListeners.forEach(Runnable::run);
});
}
}
class Outlet {
private final int id;
private final Inventory inventory; // shared — NOT a copy
private final RecipeBook recipes;
Outlet(int id, Inventory inventory, RecipeBook recipes) {
this.id = id; this.inventory = inventory; this.recipes = recipes;
}
String brew(String drink) {
Recipe recipe = recipes.find(drink);
inventory.consume(recipe); // <- the only synchronized part: microseconds
pour(recipe); // <- the slow part, OUTSIDE the lock
return "outlet " + id + " served " + recipe.name();
}
private void pour(Recipe recipe) {
try { Thread.sleep(30); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
class CoffeeMachine {
private final Inventory inventory;
private final List<Outlet> outlets = new ArrayList<>();
private final RecipeBook recipes;
CoffeeMachine(int outletCount, Map<Ingredient, Integer> initial, RecipeBook recipes) {
this.inventory = new Inventory(initial);
this.recipes = recipes;
for (int i = 1; i <= outletCount; i++) outlets.add(new Outlet(i, inventory, recipes));
}
Outlet outlet(int i) { return outlets.get(i - 1); }
Inventory inventory() { return inventory; }
}
public class Main {
public static void main(String[] args) throws Exception {
Map<Ingredient, Integer> initial = new EnumMap<>(Ingredient.class);
initial.put(Ingredient.WATER, 500);
initial.put(Ingredient.MILK, 200); // only enough milk for TWO lattes
initial.put(Ingredient.BEANS, 100);
initial.put(Ingredient.SUGAR, 60);
CoffeeMachine machine = new CoffeeMachine(3, initial, RecipeBook.standard());
machine.inventory().onLowStock(() -> { /* a display would light up here */ });
System.out.println("before: " + machine.inventory().snapshot());
// three outlets, three lattes, at the same time — 240ml of milk wanted, 200 available
ExecutorService pool = Executors.newFixedThreadPool(3);
List<Future<String>> results = new ArrayList<>();
for (int i = 1; i <= 3; i++) {
final int id = i;
results.add(pool.submit(() -> machine.outlet(id).brew("latte")));
}
for (Future<String> f : results) {
try { System.out.println(" " + f.get()); }
catch (ExecutionException e) { System.out.println(" refused: " + e.getCause().getMessage()); }
}
pool.shutdown();
System.out.println("after: " + machine.inventory().snapshot());
System.out.println("milk never went below zero because consume() is atomic");
}
}References & further reading
6 sources- Articlegithub.com
awesome-low-level-design — Vending / Coffee machine problems
The closest sibling problem written up in full — useful for seeing exactly where the shared-inventory version diverges.
- Docsdocs.oracle.com
Java synchronized — the Oracle tutorial
What synchronized actually guarantees: mutual exclusion and visibility. Both matter for consume().
- Articleen.wikipedia.org
Check-then-act race conditions
The formal name (TOCTOU) for the bug the prototype demonstrates. Worth being able to name in an interview.
- Book
Java Concurrency in Practice — Goetz et al.
Chapter 2 is exactly this: compound actions on shared state, and why check-then-act must be atomic.
- Docspkg.go.dev
sync.Mutex — Go documentation
The Go sample's locking primitive, with the same “lock the smallest region that works” guidance.
- Articlemartinfowler.com
Prefer configuration over subclassing — Fowler on Value Object
Background for treating a Recipe as an immutable value rather than a class hierarchy of beverages.
Knowledge check
Did it land?
Quick questions, answers revealed on submit. Sign in to save your best score.
question 01 / 08
Three outlets each order a latte needing 80ml of milk, from a tank holding 200ml. With no locking, what happens?
question 02 / 08
Why should consume() check all ingredients before deducting any of them?
question 03 / 08
Should each beverage be its own class?
question 04 / 08
Should the lock be held while the drink is actually brewing?
question 05 / 08
Why is a lock per ingredient usually the wrong choice here?
question 06 / 08
The machine cannot make a latte. What should the error say?
question 07 / 08
How does this problem differ structurally from a vending machine?
question 08 / 08
The interviewer asks for a “low ingredient” warning on a display. What is the cleanest addition?
0/8 answered