Technology · ssh / indianDevelopers
LLD 02 — Design a Vending Machine ( Design & Java Code )
A vending machine is not mainly an inventory problem. It is a state problem: inserting money, selecting products, cancelling, dispensing and rejecting invalid actions must all behave differently depending on what happened before.
A vending machine has maybe twenty products and three buttons. How complicated can the design really be?
Quite complicated, once the same button is expected to behave differently depending on what happened five seconds earlier.
Press A2 before inserting money: reject the selection.
Insert ₹50 and choose a ₹40 drink: dispense it and return ₹10.
Insert ₹20 for that same drink: keep the money, but do not dispense.
Press cancel halfway through: refund the current balance.
Try buying an empty slot: do not lose the customer's money.
The interesting part is no longer Product or Inventory.
It is state.
In LLD 01, our Parking Lot design was driven mostly by object responsibilities and replaceable strategies. This time we will solve a different problem: how do we make an object's legal behaviour depend on its current state without creating one enormous if/else block?
Start With the Behaviour, Not the Classes
For this version, our vending machine should support:
products stored in coded slots such as
A1andB2;a price and available quantity for each product;
insertion of supported coin denominations;
selection of a product after money has been inserted;
rejection when the balance is insufficient;
dispensing when enough money is available;
return of remaining balance as change;
cancellation and refund;
prevention of two purchases from mutating the same machine simultaneously.
We will leave a few things outside the first implementation:
card and UPI payments;
remote inventory synchronization;
dynamic pricing;
hardware motor failures;
exact physical coin-change optimization;
operator authentication;
distributed vending-machine management.
That last group is important. An LLD interview becomes messy very quickly if every possible real-world feature enters version one.
The Tempting Design That Starts to Hurt
A first attempt often looks like this:
public void selectProduct(String code) {
if (state == IDLE) {
throw new IllegalStateException("Insert money first");
} else if (state == HAS_MONEY) {
// validate product
// validate balance
// dispense
} else if (state == DISPENSING) {
throw new IllegalStateException("Please wait");
}
}
Then insertCoin() gets another state switch.
So does cancel().
Soon we have:
3 states × 3 operations = 9 behavioural combinations
Add OUT_OF_SERVICE, REFUNDING, or card authorization later and the number of branches keeps growing.
Worse, the rules for one state are scattered across several methods.
A better question is:
What if each state itself knew which operations were legal?
That leads naturally to the State Pattern.

