All problems (16)
System Design courseSystem Design Problems

0%

0/16 done

Practice · Low Level Design · State Machines

Design a Vending Machine

BeginnerState patternCoinsChangeInventory⏱ 1-2 h

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

DifficultyBeginner
Time1–2 hours to design and code; about 45 minutes in an interview
PrerequisitesEnums and state machines, interfaces, a little about locks
ConceptsThe State pattern, making change, keeping a failed sale from changing anything
Real worldSnack 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

  1. Slots have a code, a product name, a price and a stock count.
  2. Accept 5, 10, 20, 50 and 100 taka; reject anything else.
  3. Insert adds to the customer's balance.
  4. 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.
  5. If the machine cannot make exact change, the sale is cancelled and the customer gets their coins back. Nothing else changes.
  6. Cancel returns exactly the coins the customer inserted.
  7. Clear messages: insert money first, not enough money (and how much more), sold out, unknown slot.

Non-functional requirements

RequirementWhat it means here
No lost moneyEvery path ends with either a sale plus correct change, or a full refund
ExtensibilityNew states (maintenance mode, card payment) without rewriting every method
Correctness under concurrencyThe touch screen and the coin slot run on different threads; the balance must never be corrupted
TestabilityEvery 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

EntityJob in one sentenceHoldsDoes
ProductWhat's in a slotname, price, stocknothing: data
MachineThe one entry point and owner of all money and stockslots, coin bank, inserted coins, current state, the lockInsert, Choose, Cancel
state (interface)How the machine answers events right nownothinginsert, choose, cancel
idle, hasMoneyThe two statesnothingtheir own answers to each event
makeChangePays an amount from a coin bank, largest firstnothingreturns coins, new bank, ok
SaleWhat the customer receivesproduct, changenothing: 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

Machine- state: state- slots: map[string]*Product- bank: map[int]int- inserted: []int- mu: sync.Mutex+ Insert(coin) error+ Choose(slot) (Sale, error)+ Cancel() ([]int, error)+ Balance() intProduct+ Name: string+ Price: int+ Stock: intSale+ Product: string+ Change: []int«interface»stateinsert(m, coin) errorchoose(m, slot) (Sale, error)cancel(m) ([]int, error)idleinsert / choose / cancelhasMoneyinsert / choose / cancelcurrentslots1*Choose returnseach state answers insert, choose and cancel its own way (State)Machine hands every button press to its current stateMachine owns the slots; each slot holds a Product with price and stocka successful Choose returns a Sale: the product and the change
Press play. First the two states that implement the state interface light up, then the link from Machine to its current state, then the slots it owns, then the Sale it returns.

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

CustomerMachinehasMoneymakeChangeChoose(A1)lockchoose(m, A1)30 ≤ 40: okmakeChange(10, bank)[10], okstock-1, state = idleSale{Chips, [10]}Chips + change [10]
Press play. 40 tk is in, the customer picks A1. The machine forwards to hasMoney, which checks the price, asks makeChange for 10 tk, updates the machine and returns the sale.

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

insert coinsale done / cancelIdleno moneyHasMoneybalance > 0dispenseChips + changeinsert 20select A1: needs 10 tk more, stays in HasMoney+20select A1 again: balance 40, price 30change [10]each state answers insert, select and cancel in its own way
Press play. A coin moves the machine from Idle to HasMoney. Selecting A1 with too little money keeps it in HasMoney; after another coin, the sale goes through, change comes out, and the machine is back in Idle.

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

100x250x020x4 → x310x1 → x05x2 → x1coins in the machine, largest first20105change 35 = 20 + 10 + 5change 70: 20 + 20 + 20 + 5 = 65, still 5 owed: cancel and refund the 100
Press play. Change of 35 taka is paid largest coin first: 20, then 10, then 5. Later, change of 70 runs out of coins with 5 taka still owed, so the sale is cancelled and the customer's 100 is refunded.

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: Machine holds data and the lock; each state holds the rules for one situation; makeChange only does arithmetic.
  • Open/closed: adding a "maintenance" state (refuses everything, accepts a technician key) is a new type; Insert, Choose and Cancel don'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. makeChange works on a copy, which makes this easy.

Design patterns

State

  1. Why it's needed: select, insert and cancel mean different things with and without money.
  2. What it solves: replaces if m.status == "idle" { … } else if … in every method with one object per state.
  3. Without it: every new state (maintenance, card payment, out of order) edits every method.
  4. 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.
  5. Where it is: state.go. Machine holds state and calls m.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 makeChange for 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}")

What changes from language to language:

StatesThe lock
Goan interface with two small typessync.Mutex in every public method
Pythontwo classes with the same methods (duck typing)threading.Lock with with
TypeScripttwo object literals typed as Statenone needed: one thread, no await
Rustan enum State and match: the compiler checks every state is handledall data inside one Mutex
Javaan interface with two classessynchronized 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]
TestChecks
TestChooseBeforeInsertIdle refuses select and cancel
TestBadCoinChangesNothingA 3-taka coin is rejected and the balance stays 0
TestNotEnoughMoneyKeepsBalanceA short select keeps the money and the stock
TestExactChange50 for 30-taka chips gives one 20 back
TestSoldOutSold out keeps the money in
TestCancelReturnsSameCoinsCancel returns [10 5], the coins actually inserted
TestNoChangeRefundsAndKeepsBankA failed sale changes nothing: stock, balance, bank
TestConcurrentInserts50 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 paymentA new payment source next to coins; selecting charges the card instead of using the balance. Likely a new state, PayingByCard.
Maintenance modeA 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 coinsChange policy becomes a strategy: give larger coins first, or keep a minimum of 5s in the machine.
Power cut mid-salePersist 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

  1. Why use the State pattern instead of a switch on status?
  2. Why keep the list of inserted coins, not just the total?

Intermediate

  1. How do you make sure a failed sale changes nothing?
  2. Where would a "maintenance mode" go?

Advanced

  1. When is greedy change-making wrong, and what would you use instead?
  2. 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 if chains 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.