Appearance
9.7.16 — Coffee Vending Machine
"Design a coffee vending machine. It serves espresso, latte, cappuccino and hot chocolate, and it has several outlets so more than one drink can be made at a time."
This looks like the snack machine from 9.7.3 and it is a different problem, for one reason that changes everything downstream.
A snack machine's inventory is a shelf. Slot A4 holds seven chocolate bars. Selling one is subtracting one from a number that belongs to that slot alone, and nothing else in the machine is affected.
A coffee machine's inventory is a set of tanks that every drink shares. A latte takes 50 ml of water, 30 ml of milk and 18 g of beans. A cappuccino takes the same beans and more milk. There is no "latte stock" to decrement — whether a latte can be made is a question about four tanks at once, and making one changes what every other drink on the menu can still do.
That single difference turns a shelf-counting exercise into a small multi-resource allocation problem, and the rest of this page is the consequences.
1. The recipe is a bill of ingredients
typescript
type IngredientId = "water" | "milk" | "beans" | "cocoa" | "sugar";
interface Recipe {
drink: DrinkId;
needs: ReadonlyMap<IngredientId, Quantity>; // (1)
brewTime: Seconds; // (2)
price: Money;
}
const LATTE: Recipe = {
drink: "latte",
needs: new Map([["water", ml(50)], ["milk", ml(120)], ["beans", g(18)]]),
brewTime: 35,
price: money(2.40),
};(1) A map from ingredient to how much of it the drink consumes. This is the whole model, and it is worth noticing what it is not: there is no Latte class, no Cappuccino extends Coffee, and no subclass per drink. A drink differs from another drink only in its numbers, and differences in data do not justify differences in type (9.2.4). Adding flat white to the menu should be a row in a configuration file, not a deployment.
(2) Brew time belongs to the recipe because it varies per drink, and section 5 is about why a brew taking real time is the most interesting constraint in the machine.
Quantities need a unit-aware type. Milk is millilitres and beans are grams, and a number lets you add them. The same argument as Money in the wallet (9.7.10): make the unit part of the type and a whole family of mistakes becomes impossible to write.
2. Availability is derived, never stored
The most common wrong turn in this problem is a stock field per drink, updated when a drink is sold. It is wrong because it is a second copy of the truth: selling a cappuccino changes how many lattes are possible, so every sale would have to recompute every other drink's count, and any bug leaves the two copies disagreeing.
How many of a drink can be made right now is a question with an exact answer, computed from the tanks.
typescript
class Inventory {
#levels = new Map<IngredientId, Quantity>();
canMake(recipe: Recipe): number { // (1)
let best = Infinity;
for (const [id, needed] of recipe.needs) {
const have = this.#levels.get(id) ?? zero(id);
best = Math.min(best, Math.floor(have.div(needed))); // (2)
}
return best === Infinity ? 0 : best; // (3)
}
}(1) It returns a count rather than a boolean, because the count is what the operational side of the machine needs — "four lattes left" drives the refill alert in section 7, and the boolean is just canMake(r) > 0.
(2) The minimum over ingredients. A drink is limited by whichever tank runs out first, which is the same shape as any bill-of-materials problem: how many bicycles you can build is decided by the scarcest part, not the average.
(3) A recipe with no ingredients would otherwise report infinity. Returning zero for a nonsensical recipe is better than returning a number that will be believed.
This is the cached-versus-derived decision made explicitly, and it is the same one as the wallet's balance: the tank levels are the truth, and everything a customer sees about availability is computed from them. If the menu screen ever needs this to be faster, the answer is a cache with the tanks as its source, not a second authoritative number.
3. The claim: all ingredients or none
typescript
class Inventory {
#lock = new Mutex(); // (1)
async reserve(recipe: Recipe): Promise<Reservation | OutOfStock> {
return this.#lock.withLock(() => { // (2)
for (const [id, needed] of recipe.needs) { // (3) check everything first
if (this.#levels.get(id)!.lt(needed))
return { kind: "outOfStock", missing: id };
}
for (const [id, needed] of recipe.needs) { // (4) then take everything
this.#levels.set(id, this.#levels.get(id)!.minus(needed));
}
return { kind: "reserved", id: newReservationId(), recipe };
});
}
}(1) One lock over the whole inventory, not one per ingredient. That looks coarse and it is the right call, which section 4 argues properly.
(2) The lock covers only the check and the deduction — a few microseconds of arithmetic. It does not cover the thirty-five seconds of brewing.
(3) and (4) are two separate loops on purpose. Check every ingredient before taking any of them. Doing it in one loop means a drink whose fourth ingredient is missing has already had three deducted, and now you must put them back — the partial failure you can simply avoid having.
Why this is the same problem as the three-night hotel booking in 9.7.13: several resources must be claimed together and a partial claim is worse than no claim. There, the rule was that the statement must affect exactly three rows or roll back. Here it is check-all-then-take-all under one lock. Different mechanism, identical requirement, and recognising a shape you have already solved is most of what makes an unseen problem quick.
4. One lock, held briefly, released before brewing
Two outlets brewing at once is where candidates either invent a deadlock or avoid one.
The tempting design is a lock per ingredient, so a hot chocolate and an espresso, sharing nothing but water, do not block each other. It also creates the classic deadlock: outlet one holds milk and waits for beans, outlet two holds beans and waits for milk, and the machine stops forever (9.5.3). The standard fix is to always acquire ingredient locks in a fixed global order, which works and which you should be able to say.
Then say why you are not doing it. The critical section is a handful of map lookups and subtractions. It runs in microseconds. A drink takes thirty-five seconds. Contention on a single lock in a machine with four outlets is not measurable, and the fine-grained version buys nothing while adding a deadlock you must reason about forever. This is the concurrency judgement the question is really testing: the right granularity comes from how long the lock is held, not from how many resources there are (9.5.2).
The rule that actually matters is what the lock does not cover. Reserving is under the lock; brewing is not. If the lock were held for the whole brew, the machine would have four outlets and the throughput of one, and every candidate who describes "lock the inventory, make the drink, unlock" has designed exactly that. Say the boundary explicitly: claim fast, work slow, and never hold a lock across the slow part.
5. Brewing takes time, and time is where the states come from
The snack machine's dispense is a mechanical instant. A brew is thirty-five seconds of grinding, heating and pouring, and anything can happen during it.
typescript
type OutletState =
| { kind: "idle" }
| { kind: "awaitingCup" } // (1)
| { kind: "brewing"; reservation: Reservation; until: Instant } // (2)
| { kind: "ready"; drink: DrinkId; since: Instant } // (3)
| { kind: "faulted"; reservation: Reservation; stage: BrewStage }; // (4)(1) Many machines check that a cup is present before starting, because pouring a latte onto the drip tray wastes ingredients that are already gone. Making it a state rather than a check inside the brew method means the machine can wait for a cup without holding anything.
(2) The brewing state carries its reservation, so whatever happens next, the machine knows exactly what was consumed and for whom.
(3) A finished drink occupies the outlet until it is taken. Machines that skip this state let the next customer's coffee pour into the previous customer's cup.
(4) The failure state carries how far the brew got, and that is the field the whole next section turns on.
The asymmetry that makes this problem interesting: money can be given back, and milk cannot be un-poured.
When a brew fails halfway, the customer gets a refund — that part is easy and non-negotiable. But the ingredients consumed up to the failure are gone. They are not returned to the tanks, because they are physically in the drip tray. So the reservation is not simply released on failure; it is written off, recorded as waste, and the tank levels stay where the reservation left them.
typescript
function onBrewFailure(outlet: OutletState & { kind: "brewing" }, stage: BrewStage) {
refund(outlet.reservation); // (1) money always returns
inventory.writeOff(outlet.reservation, stage); // (2) ingredients do not
return { kind: "faulted", reservation: outlet.reservation, stage };
}(1) Unconditional. A customer who paid and received nothing is refunded regardless of what the machine's own accounting says.
(2) Recording the loss rather than pretending it did not happen is what keeps the tank levels honest. A machine that returns the ingredients to the count on every fault will slowly, silently believe it has more milk than it does, and then it will fail mid-brew again — this time for a customer whose drink it thought was possible.
This is the same discipline as the ATM's journal in 9.7.8: when the physical action cannot be undone, the design records what happened rather than pretending it can be reversed.
6. Customisation, without a class per combination
"An extra shot", "oat milk instead of dairy", "no sugar", "large". Four options with a few settings each produce dozens of drinks, and nobody wants a class for each.
The answer is that an option changes the bill, and the bill is data:
typescript
interface RecipeModifier {
apply(bill: Bill): Bill; // (1)
priceDelta: Money;
}
const EXTRA_SHOT: RecipeModifier = {
apply: (b) => b.add("beans", g(9)).add("water", ml(25)), // (2)
priceDelta: money(0.50),
};
const OAT_MILK: RecipeModifier = {
apply: (b) => b.remove("milk").add("oatMilk", b.amountOf("milk")), // (3)
priceDelta: money(0.40),
};
function finalBill(base: Recipe, mods: readonly RecipeModifier[]): Bill {
return mods.reduce((bill, m) => m.apply(bill), Bill.from(base)); // (4)
}(1) Each modifier takes a bill and returns a new one. Nothing mutates, so applying the same modifier twice is a decision the caller makes rather than an accident.
(2) An extra shot is more beans and more water. That is all it is.
(3) Swapping milk means removing one ingredient and adding the same volume of another. Written this way, the machine will correctly report "no oat milk" when the oat tank is empty even though the dairy tank is full, which is a real behaviour that a boolean usesOatMilk flag would get wrong.
(4) The modifiers fold over the bill in order, producing one final bill that goes to reserve exactly like an unmodified recipe. The reservation code never learns that customisation exists. That is 9.4.8 applied to data rather than to behaviour: each wrapper adds something and the thing underneath is unchanged.
One consequence worth volunteering: because the final bill is computed before reserving, "can I make a large oat latte with an extra shot" is answered by the same canMake as any other drink, on the real ingredients it needs. Designs that check availability against the base recipe and apply options later are the ones that take a customer's money and then discover there is no oat milk.
7. Refills, thresholds, and the number the operator actually wants
The tank levels give an exact answer to the question the person restocking the machine cares about, and it is not "how much milk is left".
It is "how many more drinks can this machine serve before someone has to come out here", and it is the same minimum-over-ingredients calculation from section 2, run across the menu with the expected mix of orders. A machine with plenty of milk and forty grams of beans is about to stop selling coffee, and litres of milk on the dashboard hides that completely.
Two thresholds, not one.
A warning level, which triggers a restock visit and does not change what the machine will sell. It has to be set from how long a visit takes to arrange, not from a round number.
A cut-off level, below which a drink is removed from the menu even though there is technically enough for one more. Serving the last 30 ml of a milk tank produces a bad drink, and the cut-off is where the machine's judgement about quality lives.
A refill is an event, not an assignment. Recording "operator added 2 litres of milk at 14:05" rather than setting the level to 2 litres means the machine's history explains itself: consumption per hour, drinks per refill, and the tank that is quietly leaking because its recorded consumption does not match its recorded refills. The same reasoning as the wallet's ledger in 9.7.10 — record what happened, derive the current value.
8. The states that block brewing, and why they belong in the machine
A real machine spends part of its life unable to make coffee for reasons that have nothing to do with ingredients: it is warming up, it is running a rinse cycle, the grounds container is full, the drip tray needs emptying, a door is open.
Every one of these is a machine-level state, and putting them in the machine's state union rather than as booleans checked inside brew is what stops the impossible combinations existing:
typescript
type MachineState =
| { kind: "warmingUp"; readyAt: Instant }
| { kind: "operational" }
| { kind: "cleaning"; cycle: CleanCycle; until: Instant }
| { kind: "blocked"; reason: "groundsFull" | "trayFull" | "doorOpen" | "noWater" };Cleaning is the one with a design decision in it. A rinse cycle must run after a period of milk use, and it takes a minute during which nothing can be brewed. Two policies are defensible and they should be named as a choice rather than assumed: run it immediately when due, which is safest and annoys a customer standing there; or defer it until the machine has been idle for a moment, with a hard deadline after which it runs regardless. The second is what real machines do, and it is a scheduling policy — which means it is configuration, not a constant buried in a method.
Blocking states must be reported, not just enforced. A machine that silently refuses to serve because its grounds container is full is a machine that stays broken until someone walks past. The state carries its reason so the display can show it and the operator's dashboard can act on it.
9. What the interviewer will push on
"How is this different from a snack vending machine?" The inventory. A snack machine decrements a count that belongs to one slot. A coffee machine's drinks share tanks, so availability is a minimum over ingredients computed from the tank levels, and selling one drink changes what every other drink can do. Any answer with a stock count per drink has missed the question.
"Two outlets, one milk tank. What happens?" Reserve every ingredient or none of it, under one lock, with the check loop separate from the deduct loop so a partial deduction never happens. Then the part they are really listening for: the lock is released before brewing starts. Holding it across a thirty-five-second brew gives a four-outlet machine the throughput of one.
"Why not a lock per ingredient?" Because it creates a deadlock — one outlet holding milk and waiting for beans while another holds beans and waits for milk — which then has to be fixed with a fixed global lock ordering. And because the critical section is microseconds of arithmetic while a drink takes half a minute, so there is no contention to relieve. Granularity follows from how long the lock is held, not from how many resources exist.
"The machine fails halfway through pouring." Refund the money unconditionally, and do not return the ingredients to the tanks — they are in the drip tray. Record the loss as waste against the reservation. A machine that credits ingredients back on every fault drifts into believing it has milk it does not have, and then fails mid-brew for the next customer too.
"Add oat milk, extra shots and three sizes." A modifier transforms the bill of ingredients and returns a new bill; the final bill is computed before anything is reserved, so the reservation code never learns customisation exists. The wrong answer is a class or a flag per combination. The detail that shows you thought it through: swapping to oat milk must move the requirement to the oat tank, so the machine correctly refuses when oat is empty and dairy is full.
"When does the operator get told to come out?" Not on litres of milk. On drinks remaining, which is the same minimum-over-ingredients calculation applied across the menu, with two thresholds — a warning that triggers a visit and a cut-off below which a drink leaves the menu because the last of a tank makes a bad one.
The thing to volunteer that nobody asks for: a refill is an event, not an assignment. Recording "2 litres of milk added at 14:05" rather than setting the level to 2 litres gives you consumption per hour, drinks per refill, and the ability to notice a tank whose consumption does not match what was poured into it — which is how you find a leak, a miscalibrated pump, or someone helping themselves. Candidates model the machine; modelling the machine's history is what makes it operable.
Recall
- A snack machine's inventory is a shelf count per slot. A coffee machine's inventory is shared tanks, so a drink is a bill of ingredients and availability is the minimum over ingredients.
- Never store a per-drink stock count. It is a second copy of the truth; selling one drink changes what every other drink can do. Derive it from the tanks.
- The claim is check every ingredient, then take every ingredient — two loops, one lock, all or nothing. Same requirement as the three-night hotel claim.
- One coarse lock, held only for the reservation. Never across the brew. Per-ingredient locks buy nothing against a microsecond critical section and introduce a deadlock that then needs a global ordering to fix.
- Brewing is a state carrying its reservation. On failure, refund the money and write off the ingredients — they are in the drip tray, not the tank. Crediting them back makes the machine believe in milk it does not have.
- Customisation is a modifier over the bill, folded into a final bill before reserving, so the reservation code never learns options exist. Swapping to oat milk must move the requirement to the oat tank.
- The operator's number is drinks remaining, not litres. Two thresholds: a warning that triggers a visit and a cut-off where quality fails before the tank empties.
- A refill is an event, not an assignment — that is what makes consumption, waste and leaks visible.
- Warming up, cleaning, grounds full and door open are machine states, not booleans inside
brew, and each carries its reason so it can be displayed and alerted on.
Self-test: Why can there be no stock count per drink? What are the two loops in the reservation for? What must the lock not cover? What happens to ingredients when a brew fails? Where do "extra shot" and "oat milk" live? What number does the restocking operator actually need?
Quiz Bank
FoundationalModel the inventory of a coffee machine and explain why a stock count per drink is wrong.
Start from what makes this machine different from a snack machine, because the whole model follows from it. A snack machine holds seven chocolate bars in slot A4, and selling one subtracts one from a number belonging to that slot alone. Nothing else in the machine changes.
A coffee machine holds tanks — water, milk, beans, cocoa, sugar — and every drink draws from several of them. A latte takes 50 ml of water, 120 ml of milk and 18 g of beans. A cappuccino takes the same beans and different milk. There is no such thing as "latte stock".
So a drink is a bill of ingredients:
typescript
interface Recipe {
drink: DrinkId;
needs: ReadonlyMap<IngredientId, Quantity>;
brewTime: Seconds;
price: Money;
}Quantities carry their unit in the type, because millilitres and grams must never be addable to each other — the same argument as a Money type in any system that handles currency.
And availability is computed, never stored:
typescript
canMake(recipe: Recipe): number {
let best = Infinity;
for (const [id, needed] of recipe.needs) {
best = Math.min(best, Math.floor(this.level(id).div(needed)));
}
return best === Infinity ? 0 : best;
}The minimum over ingredients, because a drink is limited by whichever tank runs out first. This is the bill-of-materials shape in general: how many bicycles you can build is decided by the scarcest part.
Why a per-drink stock count is wrong, stated precisely. It is a second copy of information that already exists in the tanks. The two copies can disagree, and they will, because selling one cappuccino changes the possible number of lattes, hot chocolates and flat whites all at once. Keeping them in step means recomputing every drink's count on every sale, which is exactly the canMake calculation — so the stored count is doing no work while creating a way to be wrong.
The general rule this is an instance of: store the facts, derive the summaries. The tanks are the facts. Availability, the menu, and the "four lattes left" figure are all derived. If a derived value ever needs to be faster, cache it from the facts rather than promoting it to a second truth — the same relationship as a cached balance and its ledger.
One extra to volunteer. Returning a count rather than a boolean from canMake costs nothing and is what the operational side of the machine needs: the restock alert wants "how many drinks left", not "can we still make one". The boolean is just the count being greater than zero.
AppliedTwo outlets can brew at the same time and they share the milk and bean tanks. Design the concurrency.
Name the shape first: this is multi-resource allocation with a long, slow action attached to it. Several ingredients must be claimed together, and then something takes thirty-five seconds. Those two facts drive every decision here.
The reservation is all-or-nothing, in two loops:
typescript
async reserve(recipe: Recipe): Promise<Reservation | OutOfStock> {
return this.#lock.withLock(() => {
for (const [id, needed] of recipe.needs)
if (this.level(id).lt(needed)) return { kind: "outOfStock", missing: id };
for (const [id, needed] of recipe.needs)
this.deduct(id, needed);
return { kind: "reserved", id: newReservationId(), recipe };
});
}The loops are separate on purpose. Checking and deducting in one pass means a recipe whose fourth ingredient is short has already had three deducted, so you must now write compensation code to put them back. Two loops make that failure impossible rather than recoverable, and impossible is always the better of the two.
Then the two decisions that the question is actually about.
One lock over the whole inventory, not one per ingredient. The fine-grained version is the obvious optimisation and it is wrong here for two reasons. It creates a deadlock: outlet one holds milk and waits for beans while outlet two holds beans and waits for milk. That is fixable with a fixed global acquisition order, and you should say so to show you know the standard cure. But it is fixing a cost that does not exist — the critical section is a few map lookups and subtractions, measured in microseconds, on a machine with four outlets. There is no contention to relieve, so the fine-grained design pays a permanent reasoning cost for nothing.
The lock is released before brewing. This is the sentence that matters most. Reserve under the lock, then brew outside it. A design that locks the inventory, makes the drink and then unlocks has serialised the whole machine, and a four-outlet machine now performs like a one-outlet machine. Anyone who has held a database transaction open across an HTTP call has made the same mistake at a larger scale.
Two failure paths to complete the design.
Cancelled before brewing starts. The reservation is released and the ingredients go back to the tanks, because nothing has been poured.
Failed during brewing. The money is refunded and the ingredients are not returned — they are physically in the drip tray. The reservation is written off as waste and the tank levels stay where the reservation put them. Returning them would make the machine believe in milk it does not have, and the next customer would hit the same failure.
The transferable rule, worth saying explicitly because it applies far outside coffee machines: claim fast, work slow. The right lock granularity comes from how long the lock is held rather than from how many resources are behind it, and the first thing to check in any design with a lock is what expensive work is happening inside it.
InterviewA latte is half poured and the pump fails. Walk through exactly what the system does.
Start with the asymmetry, because it is the whole answer: money can be returned, and milk cannot be un-poured. Every step follows from that.
The state already holds what is needed. The outlet was in { kind: "brewing"; reservation; until }, and the reservation records exactly which ingredients were taken and in what quantities. Nothing has to be reconstructed or guessed.
Step one: refund, unconditionally. The customer paid and received nothing. This is not conditional on the machine's own accounting agreeing, and it is not deferred to an operator. A machine that keeps money for an undelivered drink is a machine that generates complaints faster than any efficiency it gains.
Step two: write off the ingredients, do not release them.
typescript
refund(outlet.reservation);
inventory.writeOff(outlet.reservation, stage);The ingredients consumed are in the drip tray. Crediting them back to the tanks makes the machine's recorded milk level higher than the real one, and the error compounds: the machine then offers a drink it cannot make, fails again, credits back again, and drifts further. Recording the loss as waste keeps the levels honest, and it also gives the operator a number worth having — waste per day is how you notice a pump that is failing intermittently before it fails permanently.
Step three: enter a fault state that carries the stage. How far the brew got determines what happens next. A failure during grinding leaves a clean machine and it may be able to serve the next customer. A failure mid-pour leaves milk in the group head, which usually means the machine must run a rinse cycle before anything else, and possibly that it must stop selling milk drinks until someone attends.
Step four: report it. The fault state carries its reason so the display can tell the customer what happened, and the operator's dashboard can see it without anyone walking past. A silent fault is a machine that stays broken all weekend.
The design principle underneath, and the reason this question is asked: when a physical action cannot be undone, record what happened instead of pretending to reverse it. The ATM does the same thing — it writes its journal entry before the shutter opens, because no ordering of two steps survives a power cut, so the design is made recoverable rather than atomic. Candidates reach for a transaction that rolls everything back, and there is no such thing when the milk is already in the cup.
One thing to volunteer. There is a genuinely ambiguous case: the pump fails at the very last moment and the customer may or may not have a usable drink. Machines resolve this the same way the ATM resolves a disputed dispense — refund, record everything, and let the difference be settled by a human looking at the evidence, because the machine cannot see the cup. Saying that some cases are not resolvable in software, and designing for the evidence rather than for a guess, is a strong finish.
StaffA chain runs three thousand of these machines across a country. Design the fleet side: telemetry, restocking, remote menu changes and predicting failures.
Set the boundary first, and it is the same one as the traffic signal: the machine must work with no network. Payment may need a connection, brewing must not. A machine that will not serve coffee because a server is unreachable is worse than a machine with no network features at all. So the fleet layer is additive — it observes, it advises, and it never sits in the path of making a drink.
What each machine sends up, and what shape it is. The important decision is that machines report events, not current state: "poured 120 ml of milk for reservation X at 14:03", "operator added 2 litres at 14:05", "brew failed at pour stage". A stream of events reconstructs any snapshot, and it answers questions nobody thought to ask when the machine was built. A machine that reports only its current tank levels can never tell you why they moved.
Telemetry is high volume and loss-tolerant. Losing one event costs a small inaccuracy that the next refill reconciles, so it goes over a path optimised for throughput rather than for exactly-once delivery, and each event carries its own identifier so duplicates from retries can be dropped (10.4).
Restocking is a routing problem fed by prediction, and this is where the money is. Each machine's consumption per hour, per day of week, is enough to forecast when it drops below its warning threshold. Turn those forecasts into a route: visit the machines that will run out before the next possible visit, not the ones that are lowest today. That distinction is the whole value — a machine at 20% that sells four drinks a day does not need a visit, and a machine at 60% next to a station at eight in the morning does.
And the number driving all of it is drinks remaining, not litres, because a machine with full milk and no beans sells nothing while looking healthy on any tank-level dashboard.
Remote configuration — menus, prices, recipes — is the operation that can break the fleet, so it gets the careful treatment. Low volume, must not be lost, must be exactly ordered per machine. Every machine validates a configuration before accepting it, keeps the previous one, and can revert with a single instruction. Rollout is staged rather than fleet-wide, because a recipe with a wrong quantity pushed to three thousand machines at once is three thousand incidents. And a configuration change takes effect between drinks, never during one.
Predicting failures is the part that pays for the telemetry. Three signals are available without any new hardware, and each has a plausible cause:
Rising brew times for the same recipe usually means a grinder wearing or a filter clogging.
Waste events climbing — those written-off reservations from failed brews — mean an intermittent pump long before it fails outright.
Consumption not matching refills means a leak, a miscalibrated pump, or someone taking stock. This one is only detectable because refills are recorded as events rather than as assignments, which is the design decision from the single-machine page paying off at fleet scale.
Two things I would insist on.
The fleet layer must never be able to make a machine unsafe or unsellable. Its worst possible bug should be bad advice — a wasted visit, a stale menu — never a machine that refuses to brew. That is the same division of authority as a signal policy suggesting a duration while the controller clamps it.
Alert on the trend, not the threshold. A single machine in a fault state is a ticket. Twenty machines showing rising brew times on the same model in the same month is a fleet problem, and the second one is invisible if every alert is evaluated per machine.
And the number I would watch above everything else: machines that are technically online and selling nothing, because they are out of one ingredient. Every individual signal about them looks fine — the machine is up, the network is up, no faults — and it is the state in which a machine earns nothing while appearing perfectly healthy.
Flashcards
FlashWhy it is not a snack machine
Snack inventory is a shelf count per slot. Coffee inventory is shared tanks, so a drink is a bill of ingredients and availability is the minimum over ingredients. Selling one drink changes what every other drink can do.
FlashThe reservation, in two loops
Check every ingredient, then take every ingredient. One loop means a partial deduction when the fourth ingredient is short, which then needs compensation code. Two loops make that impossible instead of recoverable.
FlashWhat the lock must not cover
The brew. Reserve under the lock in microseconds, then release it and brew for thirty-five seconds. Holding it across the brew gives a four-outlet machine the throughput of one.
FlashFailed brew
Refund the money unconditionally; write the ingredients off as waste. They are in the drip tray, not the tank. Crediting them back makes the machine believe in milk it does not have.
FlashCustomisation
A modifier transforms the bill of ingredients; modifiers fold into a final bill before reserving, so the reservation code never learns options exist. Oat milk must move the requirement to the oat tank.
FlashThe operator's number
Drinks remaining, not litres. Two thresholds: a warning that triggers a visit, and a cut-off where the last of a tank makes a bad drink. Refills are recorded as events, which is how leaks become visible.
Next: 9.7.17 — task management, where the workflow itself is configuration and every customer wants a different one.