Our State Machine
We only need three states for the first design:
IdleState
There is no active transaction.
The machine can:
accept money;
reject product purchase because there is no credit;
treat cancellation as a no-op.
HasCreditState
A customer has inserted money.
The machine can:
accept more money;
attempt a product selection;
cancel and refund.
DispensingState
A successful purchase is being completed.
External actions should be rejected until the transaction finishes.
The happy path therefore looks like:
IDLE
↓ insert coin
HAS CREDIT
↓ select affordable product
DISPENSING
↓ product + change returned
IDLE
Cancellation gives us:
HAS CREDIT
↓ cancel
REFUND
↓
IDLE
We do not actually need a permanent RefundState yet because refunding is synchronous in this interview model.
Model Money Without Floating-Point Arithmetic
For currency, do not write:
double price = 1.20;
For this exercise, representing money as the smallest unit is cleaner.
So ₹40 becomes:
4000 paise
Our coin enum can therefore contain integer values:
public enum Coin {
ONE_RUPEE(100),
TWO_RUPEES(200),
FIVE_RUPEES(500),
TEN_RUPEES(1000),
TWENTY_RUPEES(2000),
FIFTY_RUPEES(5000);
private final int valueInPaise;
Coin(int valueInPaise) {
this.valueInPaise = valueInPaise;
}
public int valueInPaise() {
return valueInPaise;
}
}
For a production payment system I would normally introduce a proper Money value object with currency as well.
For one Indian vending machine, integer paise keeps the interview model focused.
Product and Slot Have Different Responsibilities
A product describes what is being sold:
public record Product(
String sku,
String name,
int priceInPaise
) {
public Product {
if (priceInPaise <= 0) {
throw new IllegalArgumentException(
"Product price must be positive"
);
}
}
}
A slot describes where it is stocked and how much remains:
public final class ProductSlot {
private final String code;
private final Product product;
private int quantity;
public ProductSlot(
String code,
Product product,
int quantity) {
if (quantity < 0) {
throw new IllegalArgumentException(
"Quantity cannot be negative"
);
}
this.code = code;
this.product = product;
this.quantity = quantity;
}
public String getCode() {
return code;
}
public Product getProduct() {
return product;
}
public boolean isAvailable() {
return quantity > 0;
}
public void dispenseOne() {
if (quantity <= 0) {
throw new IllegalStateException(
"Product is out of stock"
);
}
quantity--;
}
public int getQuantity() {
return quantity;
}
}
Keeping those concepts separate becomes useful when several machines or several slots eventually carry the same product.
Inventory Owns Product Lookup
The vending machine should not need to know whether inventory uses an array, map, database, or something else.
For now:
public final class Inventory {
private final Map<String, ProductSlot> slots =
new HashMap<>();
public void addSlot(ProductSlot slot) {
if (slots.putIfAbsent(slot.getCode(), slot) != null) {
throw new IllegalArgumentException(
"Duplicate slot: " + slot.getCode()
);
}
}
public ProductSlot getSlot(String code) {
ProductSlot slot = slots.get(code);
if (slot == null) {
throw new IllegalArgumentException(
"Unknown product slot: " + code
);
}
return slot;
}
}
There is still no database.
That is intentional.
Persistence is a storage concern; it is not required to understand the object model.
Now Define the State Contract
Every machine state must decide what happens when the same three commands arrive:
public interface VendingState {
void insertCoin(
VendingMachine machine,
Coin coin
);
DispenseResult selectProduct(
VendingMachine machine,
String slotCode
);
int cancel(
VendingMachine machine
);
String name();
}
Notice what is absent:
if state == ...
switch state ...
The machine delegates behaviour to the current state object.
IdleState: Start a Transaction
public final class IdleState implements VendingState {
@Override
public void insertCoin(
VendingMachine machine,
Coin coin) {
machine.addCredit(coin.valueInPaise());
machine.transitionTo(machine.hasCreditState());
}
@Override
public DispenseResult selectProduct(
VendingMachine machine,
String slotCode) {
throw new IllegalStateException(
"Insert money before selecting a product"
);
}
@Override
public int cancel(VendingMachine machine) {
return 0;
}
@Override
public String name() {
return "IDLE";
}
}
One successful insertCoin() changes both:
balance: 0 → coin value
state: IDLE → HAS_CREDIT
HasCreditState: Most of the Business Logic Lives Here
This is where selection becomes meaningful.
public final class HasCreditState
implements VendingState {
@Override
public void insertCoin(
VendingMachine machine,
Coin coin) {
machine.addCredit(coin.valueInPaise());
}
@Override
public DispenseResult selectProduct(
VendingMachine machine,
String slotCode) {
ProductSlot slot =
machine.inventory().getSlot(slotCode);
if (!slot.isAvailable()) {
throw new IllegalStateException(
"Selected product is out of stock"
);
}
Product product = slot.getProduct();
if (machine.currentCredit()
< product.priceInPaise()) {
int missing =
product.priceInPaise()
- machine.currentCredit();
throw new IllegalStateException(
"Insufficient balance. Need "
+ missing + " more paise"
);
}
machine.transitionTo(
machine.dispensingState()
);
return machine.completePurchase(slot);
}
@Override
public int cancel(VendingMachine machine) {
int refund = machine.takeAllCredit();
machine.transitionTo(
machine.idleState()
);
return refund;
}
@Override
public String name() {
return "HAS_CREDIT";
}
}
There are two useful details here.
If stock is empty, the machine does not consume the customer's balance.
If the customer has insufficient money, the machine remains in HAS_CREDIT, so another coin can be inserted.
Those rules fall naturally out of the state model.
DispensingState Rejects New Commands
Dispensing is deliberately boring:
public final class DispensingState
implements VendingState {
private IllegalStateException busy() {
return new IllegalStateException(
"Machine is currently dispensing"
);
}
@Override
public void insertCoin(
VendingMachine machine,
Coin coin) {
throw busy();
}
@Override
public DispenseResult selectProduct(
VendingMachine machine,
String slotCode) {
throw busy();
}
@Override
public int cancel(VendingMachine machine) {
throw busy();
}
@Override
public String name() {
return "DISPENSING";
}
}
Why model a state whose only job is to reject commands?
Because it makes the invariant explicit:
Once dispensing begins, a second user action must not modify the active transaction.
Put the Transaction in VendingMachine
Now the context becomes surprisingly small.
public record DispenseResult(
Product product,
int changeInPaise
) {}
public final class VendingMachine {
private final Inventory inventory;
private final VendingState idleState =
new IdleState();
private final VendingState hasCreditState =
new HasCreditState();
private final VendingState dispensingState =
new DispensingState();
private VendingState state;
private int currentCredit;
public VendingMachine(Inventory inventory) {
this.inventory = inventory;
this.state = idleState;
}
public synchronized void insertCoin(Coin coin) {
Objects.requireNonNull(coin);
state.insertCoin(this, coin);
}
public synchronized DispenseResult selectProduct(
String slotCode) {
return state.selectProduct(this, slotCode);
}
public synchronized int cancel() {
return state.cancel(this);
}
Inventory inventory() {
return inventory;
}
int currentCredit() {
return currentCredit;
}
void addCredit(int amount) {
currentCredit += amount;
}
int takeAllCredit() {
int amount = currentCredit;
currentCredit = 0;
return amount;
}
DispenseResult completePurchase(
ProductSlot slot) {
Product product = slot.getProduct();
if (state != dispensingState) {
throw new IllegalStateException(
"Machine is not dispensing"
);
}
slot.dispenseOne();
int change =
currentCredit - product.priceInPaise();
currentCredit = 0;
state = idleState;
return new DispenseResult(
product,
change
);
}
void transitionTo(VendingState nextState) {
this.state = Objects.requireNonNull(nextState);
}
VendingState idleState() {
return idleState;
}
VendingState hasCreditState() {
return hasCreditState;
}
VendingState dispensingState() {
return dispensingState;
}
public synchronized int getCurrentCredit() {
return currentCredit;
}
public synchronized String getStateName() {
return state.name();
}
}
That is the complete core design.
The public API of the machine is only:
insertCoin(...)
selectProduct(...)
cancel()
Everything else exists to keep those operations correct.

