Practice · Low Level Design · State Machines
Design a Vending Machine
Idle, has money, dispensing, out of stock: the State pattern in its natural home, plus giving correct change and refunding when something goes wrong.
Learn mode: the full solution is shown below.
Problem introduction
| Difficulty | Beginner |
| Time | 1–2 hours to design and code; about 45 minutes in an interview |
| Prerequisites | Enums and state machines, interfaces, a little about locks |
| Concepts | The State pattern, making change, keeping a failed sale from changing anything |
| Real world | Snack and drink machines in offices and hospitals, ticket machines, any kiosk that takes money |
The vending machine is the classic State pattern question. The same button ("select A1") must do different things depending on whether money is in, and the interviewer wants to see that you don't answer it with a pile of if status == … checks. Making change, and refunding cleanly when change can't be made, adds the real-world twist.
Problem statement
Picture a snack machine in a hospital corridor in Dhaka. It has slots like A1 (chips, 30 taka), B2 (juice, 65 taka) and C3 (water, 20 taka). It accepts coins and notes of 5, 10, 20, 50 and 100 taka, and it gives change from the coins inside it.
A customer inserts money, presses a slot code, and either gets the item plus change, or a clear message: not enough money, sold out, or "can't make change". They can press cancel at any time before buying and get their own coins back.
What we are building: the controller inside the machine: accepting coins, choosing items, giving change and refunds.
Why it exists: a machine that eats money or gives wrong change loses customers and money fast, and nobody is there to fix it.
In one line: accept money, sell an item if the customer has paid enough and change can be made, otherwise refund cleanly.
Requirements
Functional requirements
- Slots have a code, a product name, a price and a stock count.
- Accept 5, 10, 20, 50 and 100 taka; reject anything else.
- Insert adds to the customer's balance.
- Select sells the item if it exists, is in stock, and the balance covers the price. Change is given from the machine's coins, largest first.
- If the machine cannot make exact change, the sale is cancelled and the customer gets their coins back. Nothing else changes.
- Cancel returns exactly the coins the customer inserted.
- Clear messages: insert money first, not enough money (and how much more), sold out, unknown slot.
Non-functional requirements
| Requirement | What it means here |
|---|---|
| No lost money | Every path ends with either a sale plus correct change, or a full refund |
| Extensibility | New states (maintenance mode, card payment) without rewriting every method |
| Correctness under concurrency | The touch screen and the coin slot run on different threads; the balance must never be corrupted |
| Testability | Every rule testable without hardware |
Assumptions and out of scope
- One customer at a time at the physical machine.
- No card or bKash payments, no restocking UI, no audit reports.
What to hand in
- The states and what each event does in each state.
- A class diagram.
- Code for insert, select and cancel, including change-making.
- Tests for "not enough money", "sold out", "can't make change" and cancel.
Hint 1: what does 'select' do?
Write a small table: rows are states (no money, has money), columns are events (insert, select, cancel). If most cells are different, that's a sign for the State pattern.
Hint 2: where does change come from?
From the coins inside the machine, plus the coins the customer just inserted. Try largest coin first.
Hint 3: what if change fails halfway?
Work on a copy of the coin counts. Only replace the real counts if the whole amount was paid.
Hint 4: cancel
To return the customer's own coins, keep a list of what they inserted, not just a total.
Core entities
| Entity | Job in one sentence | Holds | Does |
|---|---|---|---|
Product | What's in a slot | name, price, stock | nothing: data |
Machine | The one entry point and owner of all money and stock | slots, coin bank, inserted coins, current state, the lock | Insert, Choose, Cancel |
state (interface) | How the machine answers events right now | nothing | insert, choose, cancel |
idle, hasMoney | The two states | nothing | their own answers to each event |
makeChange | Pays an amount from a coin bank, largest first | nothing | returns coins, new bank, ok |
Sale | What the customer receives | product, change | nothing: a value |
Relationships: Machine owns its slots, coin bank and inserted coins. It holds a current state and delegates every event to it. States change the machine (and swap themselves for the next state). makeChange is a pure function: it gets a copy of the bank and returns a new one.
Diagrams
Class diagram
How to read it: Machine holds one field of type state, and both idle and hasMoney implement that interface. Every public button (Insert, Choose, Cancel) just forwards to whichever state is current, so there is no switch on state anywhere. Machine owns its products, and Choose gives back a Sale value.
Notation: + is public, - is private. A filled diamond ◆ means owns (the part lives and dies with its owner), a plain arrow means holds a reference to, a dashed arrow means uses, and a dashed arrow with a hollow triangle means implements this interface.
Sequence: buying chips
How to read it: Each vertical dashed line is one object, and time runs downwards. A solid arrow is a call, a dashed arrow is the answer coming back, and the thin bars show when an object is busy. The lock is taken once in Machine and held until the reply. hasMoney does the work, and it is also the one that moves the machine back to idle. If makeChange had answered "not possible", the arrow back would have been an error and nothing would have changed.
State: what each event does
How to read it: two boxes are the two states; arrows are events. In Idle, insert moves to HasMoney, and select or cancel are refused. In HasMoney, insert stays, select either stays (not enough money, sold out) or sells and goes back to Idle, and cancel refunds and goes back to Idle. Each state is one class that answers the same three events in its own way.
Making change from the coin bank
How to read it: each box is a coin size with how many the machine holds. Change is paid by taking the largest coin that still fits. If the amount can't reach zero, nothing is taken: the sale is cancelled and the inserted coins come back.
Design principles
- Single responsibility:
Machineholds data and the lock; each state holds the rules for one situation;makeChangeonly does arithmetic. - Open/closed: adding a "maintenance" state (refuses everything, accepts a technician key) is a new type;
Insert,ChooseandCanceldon't change. - Encapsulation: coins, stock and state are private. Only the three public methods can change them, and they all take the lock.
- All or nothing: a sale either completes (stock down, bank updated, change given) or changes nothing.
makeChangeworks on a copy, which makes this easy.
Design patterns
State
- Why it's needed: select, insert and cancel mean different things with and without money.
- What it solves: replaces
if m.status == "idle" { … } else if …in every method with one object per state. - Without it: every new state (maintenance, card payment, out of order) edits every method.
- Trade-offs: more types. For two states a switch would be fine; the pattern pays off as states grow, which is exactly the follow-up interviewers ask.
- Where it is:
state.go.Machineholdsstateand callsm.state.choose(m, slot).
Patterns we did not use
- Strategy for change-making: greedy is correct for 100/50/20/10/5. If the machine ever gets odd coins, swap
makeChangefor a dynamic-programming version; it's one function, so it doesn't need an interface yet. - Singleton for the machine: each physical machine is one object created in
main; tests create their own.
Complete solution in Go
Project structure
vending/
├── go.mod
├── product.go Product, Sale, accepted coins
├── change.go makeChange: pay an amount from a coin bank
├── state.go the State pattern: idle and hasMoney
├── machine.go Machine: the entry point and the lock
├── machine_test.go tests, including a concurrency test
└── cmd/demo/main.go a customer at the machine
Step 1: products and coins
// product.go
package vending
// Product sits in one slot. Price is in taka.
type Product struct {
Name string
Price int
Stock int
}
// Coins the machine accepts, largest first (also the order change is given in).
var Denominations = []int{100, 50, 20, 10, 5}
func validCoin(c int) bool {
for _, d := range Denominations {
if d == c {
return true
}
}
return false
}
// Sale is what the customer gets back from a successful purchase.
type Sale struct {
Product string
Change []int
}
Step 2: making change, on a copy
// change.go
package vending
// makeChange pays `amount` from the coins in `bank`, largest first, and returns the coins
// used. It works on a copy, so a failed attempt changes nothing. Greedy is correct for
// 100/50/20/10/5; for odd coin sets you'd need dynamic programming.
func makeChange(amount int, bank map[int]int) ([]int, map[int]int, bool) {
left := map[int]int{}
for d, n := range bank {
left[d] = n
}
var coins []int
for _, d := range Denominations {
for amount >= d && left[d] > 0 {
amount -= d
left[d]--
coins = append(coins, d)
}
}
return coins, left, amount == 0
}
Step 3: the states
hasMoney.choose adds the inserted coins to a copy of the bank before computing change, so the customer's own coins can be used. Only when change succeeds does the copy replace the real bank.
// state.go
package vending
import (
"errors"
"fmt"
)
var (
ErrBadCoin = errors.New("not a valid coin")
ErrInsertFirst = errors.New("insert money first")
ErrNothing = errors.New("nothing to refund")
ErrUnknownSlot = errors.New("no such slot")
)
// state is the State pattern: each state answers the same three events in its own way.
type state interface {
insert(m *Machine, coin int) error
choose(m *Machine, slot string) (Sale, error)
cancel(m *Machine) ([]int, error)
}
// idle: no money inserted.
type idle struct{}
func (idle) insert(m *Machine, coin int) error {
m.inserted = append(m.inserted, coin)
m.state = hasMoney{}
return nil
}
func (idle) choose(*Machine, string) (Sale, error) { return Sale{}, ErrInsertFirst }
func (idle) cancel(*Machine) ([]int, error) { return nil, ErrNothing }
// hasMoney: the customer has a balance and can buy or cancel.
type hasMoney struct{}
func (hasMoney) insert(m *Machine, coin int) error {
m.inserted = append(m.inserted, coin)
return nil
}
func (hasMoney) choose(m *Machine, slot string) (Sale, error) {
p, ok := m.slots[slot]
if !ok {
return Sale{}, ErrUnknownSlot
}
if p.Stock == 0 {
return Sale{}, fmt.Errorf("%s is sold out", p.Name)
}
if bal := m.balance(); bal < p.Price {
return Sale{}, fmt.Errorf("%s costs %d tk, insert %d tk more", p.Name, p.Price, p.Price-bal)
}
// The inserted coins join the bank first, so they can be used for change.
bank := map[int]int{}
for d, n := range m.bank {
bank[d] = n
}
for _, c := range m.inserted {
bank[c]++
}
change, after, ok := makeChange(m.balance()-p.Price, bank)
if !ok {
refund := m.inserted
m.inserted, m.state = nil, idle{}
return Sale{}, fmt.Errorf("cannot make change, refunded %v", refund)
}
m.bank = after
p.Stock--
m.inserted, m.state = nil, idle{}
return Sale{Product: p.Name, Change: change}, nil
}
func (hasMoney) cancel(m *Machine) ([]int, error) {
refund := m.inserted
m.inserted, m.state = nil, idle{}
return refund, nil
}
Step 4: the machine
Every public method takes the lock and hands the event to the current state.
// machine.go
package vending
import "sync"
// Machine is the one entry point. The current state object decides how each event is handled.
type Machine struct {
mu sync.Mutex
state state
slots map[string]*Product
bank map[int]int // coins available for change
inserted []int // this customer's coins, returned exactly on cancel
}
func NewMachine(slots map[string]*Product, bank map[int]int) *Machine {
return &Machine{state: idle{}, slots: slots, bank: bank}
}
func (m *Machine) Insert(coin int) error {
m.mu.Lock()
defer m.mu.Unlock()
if !validCoin(coin) {
return ErrBadCoin
}
return m.state.insert(m, coin)
}
func (m *Machine) Choose(slot string) (Sale, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.state.choose(m, slot)
}
func (m *Machine) Cancel() ([]int, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.state.cancel(m)
}
// Balance is the customer's money in the machine.
func (m *Machine) Balance() int {
m.mu.Lock()
defer m.mu.Unlock()
return m.balance()
}
// balance is for code that already holds the lock (the states).
func (m *Machine) balance() int {
total := 0
for _, c := range m.inserted {
total += c
}
return total
}
// Stock reports how many are left in a slot.
func (m *Machine) Stock(slot string) int {
m.mu.Lock()
defer m.mu.Unlock()
return m.slots[slot].Stock
}
Step 5: run it
// cmd/demo/main.go
package main
import (
"fmt"
"vending"
)
func main() {
m := vending.NewMachine(
map[string]*vending.Product{
"A1": {Name: "Chips", Price: 30, Stock: 2},
"B2": {Name: "Juice", Price: 65, Stock: 3},
"C3": {Name: "Water", Price: 20, Stock: 0},
},
map[int]int{20: 2, 10: 2, 5: 2}, // coins loaded for change
)
show := func(action, result string) { fmt.Printf("%-14s -> %s\n", action, result) }
insert := func(c int) {
if err := m.Insert(c); err != nil {
show(fmt.Sprint("insert ", c), "rejected: "+err.Error())
return
}
show(fmt.Sprint("insert ", c), fmt.Sprintf("balance %d tk", m.Balance()))
}
choose := func(slot string) {
s, err := m.Choose(slot)
if err != nil {
show("select "+slot, err.Error())
return
}
total := 0
for _, c := range s.Change {
total += c
}
show("select "+slot, fmt.Sprintf("%s, change %d tk %v", s.Product, total, s.Change))
}
insert(20)
choose("A1")
insert(20)
choose("A1")
choose("B2")
insert(100)
choose("B2")
insert(50)
choose("C3")
if r, err := m.Cancel(); err == nil {
show("cancel", fmt.Sprintf("refunded %v", r))
}
insert(3)
insert(100)
choose("A1")
fmt.Printf("stock: A1 %d, B2 %d, C3 %d\n", m.Stock("A1"), m.Stock("B2"), m.Stock("C3"))
}
$ go run ./cmd/demo
insert 20 -> balance 20 tk
select A1 -> Chips costs 30 tk, insert 10 tk more
insert 20 -> balance 40 tk
select A1 -> Chips, change 10 tk [10]
select B2 -> insert money first
insert 100 -> balance 100 tk
select B2 -> Juice, change 35 tk [20 10 5]
insert 50 -> balance 50 tk
select C3 -> Water is sold out
cancel -> refunded [50]
insert 3 -> rejected: not a valid coin
insert 100 -> balance 100 tk
select A1 -> cannot make change, refunded [100]
stock: A1 1, B2 2, C3 0
Reading the output:
- 20, then select A1: chips cost 30, so the machine asks for 10 more and keeps the 20.
- Another 20, select A1: balance 40, chips 30, change 10.
- select B2 with no money: Idle refuses with "insert money first".
- 100, select B2: juice is 65, change 35 is paid as 20 + 10 + 5.
- 50, select C3: water is sold out; the 50 stays, and cancel returns exactly
[50]. - insert 3: not a coin the machine takes.
- 100, select A1: change of 70 is needed, but the machine only has three 20s and one 5 left (65 taka). It refunds the 100 and sells nothing. The final stock line confirms A1 still has 1.
The other five languages
Same design, same story, same output. Every program was run.
from __future__ import annotations
import threading
from dataclasses import dataclass
DENOMINATIONS = [100, 50, 20, 10, 5] # largest first
@dataclass
class Product:
name: str
price: int
stock: int
class VendingError(Exception):
pass
def make_change(amount: int, bank: dict[int, int]) -> tuple[list[int], dict[int, int], bool]:
"""Pay `amount` from `bank`, largest coin first, working on a copy."""
left, coins = dict(bank), []
for d in DENOMINATIONS:
while amount >= d and left.get(d, 0) > 0:
amount -= d
left[d] -= 1
coins.append(d)
return coins, left, amount == 0
class Idle:
"""State: no money inserted."""
def insert(self, m: Machine, coin: int) -> None:
m.inserted.append(coin)
m.state = HasMoney()
def choose(self, m: Machine, slot: str):
raise VendingError("insert money first")
def cancel(self, m: Machine) -> list[int]:
raise VendingError("nothing to refund")
class HasMoney:
"""State: the customer has a balance and can buy or cancel."""
def insert(self, m: Machine, coin: int) -> None:
m.inserted.append(coin)
def choose(self, m: Machine, slot: str) -> tuple[str, list[int]]:
p = m.slots.get(slot)
if p is None:
raise VendingError("no such slot")
if p.stock == 0:
raise VendingError(f"{p.name} is sold out")
bal = sum(m.inserted)
if bal < p.price:
raise VendingError(f"{p.name} costs {p.price} tk, insert {p.price - bal} tk more")
bank = dict(m.bank)
for c in m.inserted: # inserted coins can be used for change
bank[c] = bank.get(c, 0) + 1
change, after, ok = make_change(bal - p.price, bank)
refund = m.inserted
m.inserted, m.state = [], Idle()
if not ok:
raise VendingError(f"cannot make change, refunded {fmt(refund)}")
m.bank = after
p.stock -= 1
return p.name, change
def cancel(self, m: Machine) -> list[int]:
refund = m.inserted
m.inserted, m.state = [], Idle()
return refund
class Machine:
def __init__(self, slots: dict[str, Product], bank: dict[int, int]):
self._lock = threading.Lock()
self.state: Idle | HasMoney = Idle()
self.slots, self.bank = slots, bank
self.inserted: list[int] = []
def insert(self, coin: int) -> None:
with self._lock:
if coin not in DENOMINATIONS:
raise VendingError("not a valid coin")
self.state.insert(self, coin)
def choose(self, slot: str) -> tuple[str, list[int]]:
with self._lock:
return self.state.choose(self, slot)
def cancel(self) -> list[int]:
with self._lock:
return self.state.cancel(self)
def balance(self) -> int:
with self._lock:
return sum(self.inserted)
def fmt(coins: list[int]) -> str:
return "[" + " ".join(map(str, coins)) + "]" # same format as Go
m = Machine({"A1": Product("Chips", 30, 2), "B2": Product("Juice", 65, 3), "C3": Product("Water", 20, 0)},
{20: 2, 10: 2, 5: 2})
def show(action: str, result: str) -> None:
print(f"{action:<14} -> {result}")
def insert(c: int) -> None:
try:
m.insert(c)
show(f"insert {c}", f"balance {m.balance()} tk")
except VendingError as e:
show(f"insert {c}", f"rejected: {e}")
def choose(slot: str) -> None:
try:
name, change = m.choose(slot)
show(f"select {slot}", f"{name}, change {sum(change)} tk {fmt(change)}")
except VendingError as e:
show(f"select {slot}", str(e))
insert(20)
choose("A1")
insert(20)
choose("A1")
choose("B2")
insert(100)
choose("B2")
insert(50)
choose("C3")
show("cancel", f"refunded {fmt(m.cancel())}")
insert(3)
insert(100)
choose("A1")
print(f"stock: A1 {m.slots['A1'].stock}, B2 {m.slots['B2'].stock}, C3 {m.slots['C3'].stock}")const DENOMINATIONS = [100, 50, 20, 10, 5]; // largest first
interface Product {
readonly name: string;
readonly price: number;
stock: number;
}
type Bank = Map<number, number>;
// Pay `amount` from `bank`, largest coin first, working on a copy.
function makeChange(amount: number, bank: Bank): [number[], Bank, boolean] {
const left = new Map(bank);
const coins: number[] = [];
for (const d of DENOMINATIONS) {
while (amount >= d && (left.get(d) ?? 0) > 0) {
amount -= d;
left.set(d, left.get(d)! - 1);
coins.push(d);
}
}
return [coins, left, amount === 0];
}
const fmt = (coins: number[]) => `[${coins.join(" ")}]`; // same format as Go
// The State pattern: each state answers the same three events in its own way.
interface State {
insert(m: Machine, coin: number): void;
choose(m: Machine, slot: string): { product: string; change: number[] };
cancel(m: Machine): number[];
}
const idle: State = {
insert(m, coin) {
m.inserted.push(coin);
m.state = hasMoney;
},
choose() {
throw new Error("insert money first");
},
cancel() {
throw new Error("nothing to refund");
},
};
const hasMoney: State = {
insert(m, coin) {
m.inserted.push(coin);
},
choose(m, slot) {
const p = m.slots.get(slot);
if (!p) throw new Error("no such slot");
if (p.stock === 0) throw new Error(`${p.name} is sold out`);
const bal = m.balance();
if (bal < p.price) throw new Error(`${p.name} costs ${p.price} tk, insert ${p.price - bal} tk more`);
const bank = new Map(m.bank);
for (const c of m.inserted) bank.set(c, (bank.get(c) ?? 0) + 1); // inserted coins can be used for change
const [change, after, ok] = makeChange(bal - p.price, bank);
const refund = m.inserted;
m.inserted = [];
m.state = idle;
if (!ok) throw new Error(`cannot make change, refunded ${fmt(refund)}`);
m.bank = after;
p.stock--;
return { product: p.name, change };
},
cancel(m) {
const refund = m.inserted;
m.inserted = [];
m.state = idle;
return refund;
},
};
// No lock: JavaScript runs this on one thread and nothing here awaits.
class Machine {
state: State = idle;
inserted: number[] = [];
readonly slots: Map<string, Product>;
bank: Bank;
constructor(slots: Map<string, Product>, bank: Bank) {
this.slots = slots;
this.bank = bank;
}
insert(coin: number): void {
if (!DENOMINATIONS.includes(coin)) throw new Error("not a valid coin");
this.state.insert(this, coin);
}
choose(slot: string) {
return this.state.choose(this, slot);
}
cancel(): number[] {
return this.state.cancel(this);
}
balance(): number {
return this.inserted.reduce((a, b) => a + b, 0);
}
}
const m = new Machine(
new Map([
["A1", { name: "Chips", price: 30, stock: 2 }],
["B2", { name: "Juice", price: 65, stock: 3 }],
["C3", { name: "Water", price: 20, stock: 0 }],
]),
new Map([[20, 2], [10, 2], [5, 2]]),
);
const show = (action: string, result: string) => console.log(`${action.padEnd(14)} -> ${result}`);
function insert(c: number): void {
try {
m.insert(c);
show(`insert ${c}`, `balance ${m.balance()} tk`);
} catch (e) {
show(`insert ${c}`, `rejected: ${(e as Error).message}`);
}
}
function choose(slot: string): void {
try {
const s = m.choose(slot);
show(`select ${slot}`, `${s.product}, change ${s.change.reduce((a, b) => a + b, 0)} tk ${fmt(s.change)}`);
} catch (e) {
show(`select ${slot}`, (e as Error).message);
}
}
insert(20);
choose("A1");
insert(20);
choose("A1");
choose("B2");
insert(100);
choose("B2");
insert(50);
choose("C3");
show("cancel", `refunded ${fmt(m.cancel())}`);
insert(3);
insert(100);
choose("A1");
const st = (s: string) => m.slots.get(s)!.stock;
console.log(`stock: A1 ${st("A1")}, B2 ${st("B2")}, C3 ${st("C3")}`);use std::collections::HashMap;
use std::sync::Mutex;
const DENOMINATIONS: [u32; 5] = [100, 50, 20, 10, 5]; // largest first
struct Product { name: &'static str, price: u32, stock: u32 }
// Pay `amount` from `bank`, largest coin first, working on a copy.
fn make_change(mut amount: u32, bank: &HashMap<u32, u32>) -> (Vec<u32>, HashMap<u32, u32>, bool) {
let mut left = bank.clone();
let mut coins = vec![];
for d in DENOMINATIONS {
while amount >= d && left.get(&d).copied().unwrap_or(0) > 0 {
amount -= d;
*left.get_mut(&d).unwrap() -= 1;
coins.push(d);
}
}
(coins, left, amount == 0)
}
fn fmt(coins: &[u32]) -> String { // same format as Go
format!("[{}]", coins.iter().map(|c| c.to_string()).collect::<Vec<_>>().join(" "))
}
// The states as an enum; each event is a match on the current state.
#[derive(Clone, Copy, PartialEq)]
enum State { Idle, HasMoney }
struct Inner { state: State, slots: HashMap<&'static str, Product>, bank: HashMap<u32, u32>, inserted: Vec<u32> }
struct Machine { inner: Mutex<Inner> } // everything that changes lives inside the lock
impl Machine {
fn insert(&self, coin: u32) -> Result<(), String> {
if !DENOMINATIONS.contains(&coin) { return Err("not a valid coin".into()); }
let mut m = self.inner.lock().unwrap();
m.inserted.push(coin);
m.state = State::HasMoney;
Ok(())
}
fn choose(&self, slot: &str) -> Result<(&'static str, Vec<u32>), String> {
let mut guard = self.inner.lock().unwrap();
let m = &mut *guard;
if m.state == State::Idle { return Err("insert money first".into()); }
let bal: u32 = m.inserted.iter().sum();
let p = m.slots.get(slot).ok_or("no such slot")?;
if p.stock == 0 { return Err(format!("{} is sold out", p.name)); }
if bal < p.price { return Err(format!("{} costs {} tk, insert {} tk more", p.name, p.price, p.price - bal)); }
let (name, price) = (p.name, p.price);
let mut bank = m.bank.clone();
for &c in &m.inserted { *bank.entry(c).or_insert(0) += 1; } // inserted coins can be used for change
let (change, after, ok) = make_change(bal - price, &bank);
let refund = std::mem::take(&mut m.inserted);
m.state = State::Idle;
if !ok { return Err(format!("cannot make change, refunded {}", fmt(&refund))); }
m.bank = after;
m.slots.get_mut(slot).unwrap().stock -= 1;
Ok((name, change))
}
fn cancel(&self) -> Result<Vec<u32>, String> {
let mut m = self.inner.lock().unwrap();
if m.state == State::Idle { return Err("nothing to refund".into()); }
m.state = State::Idle;
Ok(std::mem::take(&mut m.inserted))
}
fn balance(&self) -> u32 { self.inner.lock().unwrap().inserted.iter().sum() }
fn stock(&self, slot: &str) -> u32 { self.inner.lock().unwrap().slots[slot].stock }
}
fn main() {
let slots = HashMap::from([
("A1", Product { name: "Chips", price: 30, stock: 2 }),
("B2", Product { name: "Juice", price: 65, stock: 3 }),
("C3", Product { name: "Water", price: 20, stock: 0 }),
]);
let m = Machine { inner: Mutex::new(Inner { state: State::Idle, slots, bank: HashMap::from([(20, 2), (10, 2), (5, 2)]), inserted: vec![] }) };
let show = |action: &str, result: &str| println!("{action:<14} -> {result}");
let insert = |c: u32| match m.insert(c) {
Ok(()) => show(&format!("insert {c}"), &format!("balance {} tk", m.balance())),
Err(e) => show(&format!("insert {c}"), &format!("rejected: {e}")),
};
let choose = |slot: &str| match m.choose(slot) {
Ok((name, change)) => show(&format!("select {slot}"), &format!("{name}, change {} tk {}", change.iter().sum::<u32>(), fmt(&change))),
Err(e) => show(&format!("select {slot}"), &e),
};
insert(20);
choose("A1");
insert(20);
choose("A1");
choose("B2");
insert(100);
choose("B2");
insert(50);
choose("C3");
if let Ok(r) = m.cancel() { show("cancel", &format!("refunded {}", fmt(&r))); }
insert(3);
insert(100);
choose("A1");
println!("stock: A1 {}, B2 {}, C3 {}", m.stock("A1"), m.stock("B2"), m.stock("C3"));
}import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class Main {
static final int[] DENOMINATIONS = {100, 50, 20, 10, 5}; // largest first
static final class Product {
final String name;
final int price;
int stock;
Product(String name, int price, int stock) { this.name = name; this.price = price; this.stock = stock; }
}
static final class VendingException extends Exception {
private static final long serialVersionUID = 1L;
VendingException(String msg) { super(msg); }
}
record Sale(String product, List<Integer> change) {}
record Change(List<Integer> coins, Map<Integer, Integer> left, boolean ok) {}
// Pay `amount` from `bank`, largest coin first, working on a copy.
static Change makeChange(int amount, Map<Integer, Integer> bank) {
Map<Integer, Integer> left = new HashMap<>(bank);
List<Integer> coins = new ArrayList<>();
for (int d : DENOMINATIONS) {
while (amount >= d && left.getOrDefault(d, 0) > 0) {
amount -= d;
left.merge(d, -1, Integer::sum);
coins.add(d);
}
}
return new Change(coins, left, amount == 0);
}
static String fmt(List<Integer> coins) { // same format as Go
return coins.stream().map(String::valueOf).collect(Collectors.joining(" ", "[", "]"));
}
// The State pattern: each state answers the same three events in its own way.
interface State {
void insert(Machine m, int coin);
Sale choose(Machine m, String slot) throws VendingException;
List<Integer> cancel(Machine m) throws VendingException;
}
static final class Idle implements State {
public void insert(Machine m, int coin) { m.inserted.add(coin); m.state = new HasMoney(); }
public Sale choose(Machine m, String slot) throws VendingException { throw new VendingException("insert money first"); }
public List<Integer> cancel(Machine m) throws VendingException { throw new VendingException("nothing to refund"); }
}
static final class HasMoney implements State {
public void insert(Machine m, int coin) { m.inserted.add(coin); }
public Sale choose(Machine m, String slot) throws VendingException {
Product p = m.slots.get(slot);
if (p == null) throw new VendingException("no such slot");
if (p.stock == 0) throw new VendingException(p.name + " is sold out");
int bal = m.balanceUnlocked();
if (bal < p.price) throw new VendingException(p.name + " costs " + p.price + " tk, insert " + (p.price - bal) + " tk more");
Map<Integer, Integer> bank = new HashMap<>(m.bank);
for (int c : m.inserted) bank.merge(c, 1, Integer::sum); // inserted coins can be used for change
Change ch = makeChange(bal - p.price, bank);
List<Integer> refund = m.inserted;
m.inserted = new ArrayList<>();
m.state = new Idle();
if (!ch.ok()) throw new VendingException("cannot make change, refunded " + fmt(refund));
m.bank = ch.left();
p.stock--;
return new Sale(p.name, ch.coins());
}
public List<Integer> cancel(Machine m) {
List<Integer> refund = m.inserted;
m.inserted = new ArrayList<>();
m.state = new Idle();
return refund;
}
}
static final class Machine {
State state = new Idle();
final Map<String, Product> slots;
Map<Integer, Integer> bank;
List<Integer> inserted = new ArrayList<>();
Machine(Map<String, Product> slots, Map<Integer, Integer> bank) { this.slots = slots; this.bank = bank; }
synchronized void insert(int coin) throws VendingException {
boolean valid = false;
for (int d : DENOMINATIONS) valid |= d == coin;
if (!valid) throw new VendingException("not a valid coin");
state.insert(this, coin);
}
synchronized Sale choose(String slot) throws VendingException { return state.choose(this, slot); }
synchronized List<Integer> cancel() throws VendingException { return state.cancel(this); }
synchronized int balance() { return balanceUnlocked(); }
int balanceUnlocked() { return inserted.stream().mapToInt(Integer::intValue).sum(); }
}
static Machine m;
static void show(String action, String result) { System.out.printf("%-14s -> %s%n", action, result); }
static void insert(int c) {
try {
m.insert(c);
show("insert " + c, "balance " + m.balance() + " tk");
} catch (VendingException e) {
show("insert " + c, "rejected: " + e.getMessage());
}
}
static void choose(String slot) {
try {
Sale s = m.choose(slot);
int total = s.change().stream().mapToInt(Integer::intValue).sum();
show("select " + slot, s.product() + ", change " + total + " tk " + fmt(s.change()));
} catch (VendingException e) {
show("select " + slot, e.getMessage());
}
}
public static void main(String[] args) throws VendingException {
Map<String, Product> slots = new HashMap<>();
slots.put("A1", new Product("Chips", 30, 2));
slots.put("B2", new Product("Juice", 65, 3));
slots.put("C3", new Product("Water", 20, 0));
m = new Machine(slots, new HashMap<>(Map.of(20, 2, 10, 2, 5, 2)));
insert(20);
choose("A1");
insert(20);
choose("A1");
choose("B2");
insert(100);
choose("B2");
insert(50);
choose("C3");
show("cancel", "refunded " + fmt(m.cancel()));
insert(3);
insert(100);
choose("A1");
System.out.printf("stock: A1 %d, B2 %d, C3 %d%n", slots.get("A1").stock, slots.get("B2").stock, slots.get("C3").stock);
}
}#include <cstdio>
#include <map>
#include <memory>
#include <mutex>
#include <numeric>
#include <stdexcept>
#include <string>
#include <vector>
const std::vector<int> kDenominations{100, 50, 20, 10, 5}; // largest first
struct Product {
std::string name;
int price, stock;
};
struct VendingError : std::runtime_error {
using std::runtime_error::runtime_error;
};
std::string fmt(const std::vector<int>& coins) { // same format as Go
std::string s = "[";
for (size_t i = 0; i < coins.size(); i++) s += (i ? " " : "") + std::to_string(coins[i]);
return s + "]";
}
// Pay `amount` from `bank`, largest coin first, working on a copy.
bool makeChange(int amount, std::map<int, int>& bank, std::vector<int>& coins) {
for (int d : kDenominations)
while (amount >= d && bank[d] > 0) {
amount -= d;
bank[d]--;
coins.push_back(d);
}
return amount == 0;
}
struct Sale {
std::string product;
std::vector<int> change;
};
class Machine;
// The State pattern: each state answers the same three events in its own way.
class State {
public:
virtual ~State() = default;
virtual void insert(Machine& m, int coin) = 0;
virtual Sale choose(Machine& m, const std::string& slot) = 0;
virtual std::vector<int> cancel(Machine& m) = 0;
};
class Machine {
public:
Machine(std::map<std::string, Product> slots, std::map<int, int> bank);
void insert(int coin);
Sale choose(const std::string& slot);
std::vector<int> cancel();
int balance();
int stock(const std::string& slot) {
std::lock_guard lock(mu_);
return slots.at(slot).stock;
}
// Used by the states, always under the lock.
std::unique_ptr<State> state;
std::map<std::string, Product> slots;
std::map<int, int> bank;
std::vector<int> inserted;
int balanceUnlocked() const { return std::accumulate(inserted.begin(), inserted.end(), 0); }
private:
std::mutex mu_;
};
class Idle : public State {
public:
void insert(Machine& m, int coin) override;
Sale choose(Machine&, const std::string&) override { throw VendingError("insert money first"); }
std::vector<int> cancel(Machine&) override { throw VendingError("nothing to refund"); }
};
class HasMoney : public State {
public:
void insert(Machine& m, int coin) override { m.inserted.push_back(coin); }
Sale choose(Machine& m, const std::string& slot) override {
auto it = m.slots.find(slot);
if (it == m.slots.end()) throw VendingError("no such slot");
Product& p = it->second;
if (p.stock == 0) throw VendingError(p.name + " is sold out");
int bal = m.balanceUnlocked();
if (bal < p.price)
throw VendingError(p.name + " costs " + std::to_string(p.price) + " tk, insert " + std::to_string(p.price - bal) + " tk more");
std::map<int, int> bank = m.bank;
for (int c : m.inserted) bank[c]++; // inserted coins can be used for change
std::vector<int> change;
bool ok = makeChange(bal - p.price, bank, change);
std::vector<int> refund = std::move(m.inserted);
m.inserted.clear();
m.state = std::make_unique<Idle>(); // `this` is destroyed here: touch only locals from now on
if (!ok) throw VendingError("cannot make change, refunded " + fmt(refund));
m.bank = bank;
p.stock--;
return {p.name, change};
}
std::vector<int> cancel(Machine& m) override {
std::vector<int> refund = std::move(m.inserted);
m.inserted.clear();
m.state = std::make_unique<Idle>();
return refund;
}
};
void Idle::insert(Machine& m, int coin) {
m.inserted.push_back(coin);
m.state = std::make_unique<HasMoney>(); // replaces (destroys) this Idle object; return right away
}
Machine::Machine(std::map<std::string, Product> s, std::map<int, int> b)
: state(std::make_unique<Idle>()), slots(std::move(s)), bank(std::move(b)) {}
void Machine::insert(int coin) {
std::lock_guard lock(mu_);
bool valid = false;
for (int d : kDenominations) valid |= d == coin;
if (!valid) throw VendingError("not a valid coin");
state->insert(*this, coin);
}
Sale Machine::choose(const std::string& slot) {
std::lock_guard lock(mu_);
return state->choose(*this, slot);
}
std::vector<int> Machine::cancel() {
std::lock_guard lock(mu_);
return state->cancel(*this);
}
int Machine::balance() {
std::lock_guard lock(mu_);
return balanceUnlocked();
}
int main() {
Machine m({{"A1", {"Chips", 30, 2}}, {"B2", {"Juice", 65, 3}}, {"C3", {"Water", 20, 0}}}, {{20, 2}, {10, 2}, {5, 2}});
auto show = [](const std::string& action, const std::string& result) {
std::printf("%-14s -> %s\n", action.c_str(), result.c_str());
};
auto insert = [&](int c) {
try {
m.insert(c);
show("insert " + std::to_string(c), "balance " + std::to_string(m.balance()) + " tk");
} catch (const VendingError& e) {
show("insert " + std::to_string(c), std::string("rejected: ") + e.what());
}
};
auto choose = [&](const std::string& slot) {
try {
Sale s = m.choose(slot);
int total = std::accumulate(s.change.begin(), s.change.end(), 0);
show("select " + slot, s.product + ", change " + std::to_string(total) + " tk " + fmt(s.change));
} catch (const VendingError& e) {
show("select " + slot, e.what());
}
};
insert(20);
choose("A1");
insert(20);
choose("A1");
choose("B2");
insert(100);
choose("B2");
insert(50);
choose("C3");
show("cancel", "refunded " + fmt(m.cancel()));
insert(3);
insert(100);
choose("A1");
std::printf("stock: A1 %d, B2 %d, C3 %d\n", m.stock("A1"), m.stock("B2"), m.stock("C3"));
}What changes from language to language:
| States | The lock | |
|---|---|---|
| Go | an interface with two small types | sync.Mutex in every public method |
| Python | two classes with the same methods (duck typing) | threading.Lock with with |
| TypeScript | two object literals typed as State | none needed: one thread, no await |
| Rust | an enum State and match: the compiler checks every state is handled | all data inside one Mutex |
| Java | an interface with two classes | synchronized methods |
| C++ | an abstract class, unique_ptr<State> | std::lock_guard |
One C++ detail worth knowing: when a state replaces itself (m.state = make_unique<Idle>()), the old state object is destroyed on that line, so the code touches only locals afterwards.
Testing
// machine_test.go
package vending
import (
"errors"
"slices"
"sync"
"testing"
)
func newTestMachine() *Machine {
return NewMachine(
map[string]*Product{
"A1": {Name: "Chips", Price: 30, Stock: 2},
"C3": {Name: "Water", Price: 20, Stock: 0},
},
map[int]int{20: 2, 10: 2, 5: 2},
)
}
func TestChooseBeforeInsert(t *testing.T) {
m := newTestMachine()
if _, err := m.Choose("A1"); !errors.Is(err, ErrInsertFirst) {
t.Fatalf("got %v", err)
}
if _, err := m.Cancel(); !errors.Is(err, ErrNothing) {
t.Fatalf("cancel in idle: %v", err)
}
}
func TestBadCoinChangesNothing(t *testing.T) {
m := newTestMachine()
if err := m.Insert(3); !errors.Is(err, ErrBadCoin) {
t.Fatalf("got %v", err)
}
if m.Balance() != 0 {
t.Fatalf("balance %d", m.Balance())
}
}
func TestNotEnoughMoneyKeepsBalance(t *testing.T) {
m := newTestMachine()
m.Insert(20)
if _, err := m.Choose("A1"); err == nil {
t.Fatal("expected an error")
}
if m.Balance() != 20 || m.Stock("A1") != 2 {
t.Fatalf("balance %d stock %d", m.Balance(), m.Stock("A1"))
}
}
func TestExactChange(t *testing.T) {
m := newTestMachine()
m.Insert(50)
s, err := m.Choose("A1")
if err != nil || s.Product != "Chips" || !slices.Equal(s.Change, []int{20}) {
t.Fatalf("got %+v, %v", s, err)
}
if m.Stock("A1") != 1 || m.Balance() != 0 {
t.Fatal("stock or balance not updated")
}
}
func TestSoldOut(t *testing.T) {
m := newTestMachine()
m.Insert(20)
if _, err := m.Choose("C3"); err == nil || err.Error() != "Water is sold out" {
t.Fatalf("got %v", err)
}
if m.Balance() != 20 {
t.Fatal("sold out should keep the money in")
}
}
func TestCancelReturnsSameCoins(t *testing.T) {
m := newTestMachine()
m.Insert(10)
m.Insert(5)
got, err := m.Cancel()
if err != nil || !slices.Equal(got, []int{10, 5}) {
t.Fatalf("got %v, %v", got, err)
}
}
func TestNoChangeRefundsAndKeepsBank(t *testing.T) {
m := NewMachine(map[string]*Product{"A1": {Name: "Chips", Price: 30, Stock: 1}}, map[int]int{})
m.Insert(100)
if _, err := m.Choose("A1"); err == nil {
t.Fatal("expected cannot make change")
}
if m.Stock("A1") != 1 || m.Balance() != 0 || len(m.bank) != 0 {
t.Fatal("a failed sale must change nothing")
}
}
// Many goroutines inserting at once must not lose a coin. Run with -race.
func TestConcurrentInserts(t *testing.T) {
m := newTestMachine()
var wg sync.WaitGroup
for range 50 {
wg.Add(1)
go func() { defer wg.Done(); m.Insert(10) }()
}
wg.Wait()
if m.Balance() != 500 {
t.Fatalf("balance %d, want 500", m.Balance())
}
}
$ go test -race -v ./...
=== RUN TestChooseBeforeInsert
--- PASS: TestChooseBeforeInsert (0.00s)
=== RUN TestBadCoinChangesNothing
--- PASS: TestBadCoinChangesNothing (0.00s)
=== RUN TestNotEnoughMoneyKeepsBalance
--- PASS: TestNotEnoughMoneyKeepsBalance (0.00s)
=== RUN TestExactChange
--- PASS: TestExactChange (0.00s)
=== RUN TestSoldOut
--- PASS: TestSoldOut (0.00s)
=== RUN TestCancelReturnsSameCoins
--- PASS: TestCancelReturnsSameCoins (0.00s)
=== RUN TestNoChangeRefundsAndKeepsBank
--- PASS: TestNoChangeRefundsAndKeepsBank (0.00s)
=== RUN TestConcurrentInserts
--- PASS: TestConcurrentInserts (0.00s)
PASS
ok vending 1.012s
? vending/cmd/demo [no test files]
| Test | Checks |
|---|---|
TestChooseBeforeInsert | Idle refuses select and cancel |
TestBadCoinChangesNothing | A 3-taka coin is rejected and the balance stays 0 |
TestNotEnoughMoneyKeepsBalance | A short select keeps the money and the stock |
TestExactChange | 50 for 30-taka chips gives one 20 back |
TestSoldOut | Sold out keeps the money in |
TestCancelReturnsSameCoins | Cancel returns [10 5], the coins actually inserted |
TestNoChangeRefundsAndKeepsBank | A failed sale changes nothing: stock, balance, bank |
TestConcurrentInserts | 50 goroutines insert 10 each: balance is exactly 500 |
We removed the lock from Insert and ran the concurrency test again: the race detector reported 7 data races and the test failed. With the lock it passes.
Design review
What's good: every path ends in a sale or a full refund; the State pattern keeps each situation's rules together; change-making is a pure function that's easy to test.
| "What if…" | What to change |
|---|---|
| Card or bKash payment | A new payment source next to coins; selecting charges the card instead of using the balance. Likely a new state, PayingByCard. |
| Maintenance mode | A maintenance state that refuses customers and allows restocking. No existing method changes. |
| Odd coin sizes (say 25) | Greedy can fail where a solution exists; swap makeChange for dynamic programming. |
| Prefer keeping small coins | Change policy becomes a strategy: give larger coins first, or keep a minimum of 5s in the machine. |
| Power cut mid-sale | Persist the inserted coins and stock; on restart, refund any inserted coins. |
Before production: an event log of every insert, sale and refund (for disputes), and a low-change warning so staff refill coins before the machine starts refusing sales.
Interview questions
Practice questions
Beginner
- Why use the State pattern instead of a
switchon status? - Why keep the list of inserted coins, not just the total?
Intermediate
- How do you make sure a failed sale changes nothing?
- Where would a "maintenance mode" go?
Advanced
- When is greedy change-making wrong, and what would you use instead?
- The touch screen and the coin slot run on different threads. What can go wrong without a lock?
Common design mistakes
- A status string with
ifchains in every method. - Taking coins from the bank before knowing change can be made, leaving the bank short after a failed sale.
- Refunding "50 taka" in any coins instead of the customer's own coins.
- Decrementing stock before payment succeeds.
Answers
1. Why the State pattern?
Each event behaves differently per state. With a switch, every method grows a branch per state, and every new state edits every method. With the pattern, a new state is a new type and the machine just delegates.
2. Why keep the inserted coins?
Cancel should return what the customer put in, and the inserted coins can also be used to make change. A total alone loses both.
3. How does a failed sale change nothing?
makeChange works on a copy of the bank. Stock and bank are updated only after change succeeds; on failure the inserted coins are returned and the state goes back to idle.
4. Where does maintenance mode go?
In a new state type: it refuses insert and select, and the machine gets a technician method to enter and leave it. The existing states don't change.
5. When is greedy wrong?
With coin sets like 25/10/1, greedy pays 30 as 25 + 1×5 (six coins) instead of 10×3, and with limited coins it can fail when another combination works. Dynamic programming over the available coins finds a solution whenever one exists.
6. What goes wrong without a lock?
Insert's "read inserted list, append" can interleave with select's "read balance, clear list", losing a coin or selling against a stale balance. Our test showed real data races when the lock was removed.
What interviewers look for: a clear state table, the State pattern used for a reason, all-or-nothing sales, and a sensible answer about change-making.
Designed it yourself and understood the solution? Mark it to track your progress.