Coffee Vending Machine
Recipes, stock, coins and a state machine
Design a machine that sells drinks made from a stock of ingredients: pick a drink, pay, get the drink and the change, and never keep money for a drink it did not make.
Overview
A coffee vending machine holds a few ingredients (water, beans, milk, cocoa), knows a handful of recipes, and has three buttons a user can press: choose a drink, insert money, cancel. It must refuse a drink it cannot make, keep count of what it has poured, give change, and refund every cent when a sale does not finish.
The problem looks small and is a favorite because it tests three things at once. Can you model a device whose valid actions depend on what it is doing right now? Can you add a new drink without touching the code that sells drinks? And can you make the one dangerous step, checking the stock and then taking from it, safe when two requests arrive together?
A strong answer names a state machine in the first minute, says that recipes are data, and points at the check-and-consume step as the place that needs a lock. The code below does all three.
Scoping the problem
Before drawing anything, ask these six questions, because each answer removes a branch from the design.
- Which drinks, and can new ones be added later? — Decides whether a drink is a class or a row of data — Recipes are data: a name, a price and the amounts it needs
- How is money handled: coins, notes, card? — Coins and notes need a balance and change; a card is one call — Coins in cents; card payment is a follow-up question
- Can a user cancel, and when? — Cancel must refund; during dispensing it must be refused — Yes, until the price is covered
- What happens if an ingredient runs out mid-sale? — The machine must not keep the money — Full refund, back to idle
- How does maintenance learn stock is low? — Polling versus a notification — The inventory calls listeners when a level crosses a threshold
- One user at a time, or many requests at once? — Decides where the locks go — One user at the panel, but the inventory is shared with a maintenance thread
Core entities
Four small classes carry the design. The machine owns an inventory and a recipe table, and forwards every button press to its current state object.
The machine's states
The machine is in exactly one of three states, and each state decides what every button does. Adding a fourth state (say, OutOfService for maintenance) is a new class, not a new branch in three methods.
In the code below, dispensing finishes inside the same locked call that covered the price, so no other button press ever sees the Dispensing state. The state earns its place once brewing is a slow physical step that reports back later: then the busy answers in its column are what users get while the cup fills.
- select(name) — check the recipe and the stock, move on — refused: cancel first — refused: busy
- insert(cents) — coins go straight to the return slot — add to the balance; dispense once the price is covered — coins go to the return slot
- cancel() — nothing to cancel — refund the balance, back to Idle — refused
Buying a drink
The happy path touches every class once. Notice that the stock check happens twice: once at selection, so the user is not asked to pay for a drink the machine cannot make, and once more inside try_consume, because the stock may have changed since.
Recipes are data
A drink is a row, not a class. To add a mocha, add a Recipe; nothing in the states or the inventory changes. A customization such as an extra shot fits the same shape: a row of needs and cents that would be added to the base recipe before the stock check. The code below does not implement add-ons; supporting them means letting select take a list of add-on rows and summing them.
- espresso — 200 — 30 — 8 — 0 — 0
- latte — 300 — 30 — 8 — 150 — 0
- mocha (added later) — 350 — 30 — 8 — 120 — 10
- extra shot (add-on, follow-up) — +80 — +30 — +8 — 0 — 0
Where the money goes
Prices and balances are integers in cents. Floating-point money is a classic interview mistake: 0.1 + 0.1 + 0.1 is not equal to 0.3 in binary floating point, and change calculations drift. The machine tracks two numbers, the balance for the current sale and the total pushed into the coin return, and every path below leaves the balance at zero.
- Paid 400 for a 300 drink — 0 — +100 change — yes
- Cancel after inserting 100 — 0 — +100 refund — no
- Coins inserted with no drink selected — 0 — +coins, at once — no
- Ingredient ran out between selection and payment — 0 — +everything paid — no
Thread safety
Two locks, each with one job. The machine's lock makes each button press one atomic action, so the state cannot change halfway through a method. The inventory's lock makes check-and-consume one step, so two requests that both see enough beans cannot both take them. Python's Lock is not reentrant: the same thread acquiring it twice blocks forever. So _dispense runs with the machine lock already held and never takes it again, and the inventory calls its low-stock listeners after releasing its own lock, so a listener that reads a level cannot deadlock.
- CoffeeMachine._lock — the state, the selection, the balance — one public call, including the listener calls that try_consume makes during a sale — a listener must not call the machine back, or it blocks on this lock
- Inventory._lock — stock levels — check and subtract — calling a listener
What you will be able to do
- Draw the three states and say what each button does in each one, without an if-else ladder.
- Explain why a recipe is data and show that a new drink is a new row.
- Point at the one check-and-consume step and explain why it needs a lock, and why the listener call happens outside it.
- Account for every cent: change, refunds and coins inserted at the wrong time.
- Name the follow-ups, such as card payments, change with limited coins and a maintenance state, and sketch each in a sentence.
Functional requirements
- select(name) accepts a drink only when it exists and every ingredient it needs is in stock
- insert(cents) adds to the balance; once the price is covered the machine dispenses and returns the change
- cancel() refunds the balance while the machine is waiting for payment and is refused while dispensing
- Money inserted when no drink is selected goes straight back to the user
- Dispensing takes all of a recipe's ingredients or none; if any is short the whole payment is refunded
- refill(ingredient) restores a container to capacity; a listener is told when a level falls to 20 percent or less
- New drinks are added as recipe rows, without changing the machine or state code
Non-functional requirements
- Every button press is atomic: the state, selection and balance change together or not at all
- Stock never goes negative, even when a maintenance thread and a sale touch the inventory at once
- No money is kept for a drink that was not made
- Prices and balances are integers in cents, never floats
- Small, testable classes: each state is a class with three methods and no shared conditionals
Interview questions
- Why use the State pattern here instead of an if-else on a status field?
- How would you add card payments without touching the states?
- The machine has a limited number of each coin. How do you give change?
- What if the power fails while a drink is being made?
- Where exactly is the race, and why is one lock in the inventory enough?
- How does maintenance learn that milk is low, and why not just poll?
Sign up free to read the full topic
More Low-Level Design topics
Start free on Cariara · Coding problems · System design studio