Beginner24 min readMachine Coding Practicelive prototype

Coffee Machine

The vending machine's harder sibling. There, each product sat in its own slot. Here, every drink is made from the same tanks — so the moment you have more than one outlet, two customers can both check the milk, both see enough, and both pour. This is the smallest problem that forces you to get a shared resource right.

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.

Water500 Milk200 Beans100 Sugar60 Ingredient Inventory all four tanks together every tank feeds every outlet outlet #1 outlet #2 outlet #3 Outlet 🥛 🍵 Espresso Latte Tea Beverage made by a Recipe: water 60, milk 80, beans 20
Follow the blue arrows. Every tank feeds every outlet — that crossing is the entire problem, and it is why this is not just a vending machine with different pictures.

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

  1. Are recipes data? A class Latte and a class Espresso with identical structure means adding a drink is a code change. A row in a map means it is configuration.
  2. Is check-and-take atomic? The one question the problem exists to ask. Everything else here is bookkeeping.
  3. 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.
  4. 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.
  5. 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.

✗ A CLASS PER DRINK class Espresso { make() { water 50, beans 20 } } class Latte { make() { water 60, milk 80, beans 20 } } class Cappuccino { make() { water 50, milk 60, beans 25 } } same shape three times new drink = new class + edit a factory + a redeploy ✓ RECIPES AS DATA Map<String, Map<Ingredient, Integer>> espresso → { water 50, beans 20 } latte → { water 60, milk 80, beans 20 } cappuccino → { water 50, milk 60, beans 25 } one brew() for every drink new drink = one row of config no code, no redeploy
The test: does the new thing behave differently, or only hold different numbers? Different numbers means data. If someone later adds a drink that needs a step rather than an ingredient — steam the milk, wait 30s — that earns a class.

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:

⚠️ UNGUARDED — everyone checks before anyone takes outlet 1 read milk = 200 ✓ milk −= 80 outlet 2 read milk = 200 ✓ milk −= 80 outlet 3 read milk = 200 ✓ milk −= 80 3 lattes served · milk tank = −40ml · the machine poured what it did not have 🔒 GUARDED — check and take are one indivisible step outlet 1 lock · 200 ≥ 80 · take → 120 outlet 2 lock · 120 ≥ 80 · take → 40 outlet 3 lock · 40 < 80 · REFUSED 2 served, 1 refused milk = 40, never below 0
Run both halves in the prototype and read the milk tank. −40ml versus 40ml — the same order, the same recipes, one lock apart.

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.

the fix, in full
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.

✗ ONE PASS — check and take together, ingredient by ingredient water ok → take beans ok → take milk short → throw water and beans are gone · no coffee made · nobody can undo it ✓ TWO PASSES — check everything, then take everything check water, beans, milk milk short → throw every tank untouched the same rule as the parking lot and the ATM: do everything that can fail before the first thing you cannot undo
Three problems in this phase, one rule: every check that can fail goes before the first irreversible action.

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.

✓ ONE LOCK ON THE INVENTORY ✓ four lines, impossible to deadlock ✓ only the arithmetic is serialised ✓ the 30-second brew stays parallel contention window: microseconds ✗ A LOCK PER TANK outlet 1: lock milk → lock water outlet 2: lock water → lock milk → deadlock, unless you fix an order more machinery, no measurable gain what is actually slow is the BREW — and that happens outside the lock: lock · take ingredients unlock · brew for 30 seconds — all three outlets, at once
Hold the lock only around the ingredient arithmetic, never around the brewing. That is the sentence that answers “isn't one lock a bottleneck?” — and it is true of nearly every shared-resource design. More on the failure modes in Deadlock, race conditions, starvation.

The class diagram

CoffeeMachine + order(outlet, drink) + refill(ingredient, qty) 1..* Outlet + brew(recipe) ALL outlets → the SAME one Inventory «shared resource» - levels : Map<Ingredient,int> + synchronized consume(recipe) check ALL, then take ALL RecipeBook - byName : Map Recipe - name : String - needs : Map<Ingredient,int> Ingredient «enum» WATER · MILK · BEANS · SUGAR one Inventory, many Outlets — that arrow is the whole problem
Compare with Vending Machine: there, each slot owned its own stock, so slots never interfered. Here many outlets point at one inventory, and every hard question follows from that single arrow. Notation: Class diagrams.
Customer Outlet #2 RecipeBook Inventory order(“latte”) find(“latte”) {water 60, milk 80, beans 20} consume(recipe) 🔒 check ALL then take ALL ok — lock released 🥛 (brewed outside the lock)
The lock lives entirely inside 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 LowIngredient event 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 LargeLatte rows. A small function, not new data.
  • “Refilling while people are brewing.”refill() must take the same lock as consume(). 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.

try 01

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.

try 02

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.

try 03

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.

try 04

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.

try 05

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?

try 06

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

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