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.

KindRequirement for this design
FunctionalThe machine holds products in identified slots; each slot has one product, a price, and a remaining count.
FunctionalA customer may insert supported coin and note denominations, and the machine tracks the current balance.
FunctionalThe customer selects a slot, and the machine rejects an unknown or sold-out selection.
FunctionalA product may enter the dispensing flow only when the balance covers its price.
FunctionalA successful purchase decrements exactly one slot, returns the product, and returns computable change.
Functionalcancel() returns the current tender and clears the transaction before dispensing begins.
FunctionalInsufficient funds preserve the balance so the customer can add money or cancel.
FunctionalWhen no slot has stock, the machine enters a sold-out state and refuses new money.
Non-functionalEvery operation must have one legal meaning for the machine's current state.
Non-functionalMoney 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. Denomination contains the supported coin and note values. The prompt's conventional insertCoin() 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; SoldOutState means 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 is dispense(). Before approval, a rejected choice leaves the customer in HasMoneyState.
  • Where does change come from? A CashBox owns 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 SoldOutState before 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:

  1. A vending machine holds a current state, an inventory, a cash box, a balance, and an optional selection.
  2. An inventory contains identified slots; each slot holds a product, a price, and a count.
  3. A customer inserts a coin or note, selects a slot code, receives a product, and may receive change.
  4. A purchase moves through idle, has-money, dispensing, or sold-out conditions.
  5. A refund returns the transaction's original tender when the customer cancels.

Now sort the words by identity, lifecycle, and behavior:

BucketNounsModel choice
Contextvending machineVendingMachine holds shared transaction facts and delegates condition-specific behavior
State contractstate, conditionVendingState plus four concrete state classes encode legal operations and transitions
Domain entitiesinventory, slot, product, cash boxClasses with stock, price, or cash invariants
Attributes / valuesslot code, price, count, balance, selectionFields or small records owned by the relevant entity
Enumcoin, note, denominationOne numeric Denomination enum; the name records whether a value is a coin or note
ActorcustomerCalls the machine but does not become a core class
Excluded conceptscard payment, motor, sensor, telemetryNo speculative classes in this model

The verb pass assigns each rule to the object that already owns its facts:

Verb phraseOwnerReason
accept a denominationcurrent VendingState authorizes; CashBox recordsState decides whether acceptance is legal, while the cash box owns tender
select a productHasMoneyState coordinates a quote and transitionOnly this state has a funded transaction that may be approved
quote stock and priceInventory finds the Slot; Slot exposes its quoteStock and price belong beside the slot count
decrement one itemSlot.dispense() through InventoryThe slot protects count from going below zero
compute and remove changeCashBoxIt owns denomination counts and current tender
refund on cancelCashBox returns tender; current state chooses the next stateCash ownership and transition policy remain separate
remember the approved selectionVendingMachineSelection survives the transition from has-money to dispensing
reject an illegal operationactive VendingStateThe meaning depends on the machine's current condition
choose the next stateconcrete state handling the eventTransitions 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

Core vending-machine model
  1. 1VendingMachine is the context and retains facts shared across state transitions.
  2. 2The context delegates every condition-dependent operation through VendingState.
  3. 3IdleState accepts the first denomination and rejects selection without money.
  4. 4HasMoneyState accumulates tender, validates a choice, and approves a sale.
  5. 5DispensingState is the only state allowed to release a product.
  6. 6SoldOutState refuses new money when the entire inventory is empty.
  7. 7Inventory indexes slots and answers stock queries for the context.
  8. 8Each Slot owns one price and count, including the no-negative-stock rule.
  9. 9A Slot associates its count and price with the Product it releases.
  10. 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

Buy a product and receive change
  1. 1IdleState records the first note, then makes HasMoneyState the next behavior.
  2. 2HasMoneyState validates stock, price, and change before approving the selection.
  3. 3Only DispensingState reaches Inventory.dispense(), which decrements A1 once.
  4. 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: IdleState cannot approve a selection, HasMoneyState cannot decrement stock, and DispensingState cannot 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: CashBox computes change because it owns tender and reserve counts. Putting the calculation in DispensingState would make a temporary behavior object own persistent cash facts; putting it in Inventory would 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 Money value object carrying currency and amount.
  • Exact-change failure: Approval checks canSettle() before inventory changes. If no reserve combination exists, the machine remains in HasMoneyState; 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. SoldOutState represents 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: Implement VendingState, 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 from SoldOutState to IdleState through 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.

+50 XP on completion