Design an ATM
Card, PIN, withdraw and deposit as a state machine with cash dispensing.
- State machines and the State pattern — Behaviour that depends on a mode, without a wall of conditionals.
- Chain of responsibility — Passing a request along handlers until one takes it.
- From requirements to classes — Nouns, verbs and the use-case walk that finds the classes.
- Errors, exceptions and validation — Failing loudly at the boundary and never in the middle.
- Facade — One simple front door to a complicated subsystem.
01The design, in the six-step framework
1. Requirements and use cases
An ATM is a machine that talks to a bank on behalf of a card holder. The design is a small state machine wrapped around a few hardware parts.
- A holder inserts a card and enters a PIN; three wrong PINs retain the card.
- A holder checks a balance, withdraws cash in the notes the machine has, and deposits cash.
- A holder ejects the card or the session times out and the machine returns to idle.
- The machine refuses a withdrawal it cannot dispense exactly with the notes available.
Out of scope: the bank's own ledger, receipts, multi-currency and networking failures beyond a single retry. Non-functional constraints: an operation is only legal in the right state (no withdrawal before PIN), hardware is behind interfaces so the logic is testable without a real dispenser, and adding a new transaction type (a transfer) must not touch the state code.
2. Core entities and relationships
- ATM: the facade the holder uses; owns the current state and the hardware parts.
- ATMState: an interface with one implementation per mode: Idle, CardInserted, Authenticated, Dispensing.
- Card and Session: the inserted card's number and the authenticated account for this interaction.
- BankService: an interface the ATM calls to verify a PIN, read a balance and post a debit or credit.
- CashDispenser: holds note counts and a chain of denomination handlers that assembles a withdrawal.
- DenominationHandler: one link per note value, each dispensing as many as it can and passing the remainder on.
The ATM composes its dispenser and its session: they live and die with the machine and the interaction. States are shared, stateless objects, so the ATM only holds a reference to the current one. The bank is a dependency passed in, never created inside, so tests can hand in a fake.
3. Interfaces and key methods
class ATMState(Protocol):
def insert_card(self, atm: "ATM", card: Card) -> None: ...
def enter_pin(self, atm: "ATM", pin: str) -> None: ...
def withdraw(self, atm: "ATM", amount: int) -> None: ...
def eject(self, atm: "ATM") -> None: ...
# every method either acts and sets atm.state, or raises IllegalOperation
class BankService(Protocol):
def verify_pin(self, card: Card, pin: str) -> bool: ...
def balance(self, card: Card) -> int: ...
def debit(self, card: Card, amount: int) -> None:
"""Raise InsufficientFunds; never partially apply."""
class CashDispenser:
def can_dispense(self, amount: int) -> bool:
"""True iff the chain can make the exact amount with notes on hand."""
def dispense(self, amount: int) -> dict[int, int]:
"""Deduct and return {note_value: count}; raises if can_dispense is false."""
class ATM:
def insert_card(self, card: Card) -> None: ...
def enter_pin(self, pin: str) -> None: ...
def withdraw(self, amount: int) -> dict[int, int]: ...
def eject(self) -> None: ...
Invariants: atm.session is None exactly when the state is Idle; a debit is posted only after can_dispense has returned true, so the bank and the cash drawer never disagree; and note counts never go negative.
4. Design patterns and the choices behind them
State objects instead of flags
What varies is which operations are legal right now. Booleans like card_inserted and pin_ok multiply into combinations nobody tests. The State pattern gives each mode one class, so an illegal call is a one-line refusal and a new mode (Maintenance) is a new class. The rejected alternative, an if state == ... ladder in every method, works for three states and rots at five.
Chain of responsibility for notes
Dispensing 1,700 from 2,000, 500 and 100 notes is a greedy walk from large to small, with the twist that greedy can fail when a denomination runs out. Modelling each denomination as a link in a chain keeps the arithmetic local and lets the plan be computed before anything is deducted. A single function with a sorted list is equally short; the chain earns its place when denominations differ per machine and are configured at start-up.
The ATM as a facade
The holder sees four methods. Behind them sit the state, the bank client and the dispenser. Keeping the public surface that small is the facade idea, and it is what makes the use cases map one-to-one onto methods.
Errors as typed exceptions
Wrong PIN, illegal operation, insufficient funds and cannot-dispense are different outcomes the screen must show differently, so they are distinct exception types rather than return codes, following the fail loudly at the boundary rule.
5. Core flow and code
insert_cardin Idle creates aSessionand moves to CardInserted.enter_pinin CardInserted asks the bank; success moves to Authenticated, a third failure retains the card and returns to Idle.withdrawin Authenticated first asks the dispenser whether the exact amount is possible, then asks the bank to debit, then dispenses; any failure leaves the state unchanged.ejectclears the session and returns to Idle from any non-idle state.
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional, Protocol
class IllegalOperation(Exception): ...
class WrongPin(Exception): ...
class CardRetained(Exception): ...
class CannotDispense(Exception): ...
class InsufficientFunds(Exception): ...
@dataclass(frozen=True)
class Card:
number: str
@dataclass
class Session:
card: Card
attempts: int = 0
authenticated: bool = False
class BankService(Protocol):
def verify_pin(self, card: Card, pin: str) -> bool: ...
def balance(self, card: Card) -> int: ...
def debit(self, card: Card, amount: int) -> None: ...
class DenominationHandler:
def __init__(self, value: int, count: int, nxt: Optional[DenominationHandler] = None) -> None:
self.value, self.count, self.next = value, count, nxt
def plan(self, amount: int) -> Optional[dict[int, int]]:
"""Largest-first with backtracking: try fewer of this note if the rest cannot be made."""
if amount == 0:
return {}
use = min(self.count, amount // self.value)
while use >= 0:
rest = amount - use * self.value
tail = {} if rest == 0 else (self.next.plan(rest) if self.next else None)
if tail is not None:
return ({self.value: use} if use else {}) | tail
use -= 1
return None
class CashDispenser:
def __init__(self, notes: dict[int, int]) -> None:
self.handlers = {}
chain = None
for value in sorted(notes): # build small to large, so head is largest
chain = DenominationHandler(value, notes[value], chain)
self.handlers[value] = chain
self.chain = chain
def can_dispense(self, amount: int) -> bool:
return self.chain.plan(amount) is not None
def dispense(self, amount: int) -> dict[int, int]:
plan = self.chain.plan(amount)
if plan is None:
raise CannotDispense()
for value, n in plan.items():
self.handlers[value].count -= n
return plan
class ATMState(Protocol):
def insert_card(self, atm: ATM, card: Card) -> None: ...
def enter_pin(self, atm: ATM, pin: str) -> None: ...
def withdraw(self, atm: ATM, amount: int) -> dict[int, int]: ...
def eject(self, atm: ATM) -> None: ...
class Idle:
def insert_card(self, atm: ATM, card: Card) -> None:
atm.session = Session(card)
atm.state = CardInserted()
def enter_pin(self, atm: ATM, pin: str) -> None: raise IllegalOperation("insert a card first")
def withdraw(self, atm: ATM, amount: int) -> dict[int, int]: raise IllegalOperation("insert a card first")
def eject(self, atm: ATM) -> None: raise IllegalOperation("no card")
class CardInserted:
def insert_card(self, atm: ATM, card: Card) -> None: raise IllegalOperation("card already inserted")
def enter_pin(self, atm: ATM, pin: str) -> None:
s = atm.session
if atm.bank.verify_pin(s.card, pin):
s.authenticated = True
atm.state = Authenticated()
return
s.attempts += 1
if s.attempts >= 3:
atm.session, atm.state = None, Idle()
raise CardRetained()
raise WrongPin()
def withdraw(self, atm: ATM, amount: int) -> dict[int, int]: raise IllegalOperation("enter PIN first")
def eject(self, atm: ATM) -> None:
atm.session, atm.state = None, Idle()
class Authenticated:
def insert_card(self, atm: ATM, card: Card) -> None: raise IllegalOperation("card already inserted")
def enter_pin(self, atm: ATM, pin: str) -> None: raise IllegalOperation("already authenticated")
def withdraw(self, atm: ATM, amount: int) -> dict[int, int]:
if amount <= 0 or not atm.dispenser.can_dispense(amount):
raise CannotDispense()
atm.bank.debit(atm.session.card, amount) # raises InsufficientFunds
return atm.dispenser.dispense(amount)
def eject(self, atm: ATM) -> None:
atm.session, atm.state = None, Idle()
class ATM:
def __init__(self, bank: BankService, dispenser: CashDispenser) -> None:
self.bank, self.dispenser = bank, dispenser
self.state: ATMState = Idle()
self.session: Optional[Session] = None
def insert_card(self, card: Card) -> None: self.state.insert_card(self, card)
def enter_pin(self, pin: str) -> None: self.state.enter_pin(self, pin)
def withdraw(self, amount: int) -> dict[int, int]: return self.state.withdraw(self, amount)
def eject(self) -> None: self.state.eject(self)
6. Extensions, concurrency and what interviewers probe
- A new transaction (transfer, PIN change) is a new method on
ATMStatewith a real body only inAuthenticated; the other states inherit the refusal. Interviewers who ask for it are checking that the state classes, not the ATM, grow. - Concurrent transactions: one machine has one holder, so the ATM itself needs no lock; the bank account does, because the same account may be used at two machines. Say that
debitis the bank's atomic step and the ATM never computes a new balance itself. - Debit then dispense, or dispense then debit: the code checks the dispenser, debits, then dispenses, so a hardware jam after the debit needs a compensating credit. Mention it; in a 45-minute round naming the failure window is worth more than coding the reversal.
- Greedy change fails: with notes 500 and 200 and a request of 600, greedy takes one 500 and is stuck; the handler's backtracking loop handles it. This is the follow-up most candidates miss.
- Test first: three wrong PINs retain the card; withdraw before PIN raises; 600 from {500:1, 200:3} dispenses three 200s; note counts after a withdrawal sum correctly.
- Common mistake: letting state classes keep the session or the card. They must be stateless so one instance can be shared and so the machine's data lives in one place.
02Where this question was reported
- Educative · Grokking LLD chapter 16
- DesignGurus · DesignGurus case study
- ByteByteGo · ByteByteGo OOD chapter 13
- dev.to · Educative top-10: PIN checks, card handling, cash-out
- LLD Mastery · Google-tagged (Medium); state management, concurrent transactions
- LLD Canvas · State machine (PIN before withdrawal), Chain of Responsibility for denominations
- Codemia · Codemia Medium
- GitHub · awesome-low-level-design, Medium
- systemdesign.academy · systemdesign.academy solved
- Educative · Educative: State pattern for idle/card/processing/dispensing
- LeetCode Discuss · LeetCode 2241 (Medium), Google-tagged, freq 16.6