Vending Machine
Advanced · 17 min read · ▶ live playground · ✦ checkpoint
“Design a vending machine.” The buttons and coin slot are the easy part. The interview is testing whether balance, stock, change, and legal transitions still agree after a sold-out selection or a cancelled purchase.
The earlier State pattern lesson (state) used NoCoinState, HasCoinState, and a waiting-to-dispense SoldState as a teaching toy. This case study takes the same idea to a complete design: IdleState, HasMoneyState, DispensingState, and SoldOutState carry the real transition logic while inventory and cash keep their own rules.
Requirements
Fix the transaction rules before drawing a machine-shaped box around everything.
| Kind | Requirement for this design |
|---|---|
| Functional | The machine holds products in identified slots; each slot has one product, a price, and a remaining count. |
| Functional | A customer may insert supported coin and note denominations, and the machine tracks the current balance. |
| Functional | The customer selects a slot, and the machine rejects an unknown or sold-out selection. |
| Functional | A product may enter the dispensing flow only when the balance covers its price. |
| Functional | A successful purchase decrements exactly one slot, returns the product, and returns computable change. |
| Functional | cancel() returns the current tender and clears the transaction before dispensing begins. |
| Functional | Insufficient funds preserve the balance so the customer can add money or cancel. |
| Functional | When no slot has stock, the machine enters a sold-out state and refuses new money. |
| Non-functional | Every operation must have one legal meaning for the machine's current state. |
| Non-functional | Money uses integer rupees rather than floating-point values, and the critical flow is deterministic in tests. |
Card and mobile payments, remote telemetry, promotions, restocking workflows, counterfeit detection, and real motors or sensors are out of scope. The skeleton models the decision to release a product, not communication with physical dispensing hardware.
Clarifying questions to ask
Each answer changes either the state graph or the owner of a rule. Ask before choosing state classes.
- Does the machine require exact payment or give change? It gives change from supported denominations. A sale is not approved unless the cash box can form the change; the customer may insert exact money or cancel if it cannot.
- Are coins and notes both accepted? Yes.
Denominationcontains the supported coin and note values. The prompt's conventionalinsertCoin()command receives either kind, and balance is the sum of that tender rather than a free-form decimal. - Can the customer insert more than one denomination? Yes. The first accepted value moves the machine from idle to has-money; later values accumulate without changing state.
- Can the customer cancel and receive a refund? Yes, until the machine reaches
DispensingState. Cancellation returns the exact denominations inserted for this transaction and clears the selection. - What happens when the selected product costs more than the balance? Selection is rejected, the machine remains in
HasMoneyState, and the existing balance is preserved so more money can be inserted. - What happens if a product sells out after money was inserted? That selection is rejected without consuming balance. The customer may select another stocked slot or cancel. This physical machine processes one call at a time, so stock cannot change between an approved selection and its immediate dispense step.
- Is sold-out a slot condition or a machine state? Both ideas exist at different levels. A slot may be empty while other choices remain;
SoldOutStatemeans every slot is empty. - When is inventory decremented? Only inside
DispensingState, after price, stock, and change were checked. Selection reserves the intent in this single-threaded model; it does not decrement stock. - May the customer change a selection? Not after a selection is approved. Approval moves to
DispensingState, whose only legal business operation isdispense(). Before approval, a rejected choice leaves the customer inHasMoneyState. - Where does change come from? A
CashBoxowns reserve counts and the current tender. It searches the available 1, 2, 5, 10, 20, and 50 rupee denominations for an exact bounded combination. - What happens after the last product is sold? The successful purchase still returns its product and change, then the machine transitions to
SoldOutStatebefore the next request. - What concurrency should we assume? One physical machine serializes one customer's commands. A networked fleet may process different machines concurrently, but commands for the same machine need one ordered transaction stream.
Entity extraction
Run the noun pass against the clarified requirements:
- A vending machine holds a current state, an inventory, a cash box, a balance, and an optional selection.
- An inventory contains identified slots; each slot holds a product, a price, and a count.
- A customer inserts a coin or note, selects a slot code, receives a product, and may receive change.
- A purchase moves through idle, has-money, dispensing, or sold-out conditions.
- A refund returns the transaction's original tender when the customer cancels.
Now sort the words by identity, lifecycle, and behavior:
| Bucket | Nouns | Model choice |
|---|---|---|
| Context | vending machine | VendingMachine holds shared transaction facts and delegates condition-specific behavior |
| State contract | state, condition | VendingState plus four concrete state classes encode legal operations and transitions |
| Domain entities | inventory, slot, product, cash box | Classes with stock, price, or cash invariants |
| Attributes / values | slot code, price, count, balance, selection | Fields or small records owned by the relevant entity |
| Enum | coin, note, denomination | One numeric Denomination enum; the name records whether a value is a coin or note |
| Actor | customer | Calls the machine but does not become a core class |
| Excluded concepts | card payment, motor, sensor, telemetry | No speculative classes in this model |
The verb pass assigns each rule to the object that already owns its facts:
| Verb phrase | Owner | Reason |
|---|---|---|
| accept a denomination | current VendingState authorizes; CashBox records | State decides whether acceptance is legal, while the cash box owns tender |
| select a product | HasMoneyState coordinates a quote and transition | Only this state has a funded transaction that may be approved |
| quote stock and price | Inventory finds the Slot; Slot exposes its quote | Stock and price belong beside the slot count |
| decrement one item | Slot.dispense() through Inventory | The slot protects count from going below zero |
| compute and remove change | CashBox | It owns denomination counts and current tender |
| refund on cancel | CashBox returns tender; current state chooses the next state | Cash ownership and transition policy remain separate |
| remember the approved selection | VendingMachine | Selection survives the transition from has-money to dispensing |
| reject an illegal operation | active VendingState | The meaning depends on the machine's current condition |
| choose the next state | concrete state handling the event | Transitions stay beside the behavior that caused them |
This is the same extraction rule used throughout the course: grammar proposes; ownership decides. VendingMachine coordinates the transaction, but it does not edit slot counts or denomination reserves directly.
Core class diagram
Keep the state graph and the two protected resources visible. A payment gateway, restocker, hardware adapter, repository, and audit log can surround this core later.
class diagram
Core vending-machine model
- 1VendingMachine is the context and retains facts shared across state transitions.
- 2The context delegates every condition-dependent operation through VendingState.
- 3IdleState accepts the first denomination and rejects selection without money.
- 4HasMoneyState accumulates tender, validates a choice, and approves a sale.
- 5DispensingState is the only state allowed to release a product.
- 6SoldOutState refuses new money when the entire inventory is empty.
- 7Inventory indexes slots and answers stock queries for the context.
- 8Each Slot owns one price and count, including the no-negative-stock rule.
- 9A Slot associates its count and price with the Product it releases.
- 10CashBox owns tender, refunds, denomination reserves, and change computation.
VendingMachine owns a VendingState; concrete states implement the interface. Those are different relationships with different targets, so the diagram never tries to say that the machine both owns and implements the same object.
The four state classes do not own balance, selection, or stock. Those facts outlive one state object and belong to the context or its resources. A state authorizes an action, calls a narrow context operation, and installs the next state.
Critical-flow sequence diagram
The flow worth drawing is “buy one product with change.” The machine starts idle with a stocked A1 slot and enough five-rupee coins in its reserve.
sequence diagram
Buy a product and receive change
- 1IdleState records the first note, then makes HasMoneyState the next behavior.
- 2HasMoneyState validates stock, price, and change before approving the selection.
- 3Only DispensingState reaches Inventory.dispense(), which decrements A1 once.
- 4The sale settles, the context returns to IdleState, and product plus change come back.
The transition in the middle is the point of the pattern. selectProduct() is handled by HasMoneyState, but the next delegated call reaches DispensingState on the same VendingMachine. After stock and cash settle, the state changes again before another customer command can arrive.
If the balance were only ten rupees, message 6 would not occur: HasMoneyState would report the five-rupee shortfall and remain active. If A1 were empty, inventory would not be decremented and the tender would remain refundable.
TypeScript skeleton
The skeleton keeps money integral, implements bounded change, and gives every state all four operations. Concrete states either perform the legal transition or reject the command without partially changing the transaction.
export {};
enum Denomination {
Coin1 = 1,
Coin2 = 2,
Coin5 = 5,
Coin10 = 10,
Note20 = 20,
Note50 = 50,
}
const DENOMINATIONS_DESC: readonly Denomination[] = [
Denomination.Note50,
Denomination.Note20,
Denomination.Coin10,
Denomination.Coin5,
Denomination.Coin2,
Denomination.Coin1,
];
class Product {
public constructor(
public readonly id: string,
public readonly name: string,
) {
if (id.trim().length === 0 || name.trim().length === 0) {
throw new Error("product id and name are required");
}
}
}
interface SlotQuote {
readonly product: Product;
readonly priceInRupees: number;
readonly count: number;
}
class Slot {
private count: number;
public constructor(
public readonly code: string,
private readonly product: Product,
private readonly priceInRupees: number,
initialCount: number,
) {
if (code.trim().length === 0) {
throw new Error("slot code is required");
}
if (!Number.isInteger(priceInRupees) || priceInRupees <= 0) {
throw new Error("price must be a positive integer number of rupees");
}
if (!Number.isInteger(initialCount) || initialCount < 0) {
throw new Error("slot count must be a non-negative integer");
}
this.count = initialCount;
}
public quote(): SlotQuote {
return {
product: this.product,
priceInRupees: this.priceInRupees,
count: this.count,
};
}
public dispense(): Product {
if (this.count === 0) {
throw new Error(`slot ${this.code} is sold out`);
}
this.count -= 1;
return this.product;
}
}
class Inventory {
private readonly slots = new Map<string, Slot>();
public constructor(slots: readonly Slot[]) {
for (const slot of slots) {
if (this.slots.has(slot.code)) {
throw new Error(`duplicate slot code ${slot.code}`);
}
this.slots.set(slot.code, slot);
}
}
public quote(code: string): SlotQuote {
const slot = this.slots.get(code);
if (slot === undefined) {
throw new Error(`unknown slot ${code}`);
}
return slot.quote();
}
public dispense(code: string): Product {
const slot = this.slots.get(code);
if (slot === undefined) {
throw new Error(`unknown slot ${code}`);
}
return slot.dispense();
}
public hasAnyStock(): boolean {
return [...this.slots.values()].some(
(slot) => slot.quote().count > 0,
);
}
}
class CashBox {
private readonly reserve = new Map<Denomination, number>();
private tender: Denomination[] = [];
public constructor(
initialReserve: ReadonlyMap<Denomination, number>,
) {
for (const denomination of DENOMINATIONS_DESC) {
const count = initialReserve.get(denomination) ?? 0;
if (!Number.isInteger(count) || count < 0) {
throw new Error("cash reserve counts must be non-negative integers");
}
this.reserve.set(denomination, count);
}
}
public get balance(): number {
return this.tender.reduce((sum, value) => sum + value, 0);
}
public accept(value: Denomination): void {
if (!DENOMINATIONS_DESC.includes(value)) {
throw new Error(`unsupported denomination ${value}`);
}
this.tender.push(value);
}
public refund(): readonly Denomination[] {
const refund = [...this.tender];
this.tender = [];
return refund;
}
public canSettle(priceInRupees: number): boolean {
if (this.balance < priceInRupees) return false;
const changeDue = this.balance - priceInRupees;
return this.planChange(
changeDue,
this.availableAfterTender(),
) !== null;
}
public settle(priceInRupees: number): readonly Denomination[] {
if (!Number.isInteger(priceInRupees) || priceInRupees <= 0) {
throw new Error("sale price must be a positive integer");
}
if (this.balance < priceInRupees) {
throw new Error("insufficient funds");
}
const available = this.availableAfterTender();
const change = this.planChange(
this.balance - priceInRupees,
available,
);
if (change === null) {
throw new Error("exact change is unavailable");
}
for (const value of change) {
available.set(value, (available.get(value) ?? 0) - 1);
}
for (const denomination of DENOMINATIONS_DESC) {
this.reserve.set(
denomination,
available.get(denomination) ?? 0,
);
}
this.tender = [];
return change;
}
private availableAfterTender(): Map<Denomination, number> {
const available = new Map(this.reserve);
for (const value of this.tender) {
available.set(value, (available.get(value) ?? 0) + 1);
}
return available;
}
private planChange(
amount: number,
available: ReadonlyMap<Denomination, number>,
): Denomination[] | null {
const search = (
index: number,
remaining: number,
): Denomination[] | null => {
if (remaining === 0) return [];
if (index === DENOMINATIONS_DESC.length) return null;
const denomination = DENOMINATIONS_DESC[index];
const maxCount = Math.min(
Math.floor(remaining / denomination),
available.get(denomination) ?? 0,
);
for (let count = maxCount; count >= 0; count -= 1) {
const rest = search(
index + 1,
remaining - count * denomination,
);
if (rest !== null) {
return [
...Array.from({ length: count }, () => denomination),
...rest,
];
}
}
return null;
};
return search(0, amount);
}
}
interface Selection {
readonly slotCode: string;
readonly priceInRupees: number;
}
interface PurchaseResult {
readonly product: Product;
readonly change: readonly Denomination[];
}
interface VendingState {
readonly name: string;
insertCoin(
machine: VendingMachine,
value: Denomination,
): void;
selectProduct(machine: VendingMachine, code: string): void;
dispense(machine: VendingMachine): PurchaseResult;
cancel(machine: VendingMachine): readonly Denomination[];
}
class VendingMachine {
private state: VendingState;
private selection: Selection | null = null;
public constructor(
private readonly inventory: Inventory,
private readonly cashBox: CashBox,
) {
this.state = inventory.hasAnyStock()
? new IdleState()
: new SoldOutState();
}
public get balance(): number {
return this.cashBox.balance;
}
public get stateName(): string {
return this.state.name;
}
public setState(state: VendingState): void {
this.state = state;
}
public insertCoin(value: Denomination): void {
this.state.insertCoin(this, value);
}
public selectProduct(code: string): void {
this.state.selectProduct(this, code);
}
public dispense(): PurchaseResult {
return this.state.dispense(this);
}
public cancel(): readonly Denomination[] {
return this.state.cancel(this);
}
public acceptMoney(value: Denomination): void {
this.cashBox.accept(value);
}
public quote(code: string): SlotQuote {
return this.inventory.quote(code);
}
public rememberSelection(
slotCode: string,
priceInRupees: number,
): void {
this.selection = { slotCode, priceInRupees };
}
public selectedSale(): Selection {
if (this.selection === null) {
throw new Error("no product is selected");
}
return this.selection;
}
public releaseSelectedProduct(): Product {
return this.inventory.dispense(this.selectedSale().slotCode);
}
public canMakeChange(priceInRupees: number): boolean {
return this.cashBox.canSettle(priceInRupees);
}
public settle(priceInRupees: number): readonly Denomination[] {
return this.cashBox.settle(priceInRupees);
}
public clearSelection(): void {
this.selection = null;
}
public refundMoney(): readonly Denomination[] {
this.clearSelection();
return this.cashBox.refund();
}
public hasStock(): boolean {
return this.inventory.hasAnyStock();
}
}
class IdleState implements VendingState {
public readonly name = "idle";
public insertCoin(
machine: VendingMachine,
value: Denomination,
): void {
machine.acceptMoney(value);
machine.setState(new HasMoneyState());
}
public selectProduct(
_machine: VendingMachine,
_code: string,
): void {
throw new Error("insert money before selecting a product");
}
public dispense(_machine: VendingMachine): PurchaseResult {
throw new Error("nothing is ready to dispense");
}
public cancel(_machine: VendingMachine): readonly Denomination[] {
return [];
}
}
class HasMoneyState implements VendingState {
public readonly name = "has-money";
public insertCoin(
machine: VendingMachine,
value: Denomination,
): void {
machine.acceptMoney(value);
}
public selectProduct(machine: VendingMachine, code: string): void {
const quote = machine.quote(code);
if (quote.count === 0) {
throw new Error(`${quote.product.name} is sold out`);
}
if (machine.balance < quote.priceInRupees) {
const shortfall = quote.priceInRupees - machine.balance;
throw new Error(`insert ₹${shortfall} more`);
}
if (!machine.canMakeChange(quote.priceInRupees)) {
throw new Error("exact change is unavailable; add exact money or cancel");
}
machine.rememberSelection(code, quote.priceInRupees);
machine.setState(new DispensingState());
}
public dispense(_machine: VendingMachine): PurchaseResult {
throw new Error("select a product before dispensing");
}
public cancel(machine: VendingMachine): readonly Denomination[] {
const refund = machine.refundMoney();
machine.setState(
machine.hasStock() ? new IdleState() : new SoldOutState(),
);
return refund;
}
}
class DispensingState implements VendingState {
public readonly name = "dispensing";
public insertCoin(
_machine: VendingMachine,
_value: Denomination,
): void {
throw new Error("wait for the current product");
}
public selectProduct(
_machine: VendingMachine,
_code: string,
): void {
throw new Error("a product is already selected");
}
public dispense(machine: VendingMachine): PurchaseResult {
const sale = machine.selectedSale();
const product = machine.releaseSelectedProduct();
const change = machine.settle(sale.priceInRupees);
machine.clearSelection();
machine.setState(
machine.hasStock() ? new IdleState() : new SoldOutState(),
);
return { product, change };
}
public cancel(_machine: VendingMachine): readonly Denomination[] {
throw new Error("the approved product is already dispensing");
}
}
class SoldOutState implements VendingState {
public readonly name = "sold-out";
public insertCoin(
_machine: VendingMachine,
_value: Denomination,
): void {
throw new Error("machine is sold out");
}
public selectProduct(
_machine: VendingMachine,
_code: string,
): void {
throw new Error("machine is sold out");
}
public dispense(_machine: VendingMachine): PurchaseResult {
throw new Error("machine is sold out");
}
public cancel(machine: VendingMachine): readonly Denomination[] {
return machine.refundMoney();
}
}
const water = new Product("P-WATER", "Water");
const inventory = new Inventory([
new Slot("A1", water, 15, 2),
]);
const reserve = new Map<Denomination, number>([
[Denomination.Coin5, 2],
[Denomination.Coin2, 5],
[Denomination.Coin1, 5],
]);
const machine = new VendingMachine(
inventory,
new CashBox(reserve),
);
machine.insertCoin(Denomination.Note20);
machine.selectProduct("A1");
const purchase = machine.dispense();
console.log(`Dispensed: ${purchase.product.name}`);
console.log(`Change: ${purchase.change.map((value) => `₹${value}`).join(", ")}`);
console.log(`A1 remaining: ${inventory.quote("A1").count}`);
console.log(`State: ${machine.stateName}`);Dispensed: Water Change: ₹5 A1 remaining: 1 State: idle
The note first belongs to the current tender, not the permanent reserve. HasMoneyState confirms that a fifteen-rupee sale can return five rupees. DispensingState then releases one product, asks the cash box to settle, clears the selection, and installs the next ready state.
The change algorithm is intentionally bounded by the selected denominations and their reserve counts. It tries available counts from larger to smaller values but backtracks when that choice blocks an exact result, so a five-rupee coin cannot hide a valid three-by-two-rupee alternative.
Design-review checklist
This is where the interviewer turns a working purchase into a lifecycle review.
- Illegal transitions:
IdleStatecannot approve a selection,HasMoneyStatecannot decrement stock, andDispensingStatecannot accept a cancellation. Every rejected command leaves balance, selection, and count unchanged. - Why State beats a giant switch: The state teaching example showed the repeated-branch smell. Here four operations vary across four conditions, and transitions grow with sold-out and future maintenance modes. Concrete states keep one row of that transition table together, at the cost of more classes and navigation.
- Change ownership:
CashBoxcomputes change because it owns tender and reserve counts. Putting the calculation inDispensingStatewould make a temporary behavior object own persistent cash facts; putting it inInventorywould mix money with stock. - Integer money: Rupees are integers in this scope, avoiding floating-point equality during balance checks. A currency with subunits should use integer minor units or a
Moneyvalue object carrying currency and amount. - Exact-change failure: Approval checks
canSettle()before inventory changes. If no reserve combination exists, the machine remains inHasMoneyState; it must not discover the problem after dropping a product. - Refund semantics: Cancellation returns the exact tender, not an equivalent recomputed set of coins. The cash box has not merged those values into its permanent reserve before settlement.
- Slot sold out versus machine sold out: An empty A1 rejects that choice while other slots remain available.
SoldOutStaterepresents the stronger machine-wide condition and is selected after the final item leaves. - One physical machine and concurrency: The skeleton is single-threaded because one machine serves one ordered interaction. If remote commands can target it, serialize by machine ID or use a versioned transaction; a fleet-wide lock would be unnecessary contention.
- Failure ordering: Stock decrement and cash settlement are safe here because validation and execution are serialized in memory. Real motors introduce jams and uncertain delivery, requiring a transaction log and recovery state such as
DispenseFailedState, not a blind retry that may release twice. - Adding
MaintenanceState: ImplementVendingState, reject or refund customer commands, and define the authorized transitions into and out of maintenance. Existing public delegation stays unchanged; operational authorization belongs at the boundary. - State object lifetime: These concrete states hold no transaction data, so instances could be shared. Balance, selection, inventory, and cash stay on the context and resources; moving them into state instances would risk losing facts on every transition.
- Restocking: Restock belongs to an operator use case on
Inventory. If stock changes from zero to positive, the machine must move fromSoldOutStatetoIdleStatethrough an authorized maintenance or restock command. - Testing the transition table: Cover selection before money, multiple denominations, insufficient funds, one empty slot, all slots empty, unavailable change, cancel/refund, a successful sale, the last-item transition, double dispense, and commands during dispensing.
- With more time: Add a hardware port, durable transaction IDs, cash reconciliation, typed domain errors, restock authorization, telemetry events, and a recovery state for uncertain physical outcomes.
Checkpoint
Answer all three to mark this lesson complete
The completed machine makes the pattern earn its class count: states own conditional behavior, while the context, inventory, and cash box preserve shared facts. The next board-game case study uses fewer patterns and asks a different question—how much domain vocabulary should collapse into one honest abstraction.