Appearance
9.7.3 — Vending Machine
"Design a vending machine: it takes coins, you pick a product, it dispenses it and gives change."
Someone puts £1.50 into a machine, presses the button for a £1.20 drink, and the machine has to hand back 30p. It holds plenty of coins. It still cannot pay, because everything it holds is in 50p pieces. The drink has already dropped.
That situation is the entire problem. A vending machine is a small state machine wrapped around one rule that must never break, and almost every question an interviewer asks is a way of testing whether you found the rule and put it somewhere it can be checked.
The rule: in every path this machine can take, the money a customer inserted either becomes a product plus the correct change, or comes back in full. There is no third outcome. Not "the machine keeps 30p", not "the customer gets a drink they did not pay for", not "the money is stuck inside until an engineer visits."
1. The states, and why each one holds different data
typescript
type MachineState =
| { kind: "idle" } // (1)
| { kind: "collecting"; inserted: Money } // (2)
| { kind: "dispensing"; slot: SlotCode; paid: Money } // (3)
| { kind: "returning"; toReturn: Money; reason: ReturnReason }; // (4)
type ReturnReason =
| "cancelled" | "outOfStock" | "cannotMakeChange" | "fault" | "change"; // (5)
type Event =
| { type: "insert"; denom: Denomination }
| { type: "select"; slot: SlotCode }
| { type: "dispensed" } // (6)
| { type: "cancel" }
| { type: "fault"; detail: string };(1) Idle holds nothing, because the machine holds none of the customer's money. That emptiness is meaningful: if the state is idle, nobody is owed anything.
(2) Collecting is the only state that holds money the machine has not earned. Every other state either has no customer money or has already decided what will happen to it. Knowing there is exactly one such state is what makes the rule checkable.
(3) Dispensing carries the slot and what was paid, because the change is computed from both when the product actually lands. Carrying only the slot would mean looking up how much was inserted from somewhere else, and "somewhere else" is where money goes missing.
(4) Returning carries the amount and the reason. The reason is not decoration: the display shows something different for "out of stock" than for "I cannot make change", and the sales log needs to tell a refund apart from ordinary change.
(5) Five reasons, and notice that change is one of them. Handing back change and refunding a cancelled purchase are the same physical action — coins come out of the same hopper — so they are the same state with different reasons rather than two states that do the same thing.
(6) dispensed is an event from the hardware, not from the customer. The machine does not know the product has fallen until a sensor says so, and section 5 is about why that distinction matters.
This is the discriminated union spelling of the State pattern from 9.4.14, chosen over one object per state because the interesting part here is the data each state carries, not the behaviour. Three of the four states hold something the others do not, and a union makes it impossible to read inserted while idle.
2. One function changes the state, and the rule is provable by reading it
typescript
class VendingMachine {
#state: MachineState = { kind: "idle" };
constructor(
private inventory: Inventory, // (1)
private register: CashRegister, // (2)
private hardware: HardwarePort, // (3)
) {}
handle(e: Event): MachineState {
this.#state = this.#transition(this.#state, e); // (4) the only writer
return this.#state;
}
#transition(s: MachineState, e: Event): MachineState {
switch (s.kind) {
case "idle":
if (e.type === "insert")
return { kind: "collecting", inserted: this.register.accept(e.denom) };
return s; // (5)
case "collecting": {
if (e.type === "insert")
return { ...s, inserted: s.inserted.plus(this.register.accept(e.denom)) };
if (e.type === "cancel")
return { kind: "returning", toReturn: s.inserted, reason: "cancelled" };
if (e.type === "select") {
const price = this.inventory.priceOf(e.slot);
if (!this.inventory.hasStock(e.slot)) // (6)
return { kind: "returning", toReturn: s.inserted, reason: "outOfStock" };
if (s.inserted.lt(price)) return s; // (7)
const change = s.inserted.minus(price);
if (!this.register.canPay(change)) // (8)
return { kind: "returning", toReturn: s.inserted, reason: "cannotMakeChange" };
this.inventory.reserve(e.slot); // (9)
this.hardware.dispense(e.slot);
return { kind: "dispensing", slot: e.slot, paid: s.inserted };
}
return s;
}
case "dispensing": {
if (e.type === "dispensed") { // (10)
const change = s.paid.minus(this.inventory.priceOf(s.slot));
this.register.commitSale(s.paid, change);
return change.isZero()
? { kind: "idle" }
: { kind: "returning", toReturn: change, reason: "change" };
}
if (e.type === "fault") { // (11)
this.inventory.release(s.slot);
return { kind: "returning", toReturn: s.paid, reason: "fault" };
}
return s;
}
case "returning": // (12)
return e.type === "dispensed" ? { kind: "idle" } : s;
}
}
}(1), (2) and (3) Three collaborators, each owning one thing. Inventory owns what is in the slots, the register owns the coins, and the hardware port owns everything physical. None of them decides what happens next — that is this function's job alone.
(4) One assignment to #state, in one method. Everything about the machine's behaviour is in the function below it, which is what makes the money rule something you can verify by reading rather than by testing.
(5) Pressing a product button with no money inserted does nothing, and "does nothing" is written as return s. Every state ends this way, so an unexpected event is ignored rather than crashing a machine that is standing in a corridor with somebody's money in it.
(6) Stock is checked first, before anything else about the sale, because it is the cheapest refusal and the one that needs no arithmetic.
(7) Not enough money is the only case that stays in collecting. The customer has not made a mistake — they are part-way through inserting coins — so the machine waits and the display shows how much more is needed.
(8) The change check happens here, before anything is dispensed, and section 3 is entirely about this line.
(9) Stock is reserved and the hardware is told to move, in that order. Reserving after telling the motor to turn means a brief window where the product is physically leaving while the count still says it is there, and in a networked machine that window is a sale that never gets counted.
(10) The sale is committed only when the hardware confirms the product actually fell. Committing at the moment the button was pressed would mean a jammed machine records a sale it never made.
(11) The fault path is the whole design in three lines: the reservation is undone, and all the customer's money goes back. Nothing is kept, nothing is half-done. This is compensation — you cannot un-turn a motor, so you make up for it with an opposite action.
(12) Returning waits for the hardware to say the coins have come out, then goes idle. It never returns to collecting, because coins in the return tray belong to the customer.
Now read the rule off the code. Follow every arrow out of collecting: cancel goes to returning with everything; out of stock goes to returning with everything; cannot make change goes to returning with everything; a successful sale goes to dispensing, which either commits and returns exactly the change, or faults and returns everything. There is no path where money stays inside the machine and the customer walks away with nothing. That proof took one function to read, and that is the argument for putting the whole state machine in one place.
3. Change: the question is not "how much" but "can I actually pay it"
The machine owes 30p. It holds £4.50. It cannot pay.
typescript
class CashRegister {
private float = new Map<Denomination, number>(); // (1)
canPay(amount: Money): boolean {
return this.planFor(amount) !== null; // (2)
}
planFor(amount: Money): Map<Denomination, number> | null {
const plan = new Map<Denomination, number>();
let left = amount.minorUnits;
for (const d of DENOMS_DESCENDING) { // (3)
const take = Math.min(Math.floor(left / d.value), this.float.get(d) ?? 0);
if (take > 0) { plan.set(d, take); left -= take * d.value; }
}
return left === 0 ? plan : null; // (4)
}
}(1) A count for every denomination, never a single total. This one field is what makes the whole question answerable.
(2) "Can I pay?" is answered by trying to build a plan. There is no cheaper correct way, and the plan is needed a moment later anyway, so nothing is wasted.
(3) Work downwards from the largest coin, taking as many as both the amount and the drawer allow. This is the greedy approach, and it is correct for ordinary coin systems — 1p, 2p, 5p, 10p, 20p, 50p, £1 — because each denomination divides neatly into the ones above it.
(4) If anything is left over, no plan exists and the answer is no.
Greedy is not correct in general, and you should say so before being asked. Take a machine stocked with 25p, 10p and 4p pieces, owing 41p. Greedy takes a 25, then a 10, then cannot make 6 from 4p pieces, and gives up — but 41 is exactly ten plus ten plus ten plus four plus four plus four minus… it is not, and that is the point of a real counterexample. Use 1p, 3p and 4p pieces owing 6p: greedy takes a 4 and then needs 2, which cannot be made from 3s, so it takes two 1s and uses three coins. Two 3s would have done it in two. When the denominations are unusual, or when the machine is nearly out of a coin, greedy can fail to find a plan that exists at all, and the correct answer is the coin-change dynamic-programming algorithm bounded by the counts actually in the drawer. Naming the boundary — greedy for standard coins, dynamic programming otherwise — is worth more than implementing either.
The float is updated by the sale, which is why "exact change only" never has to be a mode. commitSale(paid, change) adds the inserted coins and removes the ones handed back. As the small coins drain, canPay starts refusing more transactions on its own, and the display can show which purchases are still possible. Nobody has to write a rule for it; the behaviour falls out of the counting.
One consequence that catches people out: the coins a customer just inserted are available for their own change. Someone paying for a £1.20 drink with a 50p and a £1 has handed the machine a 50p it can immediately give back as part of 30p — no, it cannot, and that is the honest version. It can use them, and whether it should is a decision: counting them makes more sales possible, and it means a fault after acceptance has to put back specific coins rather than the ones it took. Most machines accept them into the float and return equivalent value, and stating that equivalence explicitly is the difference between a design and a hope.
4. Ordering, and the moment nothing can save you
Every step is arranged so that the unrecoverable state cannot be reached.
Check stock before taking a selection. Cheap and prevents a pointless reservation.
Check change before dispensing. This is the ordering that matters. Once the product has dropped, the machine owes money it may not have, and there is no move left — the product cannot be un-dispensed, and the customer is standing there. Checking while the money is still in collecting keeps the failure recoverable, because a full refund is always available.
Reserve stock before telling the motor to move. The count must never say a product is present while it is physically leaving.
Commit the sale only when the hardware confirms. A jam must not be recorded as a sale.
Then the case that has no clean answer: the power fails mid-dispense. The motor has turned, and the machine does not know whether the product fell. This is the same shape as the ATM's dispense in 9.7.8, and the answer is the same: write down what you are about to do before you do it. A journal entry — slot, amount paid, change owed, timestamp — is written before the motor is told to turn. On restart the machine reads the journal, finds an entry with no completion, and knows exactly one thing happened that it cannot resolve alone.
What it does then is a policy decision that should be stated rather than invented: refund the money and flag the slot for a human to check, which favours the customer and costs the operator one product occasionally. The alternative — assume the product was delivered — favours the operator and produces complaints that are impossible to disprove. The design contribution is not choosing; it is making the ambiguity visible and small, so that a human resolves one flagged slot rather than a customer arguing with a machine.
5. The hardware is behind a port, and everything is an event
typescript
interface HardwarePort { // (1)
dispense(slot: SlotCode): void; // (2)
returnCoins(amount: Money): void;
display(message: DisplayMessage): void;
}(1) One boundary between the decision-making and the physical world. Everything the machine can do is here; everything it can learn arrives as an event.
(2) dispense returns nothing and does not wait. It asks the motor to turn, and that is all. The product falling — or not falling — comes back later as dispensed or fault.
This is the choice that makes the design usable, and it is worth defending. The alternative is a blocking call: const ok = hardware.dispenseAndWait(slot). It reads more simply and it is wrong in three ways. A jammed motor blocks the whole machine, including the cancel button. A sensor that reports late has nowhere to report to. And the machine can no longer be driven by a recorded script of events, which means every change has to be checked on physical hardware.
Events also settle the only real concurrency here. A single machine has one customer, but the software still has several sources of activity — button presses, coin sensors, the dispense sensor, a timeout — arriving whenever they like. They all go through handle, one at a time, which is the actor shape from 9.5.4. There is no lock anywhere in this design, because there is only one thing that writes state and it processes one event at a time.
A fleet is a different question, and it is the one an interviewer moves to. A hundred machines reporting to a central system means the stock count has two writers: the machine, and whatever the dashboard does. The rule is the same as everywhere else in this chapter — the physical machine owns its own stock, because it is the only thing that can be right about what is in a slot, and the central system is a mirror that may be a few minutes behind. Trying to make the cloud authoritative over a slot produces a machine that refuses to sell a product it can see.
6. Where the changes land
Three requests arrive in every version of this interview, and a good design says where each lands before it is asked.
Card and phone payment is a second way to collect, so it becomes a payment channel beside the coin register. The state machine changes in exactly one way, and it is a real change rather than a seam: paying by card is asynchronous, so collecting gains a waiting form — money is not in the machine, an authorisation is pending, and a timeout must be part of the state. Two consequences follow. A timeout with nothing captured is a clean return to idle, with no refund needed, which is worth pointing out because it is one of the few genuinely simple paths in this problem. And an authorisation arriving after the timeout is a real event with real money attached, so it has to trigger a refund through the channel, and duplicate confirmations must be handled by the authorisation identifier — which is the idempotency key idea from 9.6.3 arriving in a machine with no network in the room.
Different prices at different times is a pricing rule per slot rather than a number, and it touches nothing else, because the transition function already asks priceOf(slot) instead of reading a field.
Telling head office what sold is a listener on the transitions. It must never be on the path of the sale: a machine in a building with poor signal has to keep selling, so the sale completes locally and the report leaves when it can. Putting the report inside the transition would make a network the machine cannot reach into a reason it cannot sell a drink.
7. What the interviewer will push on
"Where does the money rule live?" In one transition function, and it is provable by reading it: every path out of collecting ends in a product plus exact change, or a full return. The tell is whether the candidate can point at the proof rather than assert the property. A design with state changes scattered across several methods cannot.
"Why check the change before dispensing?" Because after the product falls there is no move left. The machine owes money it may not hold and it cannot un-dispense. Checking while the money is still in collecting keeps the failure recoverable, since a full refund is always available.
"The machine holds £4.50 and cannot give 30p." Because change is a question about which coins are in the drawer, not about a total. The register tracks a count per denomination and answers whether the amount can be composed from what it physically has. Then the follow-up worth pre-empting: greedy composition is correct for standard coin systems and can fail on unusual ones, where the answer is bounded coin-change dynamic programming.
"What happens when the power cuts mid-dispense?" A journal entry written before the motor turns, so restart finds exactly one unresolved event. Then a stated policy — refund and flag the slot, favouring the customer — rather than a guess. The design contribution is making the ambiguity small and visible, not eliminating it, because two physical actions cannot be made atomic.
"Why is dispense fire-and-forget?" Because a blocking call lets a jammed motor freeze the cancel button, gives a late sensor nowhere to report, and makes the machine impossible to drive from a recorded script of events. Everything the machine learns arrives as an event; everything it does goes through one port.
"Add card payment." A second payment channel, plus one honest change to the state machine: an asynchronous waiting state with a timeout. Then the two cases people miss — a timeout with nothing captured needs no refund, and an authorisation landing after the timeout does, keyed by the authorisation identifier so duplicate confirmations are harmless.
The thing to volunteer that nobody asks for: the coins the customer just inserted are part of the drawer, so whether they count towards their own change is a decision with consequences. Counting them makes more sales possible and means a fault must return equivalent value rather than the specific coins taken. Candidates model the sale; noticing that the machine's ability to pay changes during the transaction itself is what shows you thought about the drawer as a live thing rather than a number.
Recall
- The rule: every path ends with the money becoming a product plus exact change, or coming back in full. No third outcome.
- One state holds uncommitted money (
collecting), so the rule has one place to be checked. - One transition function is the only writer. The rule is provable by reading it, which is why the single-gate shape wins.
- Unexpected events return the state unchanged rather than crashing a machine holding somebody's money.
- Check change before dispensing. After the product drops there is no move left.
- Change is a composition question, not a total. £4.50 in 50p pieces cannot pay 30p, so the register counts per denomination.
- Greedy composition is correct for standard coin systems; unusual ones need bounded coin-change dynamic programming.
- The float updates on every sale, so "exact change only" emerges instead of being a mode somebody codes.
- Reserve stock before the motor turns; commit the sale only when the sensor confirms. A jam must not be a recorded sale.
- Fault mid-dispense = compensation: release the reservation, return everything.
- Power cut = a journal written before the motor turns, then a stated policy — refund and flag the slot. Make the ambiguity small and visible, not absent.
- Hardware sits behind a port and never blocks. Everything learned arrives as an event, so one jam cannot freeze the cancel button.
- One event at a time through
handlemeans no locks anywhere. - In a fleet, the machine owns its own stock; the central system is a mirror.
- Card payment adds an asynchronous waiting state with a timeout, plus a refund path for an authorisation that lands late.
Self-test: Which state holds money the machine has not earned? Why is the change check before the dispense? Why is a total not an answer? What is written before the motor turns, and why? What breaks if dispense blocks? Which twist genuinely changes the state machine?
Quiz Bank
FoundationalDesign the state machine and show that the money rule holds on every path.
The states, with the data each one carries:
typescript
type MachineState =
| { kind: "idle" }
| { kind: "collecting"; inserted: Money }
| { kind: "dispensing"; slot: SlotCode; paid: Money }
| { kind: "returning"; toReturn: Money; reason: ReturnReason };collecting is the only state holding money the machine has not earned. That is the single most useful property in the design, because it means the rule has exactly one place to be checked rather than being spread across the whole machine.
dispensing carries what was paid as well as the slot, so the change is computed from data the state already holds. If it carried only the slot, the amount would have to be fetched from somewhere else, and "somewhere else" is where money goes missing during a refactor.
returning carries a reason because handing back change and refunding a cancelled purchase are physically identical — the same coins from the same hopper — but mean different things to the display and to the sales log.
Now the proof, which is the actual answer to the question. Follow every arrow leaving collecting:
Cancel → returning with inserted. Everything comes back.
Out of stock → returning with inserted. Everything comes back.
Not enough money → stays in collecting. Nothing has been decided, the customer keeps inserting, and the money is still theirs.
Cannot make change → returning with inserted. Everything comes back.
A valid sale → dispensing. From there, exactly two arrows: dispensed commits the sale and routes any change to returning, or fault releases the reservation and routes the whole amount to returning.
There is no arrow anywhere that leaves money inside the machine while the customer walks away empty-handed. That is the proof, and it took one function to read. Which is the real argument for the single-gate shape: the property that matters most has a one-function argument instead of an appeal to careful coding.
Two smaller decisions that hold it together.
Unexpected events return the state unchanged. Pressing a product button with no money does nothing. This is written explicitly rather than left to fall through, because the alternative is an exception thrown by a machine standing in a corridor with somebody's money inside it.
The sale is committed on the sensor, not the button. commitSale runs when the hardware confirms the product fell. Committing when the button was pressed would record sales for jams, which corrupts both the money ledger and the stock count at the same time.
And the shape is chosen deliberately. This is the State pattern from 9.4.14 spelled as a discriminated union rather than one class per state, because what varies between these states is the data they carry rather than the behaviour they provide. The union makes reading inserted while idle a compile error, which is a stronger guarantee than a convention that nobody does it.
AppliedThe machine holds £4.50 and cannot give 30p change. Explain why, implement the check, and say where greedy fails.
Why: because "do I hold enough money" is not the question. The £4.50 is nine 50p coins. Thirty pence cannot be composed from 50p pieces, so the machine holds fifteen times what it owes and is unable to pay a penny of it. Change is a question about which coins are in the drawer, not about a total.
So the register counts per denomination:
typescript
class CashRegister {
private float = new Map<Denomination, number>();
canPay(amount: Money): boolean { return this.planFor(amount) !== null; }
planFor(amount: Money): Map<Denomination, number> | null {
const plan = new Map<Denomination, number>();
let left = amount.minorUnits;
for (const d of DENOMS_DESCENDING) {
const take = Math.min(Math.floor(left / d.value), this.float.get(d) ?? 0);
if (take > 0) { plan.set(d, take); left -= take * d.value; }
}
return left === 0 ? plan : null;
}
}"Can I pay?" is answered by building the plan. There is no cheaper correct method, and the plan is needed moments later when the coins are actually dispensed, so nothing is computed twice.
The Math.min is the whole difference from a textbook coin-change function. It bounds each denomination by what the drawer actually contains. A function that assumes unlimited coins will confidently report that 30p is payable from a drawer holding only 50p pieces.
Where greedy fails. Taking the largest coin that fits, repeatedly, is correct when every denomination divides neatly into the ones above it — which is true of 1p, 2p, 5p, 10p, 20p, 50p and £1, and of most real currencies, because currencies are designed that way. It is not true in general.
The clean counterexample uses 1p, 3p and 4p pieces owing 6p. Greedy takes a 4, needs 2, cannot use a 3, and takes two 1s — three coins. Two 3p coins would have done it in two. Change the drawer contents slightly and greedy does not merely use more coins, it fails to find a plan that exists: with two 3p coins and no 1p coins at all, greedy takes the 4p, cannot make 2p, and reports that 6p is unpayable while holding exactly the right coins.
The correct general answer is coin-change dynamic programming bounded by the counts in the drawer. It is not needed for a machine handling ordinary money, and saying why it is not needed — that standard denominations have the property greedy relies on — is a better answer than either implementing it or ignoring it.
The float updates on every sale, and that gives one behaviour for free. commitSale(paid, change) adds the inserted coins and removes the dispensed ones. As small coins drain out, canPay refuses more and more purchases on its own, and the display can show which selections are still possible. "Exact change only" is not a mode anyone writes — it is what happens when the drawer runs low, and it appears and disappears by itself.
One detail worth raising unprompted. The coins the customer just inserted are in the drawer, so they can be used for that customer's own change. Counting them makes more sales possible, and it means that a fault after acceptance has to return equivalent value rather than the specific coins taken, because those coins may already have been handed to somebody. Machines do it this way, it is fine, and stating the equivalence explicitly is what turns an assumption into a design.
InterviewThe power fails after the motor turns but before the product falls. What does your design do, and what can it not do?
Start with the honest part, because it is the point of the question. Two physical things happen here — a motor turns and money changes hands — and no ordering of them is safe. Dispense first and a power cut costs the operator a product with no payment recorded. Record first and a power cut takes a customer's money with no product. You cannot make two physical actions atomic, so the design does not try. It makes the situation recoverable and small instead.
The mechanism is a journal entry written before the motor turns:
typescript
journal.append({
id: txnId, slot, paid, changeOwed, at: now(), phase: "dispensing",
});
hardware.dispense(slot);Before, not after. On restart the machine reads its journal, finds an entry whose phase is dispensing with no completion, and knows exactly one thing: it was in the middle of a sale and does not know how it ended. That is a far better position than the alternative, which is a machine with no record at all and a customer standing in front of it.
Then a stated policy, and the important thing is that it is stated. The usual choice is to refund the money and flag the slot for a human — favouring the customer, costing the operator a product occasionally, and putting one flagged slot in front of a person who can look inside it. The alternative, assuming the product was delivered, favours the operator and produces complaints that nobody can disprove. Either is defensible; having no rule is not, because then the behaviour depends on whichever line of code ran last.
What narrows the ambiguity further is the sensor. A machine with a delivery sensor can often resolve this by itself: if the sensor logged a product crossing the chute before the power died, the sale completed. If it did not, the product is still in the slot. So the journal records sensor events too, and the unresolvable cases shrink to the genuinely ambiguous ones — the power failing during the fall. Reducing an unresolvable class to a rare one is usually the best available outcome, and saying so is more useful than claiming the problem is solved.
One thing the design must not do, and it is the common wrong answer: retry the dispense on restart. The product may already be in the tray, and the machine cannot tell. A retry can give away two products for one payment, and — worse — it does so silently, so the operator finds out from a stock count weeks later.
This is the same shape as the ATM in 9.7.8, and the repetition is the lesson. Any time software has to make a physical thing happen, the pattern is: write your intention down before acting, resolve what you can from sensors on restart, and hand the rest to a human with enough information to settle it. It appears again in 9.7.16, where a failed brew cannot un-pour the milk, and the answer is once more a refund plus a written-off ingredient rather than an attempt to undo physics.
And it is worth naming what makes all of this possible: the state machine already carries paid and slot in the dispensing state. The journal is a copy of a state the design already had. If the amount paid lived somewhere else, the journal would have to go and fetch it while the power was failing, which is precisely when nothing can be relied upon.
StaffThis becomes a fleet of two thousand machines with card payment, remote stock monitoring and app-based reservations. What survives, what changes, and where do you refuse a requirement?
What survives is the whole kernel, and that is the design's receipt. The state machine, the money rule, the register's composition check, the ordering of the phases, the journal and the hardware port are all unchanged. Two thousand machines is two thousand copies of the same small machine, and the fleet features sit around it rather than inside it. If a fleet requirement forced a change to the transition function, that would be the signal that something is being put in the wrong place.
Card payment is the one genuine change to the state machine, and it should be presented as such rather than as a seam. Coins are synchronous — the coin is physically in the machine — while a card authorisation is a request to somewhere else that may take seconds and may never answer. So collecting gains a waiting form: no money is held, an authorisation is pending, and a timeout is part of the state rather than a background worry.
Two paths come out of that, and both are worth stating:
Timeout with nothing captured. Return to idle, no refund, nothing owed. This is one of the few genuinely clean paths in the whole problem, precisely because nothing was ever taken.
Authorisation arrives after the timeout. Real money, no session. It triggers a refund through the channel, keyed by the authorisation identifier so that duplicate confirmations from a retrying payment provider are harmless. That is the idempotency-key idea from 9.6.3, arriving in a vending machine.
Remote stock monitoring must not become remote stock authority. The rule is that the machine owns its own stock, because it is the only thing that can be right about what is physically in a slot, and the central system is a mirror that may be minutes behind. Inverting this produces the failure everybody has seen: a machine refusing to sell a drink it can see, because a dashboard somewhere believes the slot is empty. Reports leave the machine asynchronously and never sit on the path of a sale, so a machine in a basement with no signal keeps trading.
App reservations are where I would push back on the requirement. The feature adds a hold on a slot with an expiry, which is a second small state machine on the slot — available, held until a time, reserved for dispensing — and expiry that works by being ignored rather than swept, so no cleanup job can race a customer arriving to collect. That much is straightforward.
What is not straightforward is the effect. A six-slot machine with fifteen-minute holds can be fully reserved and completely idle, with people walking away from a machine that appears to have stock. So the honest answer is to surface it as a product decision rather than implement it silently: cap the number of concurrent holds per machine, keep the holds short, and report the conversion rate of holds to collections. If that rate is low, the feature is costing more sales than it creates, and the operator needs to see that number rather than discover it from a revenue chart. Refusing to implement a requirement blind is different from refusing the requirement, and being able to say which one you are doing is the senior move here.
Two fleet-level things that must be designed rather than assumed.
Configuration is data with a version. Prices, hold limits and payment settings change centrally and arrive at machines that may be offline for a day. Each machine records which version it is running, so an operator can see that a price change has reached 1,940 of 2,000 machines rather than assuming it went everywhere.
Software updates must not interrupt a transaction. A machine holding a customer's money must finish or refund before it restarts. The state machine makes this easy to state: an update is allowed only from idle, which is a one-line rule precisely because there is one state meaning "nobody is owed anything."
What I would monitor across the fleet. The rate of returns by reason, because a rise in cannotMakeChange at one machine means it needs coins rather than restocking, and that is a different van trip; unresolved journal entries, which should be near zero and each of which is a customer who may have lost money; the age of the newest stock report per machine, which is how you find a machine that has quietly stopped talking; and hold conversion rate if reservations ship, which is the number that decides whether the feature stays.
Flashcards
FlashThe money rule
Every path: the inserted money becomes a product plus exact change, or comes back in full. collecting is the only state holding money the machine has not earned, and one transition function is the only writer — so the rule is provable by reading one function.
FlashWhy change is checked first
After the product drops there is no move left: the machine owes money it may not hold and cannot un-dispense. Checking while the money is still in collecting keeps a full refund available.
FlashWhy £4.50 cannot pay 30p
Nine 50p coins. Change is a composition question, not a total, so the register counts per denomination and builds an actual plan bounded by what it holds. Greedy is correct for standard coin systems only.
FlashExact change only is not a mode
The float updates on every sale — coins in, change out. As small coins drain, canPay refuses more purchases by itself. The behaviour emerges from counting rather than from a rule somebody wrote.
FlashPower cut mid-dispense
Journal the intention before the motor turns. On restart, one unresolved entry, resolved from sensor data where possible and by a stated policy otherwise — refund and flag the slot. Never retry the dispense: the product may already be in the tray.
FlashWhy dispense never blocks
A blocking call lets a jam freeze the cancel button, gives a late sensor nowhere to report, and makes the machine impossible to drive from a recorded event script. Everything it does goes through a port; everything it learns arrives as an event.
Next: 9.7.28 — the elevator, where the state machine is the easy half and the scheduling policy is the interview.