Low Level Design (LLD) Interview Questions
Design a Vending Machine
Tier: CommonDifficulty: EasyAsked of: Junior, MidAsked at: Google, Amazon, Microsoft, Apple, Oracle
A vending machine is the cleanest state pattern problem there is. The same button means different things depending on what the machine is in the middle of, and that is the definition of when state objects earn their place.
The second half is the money. A machine takes coins before it knows whether it can complete the sale, so the design has to say who owns those coins at every moment. Get that right and the awkward follow up, what if it takes the money and does not drop the can, answers itself.
What to clarify first
- Which coins and notes are accepted. It decides whether change is a small problem or a large one.
- Does the machine give change. Almost always yes, and this is the interesting half.
- What happens when it cannot make exact change. Refuse the sale before it starts, or take the money and short the customer. Refusing is the right answer and it is why real machines have an exact change light.
- Can the customer cancel. Yes, and it must return the exact coins that went in.
- One item per sale or a basket. One. A basket is a different machine.
- Is restocking in scope. Usually one method that adds stock and coins, and no more.
- What if the motor jams. Ask this out loud. It is the whole reason the design has a dispensing state.
Out of scope worth saying. Card payments, a display, temperature control, telemetry back to the depot.
The classes
Seven, and three of them are values.
- Coin is an enum with a value, declared largest first. That declaration order is the order change is made in, so it is doing real work rather than being a list of names.
- Product is a slot code, a name and a price. The price lives with the product, not in a price table somewhere else.
- Purchase is what falls out, the product and the change together, because the customer gets both or neither.
- Inventory owns the stock and nothing else. It can take an item and put one back, and nothing outside it is allowed to change a count.
- CoinBank is the float, the coins the machine can hand back. It plans change without taking it, and takes coins only when a sale completes.
- VendingMachineState is what the machine is doing right now. Idle, collecting coins, or about to dispense.
- VendingMachine is the machine. It owns the inventory, the float and the current state, and every public method begins by checking that state.
The one design decision to lead with. The coins a customer has put in are held on the state, not in the float. They are the customer's money until the product is physically out, and only then do they join the machine's coins.
That single choice makes cancel trivial, makes a jam recoverable, and means there is never a moment where the books say the machine has money it may have to give back. Say it early, because it is the part of this answer that is actually yours.
How a sale actually runs
The machine starts idle. A coin goes in, and the machine moves to collecting, holding a list with that one coin. More coins just extend the list. Nothing has been decided yet and nothing has been touched.
The customer types a slot code. Now the machine checks four things in order. Is there such a slot, is it in stock, has enough money gone in, and can the float make the change. If the first three fail, nothing moves. If the change cannot be made, the coins come back and the machine returns to idle.
If all four pass, the machine moves to the dispensing state, carrying the product, the customer's coins and the planned change. Notice that nothing has actually happened in the physical world yet. This state exists exactly so there is somewhere to fail from.
Then the motor turns. The item comes off the shelf first, the motor pushes, and only when that succeeds does the money change hands. The customer's coins go into the float, the change comes out of it, and the machine is idle again.
If the motor jams, the item goes back on the shelf and the machine returns to collecting with the coins it was already holding. The customer can pick something else or press cancel and get their exact coins back. Nothing has to be unwound in the float, because nothing was ever put there.
Patterns actually used
State, and it is the whole answer. The tell that state has genuinely earned its place is that the same operation means different things in different situations. Here it does, three times over.
- Pressing a slot code while idle is a mistake, while collecting is a purchase, and while dispensing is an interruption.
- Inserting a coin while idle starts a session and while dispensing has to be refused, because the machine is mid sale.
- Cancelling returns coins from collecting and from dispensing, and does nothing at all from idle.
Written with booleans, that is a nest of checks like has money and not currently dispensing, repeated in every method, and one of them eventually goes missing in an edit. Written as states, an operation that is not allowed has nowhere to go.
Each state also carries only what that situation needs. Idle carries nothing. Collecting carries the coins so far. Dispensing carries the product, the coins and the planned change. There is no field on the machine that is meaningful half the time and null the rest.
Where the transitions live, and why it differs from the ATM. Here the transitions sit in the machine and the states are pure data. Every transition needs the stock and the float, and the machine owns both, so pushing the logic into the states would mean handing each state the whole machine. A state that needs the whole machine to work is not a state, it is a method with extra steps.
In Kotlin this reads especially well. The states are a sealed interface, the two operations that branch on state decide in a when the compiler forces to be exhaustive, and the two that only run in one state say so with a check.
Deliberately not used.
- No singleton. One machine in the room, one object, so it is tempting. It buys nothing, and the test that runs two machines with different floats is worth more than the pattern.
- No strategy. There is one way to make change here and one price per product. If the follow up is a happy hour price or a loyalty discount, a pricing strategy earns its place in a minute, and saying that is better than building it now.
- No observer. A low stock alert sounds like a use for it. In a machine that is polled by a depot once an hour, a listener is just a longer way to ask how much is left.
- No factory. Products come from a restock call with their data. There is nothing to construct polymorphically.
The implementation
The state file is the one to read first, then the dispense method, which is where the ordering decision lives.
Java
com.androidinterview.vending.VendingMachine.java
package com.androidinterview.vending;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.androidinterview.vending.inventory.CoinBank;
import com.androidinterview.vending.inventory.Inventory;
import com.androidinterview.vending.model.Coin;
import com.androidinterview.vending.model.Product;
import com.androidinterview.vending.model.Purchase;
import com.androidinterview.vending.state.VendingMachineState;
import com.androidinterview.vending.state.VendingMachineState.Collecting;
import com.androidinterview.vending.state.VendingMachineState.Dispensing;
import com.androidinterview.vending.state.VendingMachineState.Idle;
// The machine. Every public method starts by checking the state, which is the
// state machine doing its job. There is not one boolean flag in here, and that
// is the point of the whole design.
public final class VendingMachine {
// Stands in for the motor. Flip it to watch a sale unwind.
public volatile boolean jammed = false;
private final Inventory inventory;
private final CoinBank bank;
private VendingMachineState state = new Idle();
public VendingMachine(Inventory inventory, CoinBank bank) {
this.inventory = inventory;
this.bank = bank;
}
public VendingMachineState state() {
return state;
}
public void insert(Coin coin) {
if (state instanceof Idle) {
state = new Collecting(List.of(coin));
} else if (state instanceof Collecting collecting) {
List<Coin> coins = new ArrayList<>(collecting.inserted());
coins.add(coin);
state = new Collecting(coins);
} else {
throw new IllegalStateException("the machine is busy, take your item first");
}
}
// Choosing does not hand anything over. It checks the sale can complete,
// plans the change, and parks the machine one step from done.
public void select(String code) {
if (!(state instanceof Collecting collecting)) {
throw new IllegalStateException("insert a coin first");
}
Product product = inventory.productAt(code)
.orElseThrow(() -> new IllegalArgumentException("no slot " + code));
if (!inventory.inStock(code)) {
throw new IllegalStateException(product.name() + " is sold out");
}
int owed = collecting.paid() - product.price();
if (owed < 0) {
throw new IllegalStateException("insert " + (-owed) + " more");
}
Map<Coin, Integer> change = bank.planChange(owed);
if (change == null) {
// We cannot give the right change, so the sale never starts. The
// refusal carries the coins, because a refusal that only says the
// coins came back is the exact bug this design exists to avoid.
List<Coin> refund = collecting.inserted();
state = new Idle();
throw new NoChangeException(refund);
}
state = new Dispensing(product, collecting.inserted(), change);
}
// The step that can fail, and the reason the design is shaped this way.
//
// Take the item, turn the motor, and only when the item is physically out
// does the money change hands. If the motor jams, the item goes back on
// the shelf and the machine drops back to holding the customer's coins, so
// they can pick something else or press cancel and get them back. The
// coins were never mixed into the float, which is what makes that easy.
public Purchase dispense() {
if (!(state instanceof Dispensing dispensing)) {
throw new IllegalStateException("nothing to dispense");
}
Product product = dispensing.product();
if (!inventory.take(product.code())) {
// Someone restocked the slot to zero between selecting and
// dispensing. Nothing has moved, so drop back to holding the coins.
state = new Collecting(dispensing.inserted());
throw new IllegalStateException(product.name() + " is sold out");
}
try {
push(product);
} catch (MotorJamException jam) {
inventory.putBack(product.code());
state = new Collecting(dispensing.inserted());
throw jam;
}
bank.add(dispensing.inserted());
bank.take(dispensing.change());
state = new Idle();
return new Purchase(product, dispensing.change());
}
// Cancel at any point before the motor turns and the coins come straight
// back, because they were never mixed into the float.
public List<Coin> cancel() {
List<Coin> refund = List.of();
if (state instanceof Collecting collecting) {
refund = collecting.inserted();
} else if (state instanceof Dispensing dispensing) {
refund = dispensing.inserted();
}
state = new Idle();
return refund;
}
private void push(Product product) {
if (jammed) {
throw new MotorJamException("motor jammed on slot " + product.code());
}
}
// The refusal carries the coins, so a caller cannot handle it and forget to
// give the money back.
public static final class NoChangeException extends IllegalStateException {
private final List<Coin> refund;
public NoChangeException(List<Coin> refund) {
super("no change available, coins returned");
this.refund = List.copyOf(refund);
}
public List<Coin> refund() {
return refund;
}
}
// A jam has its own type so the catch in dispense cannot swallow a check
// that failed for some other reason.
public static final class MotorJamException extends IllegalStateException {
public MotorJamException(String message) {
super(message);
}
}
}
com.androidinterview.vending.inventory.CoinBank.java
package com.androidinterview.vending.inventory;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.androidinterview.vending.model.Coin;
// The float, the coins the machine can give back. Separate from the coins a
// customer has put in this session, and that separation is the whole reason
// a cancelled sale can return the exact coins that went in.
public final class CoinBank {
private final Map<Coin, Integer> counts = new EnumMap<>(Coin.class);
public CoinBank() {
}
// A machine is stocked with a float at the start of the day, so seeding one
// is construction rather than a call somebody can forget to make.
public CoinBank(List<Coin> initialFloat) {
add(initialFloat);
}
public void add(List<Coin> coins) {
coins.forEach(coin -> counts.merge(coin, 1, Integer::sum));
}
public void take(Map<Coin, Integer> coins) {
coins.forEach((coin, count) -> counts.merge(coin, -count, Integer::sum));
}
// Greedy change, largest coin first, and null when the float cannot make
// the amount exactly. It plans without taking anything, so a sale that
// cannot complete costs the float nothing.
//
// Greedy is what real machines do, and on an awkward float it can fail
// where an exact answer exists. That is why machines have an exact change
// light. Say so, and only write the search if you are asked for it.
public Map<Coin, Integer> planChange(int amount) {
Map<Coin, Integer> plan = new LinkedHashMap<>();
int remaining = amount;
for (Coin coin : Coin.values()) {
int take = Math.min(remaining / coin.value, counts.getOrDefault(coin, 0));
if (take > 0) {
plan.put(coin, take);
remaining -= take * coin.value;
}
}
return remaining == 0 ? plan : null;
}
}
com.androidinterview.vending.inventory.Inventory.java
package com.androidinterview.vending.inventory;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import com.androidinterview.vending.model.Product;
// The slots and how many of each are left. It owns stock and nothing else, so
// nothing else in the machine is allowed to change a count.
public final class Inventory {
private final Map<String, Product> products = new HashMap<>();
private final Map<String, Integer> counts = new HashMap<>();
public void load(Product product, int count) {
products.put(product.code(), product);
counts.merge(product.code(), count, Integer::sum);
}
public Optional<Product> productAt(String code) {
return Optional.ofNullable(products.get(code));
}
public boolean inStock(String code) {
return counts.getOrDefault(code, 0) > 0;
}
// Take is optimistic and putBack undoes it. The machine takes the item
// before the motor turns and puts it back if the motor fails, so a jam
// never sells the same can twice.
//
// The guard lives here rather than at the call site, because this class
// owns the count and a read followed by a write somewhere else is exactly
// the shape that drives a slot negative.
public boolean take(String code) {
int left = counts.getOrDefault(code, 0);
if (left <= 0) {
return false;
}
counts.put(code, left - 1);
return true;
}
public void putBack(String code) {
counts.merge(code, 1, Integer::sum);
}
}
com.androidinterview.vending.model.Coin.java
package com.androidinterview.vending.model;
// Coins the machine accepts, largest first, which is the order change is made
// in. Everything in this design counts in whole units, because a vending
// machine has no concept of half a coin.
public enum Coin {
FIFTY(50),
TWENTY(20),
TEN(10),
FIVE(5);
public final int value;
Coin(int value) {
this.value = value;
}
}
com.androidinterview.vending.model.Product.java
package com.androidinterview.vending.model;
// What sits in a slot. The code is what the customer types, so it is the
// identity, and the price lives with the product rather than in a table
// somewhere else.
public record Product(String code, String name, int price) {}
com.androidinterview.vending.model.Purchase.java
package com.androidinterview.vending.model;
import java.util.Map;
// What falls out of the machine. The product and the coins that come back with
// it, together, because the customer gets both or neither.
public record Purchase(Product product, Map<Coin, Integer> change) {}
com.androidinterview.vending.state.VendingMachineState.java
package com.androidinterview.vending.state;
import java.util.List;
import java.util.Map;
import com.androidinterview.vending.model.Coin;
import com.androidinterview.vending.model.Product;
// What the machine is doing right now, as data. Three states, and every one of
// them carries exactly what that situation needs and nothing more.
//
// The transitions live in the machine rather than on these records, because
// every transition here needs the stock and the float, and those belong to the
// machine. A state that needs to be handed the whole machine to do its job is
// not really a state, it is a method with extra steps.
public sealed interface VendingMachineState {
// Nothing owed, nothing owing.
record Idle() implements VendingMachineState {}
// Coins are in and no choice has been made. The coins are held as a list
// and not added to the float, because until the product comes out they
// still belong to the customer.
record Collecting(List<Coin> inserted) implements VendingMachineState {
public int paid() {
return inserted.stream().mapToInt(coin -> coin.value).sum();
}
}
// A choice has been made and the change has been planned, but the motor
// has not turned yet. This is the state that exists so a failed dispense
// has somewhere to fail from.
record Dispensing(Product product, List<Coin> inserted, Map<Coin, Integer> change)
implements VendingMachineState {}
}
Kotlin
com.androidinterview.vending.VendingMachine.kt
package com.androidinterview.vending
import com.androidinterview.vending.inventory.CoinBank
import com.androidinterview.vending.inventory.Inventory
import com.androidinterview.vending.model.Coin
import com.androidinterview.vending.model.Product
import com.androidinterview.vending.model.Purchase
import com.androidinterview.vending.state.VendingMachineState
import com.androidinterview.vending.state.VendingMachineState.Collecting
import com.androidinterview.vending.state.VendingMachineState.Dispensing
import com.androidinterview.vending.state.VendingMachineState.Idle
// The machine. Every public method starts by checking the state, and those
// checks are the state machine doing its job. There is not one boolean flag in
// here, which is the entire point of the design.
class VendingMachine(private val inventory: Inventory, private val bank: CoinBank) {
// Stands in for the motor. Flip it to watch a sale unwind.
@Volatile
var jammed: Boolean = false
var state: VendingMachineState = Idle
private set
fun insert(coin: Coin) {
state = when (val current = state) {
is Idle -> Collecting(listOf(coin))
is Collecting -> Collecting(current.inserted + coin)
is Dispensing -> error("the machine is busy, take your item first")
}
}
// Choosing hands nothing over. It checks the sale can complete, plans the
// change, and parks the machine one step from done.
fun select(code: String) {
val current = state
check(current is Collecting) { "insert a coin first" }
val product = requireNotNull(inventory.productAt(code)) { "no slot $code" }
check(inventory.inStock(code)) { "${product.name} is sold out" }
val owed = current.paid - product.price
check(owed >= 0) { "insert ${-owed} more" }
val change = bank.planChange(owed)
if (change == null) {
// We cannot give the right change, so the sale never starts. The
// refusal carries the coins, because a refusal that only says the
// coins came back is the exact bug this design exists to avoid.
val refund = current.inserted
state = Idle
throw NoChangeException(refund)
}
state = Dispensing(product, current.inserted, change)
}
// The step that can fail, and the reason the design is shaped this way.
//
// Take the item, turn the motor, and only once the item is physically out
// does the money change hands. If the motor jams, the item goes back on the
// shelf and the machine drops back to holding the customer's coins, so they
// can choose something else or press cancel. The coins were never mixed
// into the float, which is what makes that easy.
fun dispense(): Purchase {
val current = state
check(current is Dispensing) { "nothing to dispense" }
if (!inventory.take(current.product.code)) {
// Someone restocked the slot to zero between selecting and
// dispensing. Nothing has moved, so drop back to holding the coins.
state = Collecting(current.inserted)
error("${current.product.name} is sold out")
}
try {
push(current.product)
} catch (jam: MotorJamException) {
inventory.putBack(current.product.code)
state = Collecting(current.inserted)
throw jam
}
bank.add(current.inserted)
bank.take(current.change)
state = Idle
return Purchase(current.product, current.change)
}
// Cancel any time before the motor turns and the coins come straight back,
// because they were never mixed into the float.
fun cancel(): List<Coin> {
val refund = when (val current = state) {
is Collecting -> current.inserted
is Dispensing -> current.inserted
is Idle -> emptyList()
}
state = Idle
return refund
}
private fun push(product: Product) {
if (jammed) throw MotorJamException("motor jammed on slot ${product.code}")
}
// The refusal carries the coins, so a caller cannot handle it and forget to
// give the money back.
class NoChangeException(val refund: List<Coin>) :
IllegalStateException("no change available, coins returned")
// A jam has its own type so the catch in dispense cannot swallow a check
// that failed for some other reason.
class MotorJamException(message: String) : IllegalStateException(message)
}
com.androidinterview.vending.inventory.CoinBank.kt
package com.androidinterview.vending.inventory
import com.androidinterview.vending.model.Coin
// The float, the coins the machine can give back. It is deliberately separate
// from the coins a customer has put in this session, and that separation is
// what lets a cancelled sale return the exact coins that went in.
class CoinBank(coins: List<Coin> = emptyList()) {
private val counts = coins.groupingBy { it }.eachCount().toMutableMap()
fun add(coins: List<Coin>) = coins.forEach { counts.merge(it, 1, Int::plus) }
fun take(coins: Map<Coin, Int>) = coins.forEach { (coin, n) -> counts.merge(coin, -n, Int::plus) }
// Greedy change, largest coin first, and null when the float cannot make
// the amount exactly. It plans without taking anything, so a sale that
// cannot complete costs the float nothing.
//
// Greedy is what real machines do, and on an awkward float it can fail
// where an exact answer exists. That is what the exact change light is
// for. Say so, and only write the search if you are asked.
fun planChange(amount: Int): Map<Coin, Int>? {
var remaining = amount
val plan = mutableMapOf<Coin, Int>()
for (coin in Coin.entries) {
val take = minOf(remaining / coin.value, counts[coin] ?: 0)
if (take > 0) {
plan[coin] = take
remaining -= take * coin.value
}
}
return plan.takeIf { remaining == 0 }
}
}
com.androidinterview.vending.inventory.Inventory.kt
package com.androidinterview.vending.inventory
import com.androidinterview.vending.model.Product
// The slots and how many of each are left. It owns stock and nothing else, so
// nothing else in the machine may change a count.
class Inventory {
private val products = mutableMapOf<String, Product>()
private val counts = mutableMapOf<String, Int>()
fun load(product: Product, count: Int) {
products[product.code] = product
counts.merge(product.code, count, Int::plus)
}
fun productAt(code: String): Product? = products[code]
fun inStock(code: String): Boolean = (counts[code] ?: 0) > 0
// take is optimistic and putBack undoes it. The machine takes the item
// before the motor turns and puts it back if the motor fails, so a jam
// never sells the same can twice.
//
// The guard lives here rather than at the call site, because this class
// owns the count and a read followed by a write somewhere else is exactly
// the shape that drives a slot negative.
fun take(code: String): Boolean {
val left = counts[code] ?: 0
if (left <= 0) return false
counts[code] = left - 1
return true
}
fun putBack(code: String) {
counts.merge(code, 1, Int::plus)
}
}
com.androidinterview.vending.model.Coin.kt
package com.androidinterview.vending.model
// Coins the machine accepts, largest first, which is the order change is made
// in. Everything here counts in whole units, because a vending machine has no
// concept of half a coin.
enum class Coin(val value: Int) {
FIFTY(50),
TWENTY(20),
TEN(10),
FIVE(5),
}
com.androidinterview.vending.model.Product.kt
package com.androidinterview.vending.model
// What sits in a slot. The code is what the customer types, so it is the
// identity, and the price lives with the product rather than in a table
// somewhere else.
data class Product(val code: String, val name: String, val price: Int)
com.androidinterview.vending.model.Purchase.kt
package com.androidinterview.vending.model
// What falls out of the machine, the product and the coins that come back with
// it, because the customer gets both or neither.
data class Purchase(val product: Product, val change: Map<Coin, Int>)
com.androidinterview.vending.state.VendingMachineState.kt
package com.androidinterview.vending.state
import com.androidinterview.vending.model.Coin
import com.androidinterview.vending.model.Product
// What the machine is doing right now, as data. Three states, and each carries
// exactly what that situation needs and nothing more.
//
// The transitions live in the machine rather than on these types, because every
// transition needs the stock and the float and the machine owns both. A state
// that has to be handed the whole machine to do its job is not a state, it is
// a method with extra steps.
sealed interface VendingMachineState {
// Nothing owed, nothing owing.
data object Idle : VendingMachineState
// Coins are in and no choice has been made. They are held as a list rather
// than added to the float, because until the product comes out they still
// belong to the customer.
data class Collecting(val inserted: List<Coin>) : VendingMachineState {
val paid: Int get() = inserted.sumOf { it.value }
}
// A choice is made and the change is planned, but the motor has not turned.
// This state exists so that a failed dispense has somewhere to fail from.
data class Dispensing(
val product: Product,
val inserted: List<Coin>,
val change: Map<Coin, Int>,
) : VendingMachineState
}
Concurrency and edge cases
It takes the money and does not dispense. This is the question the problem exists for, so answer it with the ordering rather than with a lock.
The machine never holds money it might owe back. Coins stay in the state that represents this customer's session until the product is physically out. Take the item, turn the motor, and only then move the coins into the float and the change out of it. If the motor fails, put the item back and leave the coins exactly where they were.
Compare that to the ATM, where you cannot avoid the problem, because the account is at the bank and the cash is in the machine and there is no way to change both at once. There you debit and then credit back on failure, which is a compensating transaction. Here you can simply order the steps so nothing needs compensating, and knowing which of the two situations you are in is the actual skill.
It cannot make change. Check before the sale starts and refuse, rather than taking the money and shorting the customer. The change is planned without taking anything from the float, so a refused sale costs nothing and leaves no trace. The refusal itself carries the coins back to the caller, because a refusal that says the coins were returned without returning them is the bug this whole design is meant to prevent.
Add the honest limitation. Change is made greedily, largest coin first, and a greedy plan can fail on an awkward float where an exact answer exists. Real machines behave this way and light the exact change lamp. Bounded coin change is the better algorithm and it is worth mentioning rather than writing, unless you are asked.
There is a cost to the decision that coins stay the customer's. Because those coins are not in the float yet, the exact change light comes on slightly more often than it has to, and that is the price of never holding money we might owe back.
Two people using it at once. Physically impossible, and that is a fine answer, said out loud with the reason. One panel, one customer, and the machine is single threaded at the point where it matters.
Then say what would change. A bank of machines sharing one stock cupboard, or a machine that takes app payments while someone is standing at it, and now you need the stock count to be atomic. At that point the state has to move out of a field and into something shared, and the check on stock has to become a conditional update rather than a read followed by a write.
Other cases worth a sentence each.
- A sold out slot. Rejected at selection, before anything moves.
- Cancel halfway through. Returns the exact coins that went in, because they were never mixed into the float.
- A coin the machine does not take. Rejected by the coin mechanism before it ever reaches this code, which is a nice thing to point out, because it means the enum can stay small.
- The customer walks away with coins in. A timeout returns the machine to idle and pushes the coins back, the same way a jam does.
- Restocking mid session. Adding stock is safe at any time. Emptying the float while someone has money in is not, which is why the float is a separate object with its own guarded methods.
Watch