Walk Through an Actual Purchase
Suppose slot A1 contains Coke for ₹40.
The machine starts with:
state = IDLE
credit = ₹0
stock = 5
Customer inserts ₹20:
state = HAS_CREDIT
credit = ₹20
They insert another ₹20:
state = HAS_CREDIT
credit = ₹40
They select A1.
HasCreditState checks:
Does A1 exist? yes
Is stock available? yes
Is credit >= price? yes
The machine enters DISPENSING.
One unit is removed.
The transaction resets:
state = IDLE
credit = ₹0
stock = 4
If the customer had inserted ₹50 instead, the same purchase would return ₹10 as change.
If they had inserted only ₹20, selection would fail but the transaction would remain active:
state = HAS_CREDIT
credit = ₹20
That last detail is exactly why thinking in states is useful.
What About Two People Pressing Buttons at Once?
A physical vending machine normally represents one customer interaction at a time, but software events can still arrive concurrently.
Imagine:
Thread A → select A1
Thread B → cancel
If both operations mutate the same balance and state without coordination, strange results become possible:
product dispensed
+
full refund returned
Our public mutation methods are therefore synchronized:
public synchronized DispenseResult selectProduct(...)
public synchronized int cancel()
For one in-memory machine instance, that creates a simple transaction boundary around the state change.
It is deliberately not a distributed-lock solution.
If a remote service controlled thousands of physical machines, the concurrency boundary would need to move into durable storage and device-command handling.
There Is One Simplification You Should Mention in the Interview
Our DispenseResult returns change as:
int changeInPaise
Real vending hardware cannot manufacture arbitrary change.
It has a finite number of particular coins or notes.
So a more advanced version needs something like:
public interface ChangeService {
List<Coin> makeChange(int amountInPaise);
}
The implementation would track available denominations and determine whether exact change can be produced.
That creates another important state:
EXACT_CHANGE_ONLY
or a rule preventing purchases the machine cannot settle.
I would mention this extension rather than implementing it immediately unless the interviewer explicitly asks for denomination-level cash management.
That keeps the first solution focused.
Where Candidates Usually Overcomplicate This Problem
The first trap is creating subclasses for everything:
CokeProduct
PepsiProduct
WaterProduct
If their only difference is name and price, those are data values, not separate behaviours.
The second is adding:
ProductFactory
CoinFactory
InventoryFactory
VendingMachineSingleton
without any construction problem that requires them.
The third is declaring the State Pattern before identifying any actual states.
That reverses the reasoning.
We did not decide:
“This is a famous State Pattern interview question, so let's use State.”
We observed:
“The same operation has different legal behaviour depending on previous events.”
That is the reason State fits.
What Happens When Requirements Change?
Suppose the interviewer says:
“Let customers select first and pay afterward.”
Now introduce something like:
IDLE
↓ selection
PRODUCT_SELECTED
↓ sufficient money
DISPENSING
The existing states do not need enormous conditional rewrites.
“The customer can pay by card.”
Separate payment authorization from physical cash credit.
The transaction state machine can still remain.
“A dispensing motor can fail after payment.”
Now the happy path is no longer atomic.
You need to model:
payment accepted
↓
dispense attempted
↓
success → finish
failure → refund / operator intervention
That is where a DispenseFailedState could become legitimate.
“The operator needs to restock the machine.”
That is a different actor and probably a different administrative interface. It should not be forced into the customer's transaction state machine.
Good LLD evolves by introducing concepts when the requirements make them necessary.
How I Would Handle This in an Interview
For this problem, I would spend less time drawing a giant class diagram and more time showing the state transitions early.
The conversation should roughly progress like this:
First, clarify whether payment happens before selection, whether change is required, and whether exact denomination handling is expected.
Then, identify the transaction states.
Once the interviewer agrees with:
IDLE → HAS_CREDIT → DISPENSING → IDLE
the rest becomes much easier.
Next introduce Product, ProductSlot, and Inventory, followed by the VendingState interface.
Code one complete successful purchase and one invalid transition.
Only after that discuss concurrency, exact-change handling, card payments, or hardware failures.
That tells the interviewer you can distinguish the core model from the extensions.
The Lesson From LLD 02
Parking Lot taught us to ask:
Which object should own this responsibility?
Vending Machine adds another question:
Which actions are valid right now?
That question appears everywhere in backend software.
An order might move through:
CREATED → PAID → SHIPPED → DELIVERED
A payment might move through:
INITIATED → AUTHORIZED → CAPTURED
A job might move through:
QUEUED → RUNNING → SUCCEEDED
Whenever behaviour depends strongly on lifecycle state, scattering state checks around the code becomes increasingly dangerous.
The State Pattern is not valuable because it gives us more classes.
It is valuable because it puts the rules for each state in one place and makes invalid transitions difficult to ignore.
That is the real design lesson hidden inside a vending machine.

Conversation
Comments
Sign in to join the conversation.