A card, a PIN, a number on a keypad and a stack of notes. The design problem is everything that can go wrong between "the bank said yes" and "the customer has the cash".
The ATM is a classic machine-coding problem because one small box holds three things interviewers probe: a state machine (no withdrawal before a PIN), a resource with limits (so many notes of each kind), and a remote call that can fail halfway (the bank debited, then the notes jammed). Most candidates get the first two. The third wins the round: the customer must never lose money, and the ATM must never hand out money the bank didn't approve.
This lesson builds the states, the dispenser, a bank with an idempotent debit and reversal, and an ATM that orders its steps so every failure has an undo. Then it jams the notes, loses a reply from the bank, blocks a card and races a joint account.
How the round runs#
"Design an ATM. Card in, PIN, balance enquiry, cash withdrawal. It holds ₹500, ₹200 and ₹100 notes. The bank is a separate system you call. Things fail: think about what happens when they do. Working code, 90 minutes."
- 0–10 min — clarify: denominations, limits, who checks the PIN, what failures to handle
- 10–25 min — the state diagram, then the classes: ATM, dispenser, bank, transaction
- 25–40 min — the dispenser: planning notes from limited cassettes
- 40–65 min — the ATM: state guards, the withdrawal's order of steps, reversals
- 65–80 min — a demo: wrong PINs, odd amounts, a lost reply, a jam, a joint-account race
- 80–90 min — follow-ups: deposits, card-less withdrawal, power loss, cassette alerts
Requirements and clarifying questions#
| You ask | They answer | What it changes |
|---|---|---|
| Which notes? | ₹500, ₹200 and ₹100 cassettes, counts vary. | A CashDispenser with counts and a planner |
| Amount rules? | Multiples of ₹100, up to ₹10,000 a withdrawal. The bank enforces the balance. | Validate at the ATM before calling the bank |
| Who checks the PIN? | The bank. Three wrong tries block the card. | Bank.verify_pin(); the ATM keeps a blocked card |
| Notes jam after the debit? | The customer must not lose money. | A reversal keyed by the transaction id |
| The bank doesn't answer? | Same rule. | A timeout means "maybe debited": reverse |
| Joint accounts? | Two cards, one account, two ATMs at once. | The bank's check-and-debit is atomic |
Debit the account first, or dispense the cash first?
Pick an order for "take money from the account" and "push notes out of the slot". What goes wrong with each, and what makes your order safe?
One strong answer
Dispense first and the debit can still fail: the cash is gone for good. Debit first and the notes can jam: the customer is charged for nothing. Only the second is fixable, because a debit can be reversed and cash can't. So: check the ATM can make the amount, debit with a transaction id, dispense, and if anything after the debit fails, reverse that id. Journal the transaction before calling the bank, so a power cut leaves a record to reconcile.
The states#
%% title: ATM: states and events stateDiagram-v2 [*] --> Idle Idle --> CardInserted: insert card CardInserted --> CardInserted: wrong PIN CardInserted --> Authenticated: correct PIN CardInserted --> Idle: third wrong PIN, card kept / eject Authenticated --> Dispensing: withdraw, notes available Dispensing --> Authenticated: cash out / declined / reversed Dispensing --> OutOfService: jam, debit reversed, card returned Authenticated --> Idle: eject OutOfService --> Idle: serviced
Dispensing exists because the bank call and the motor are slow and the ATM must not hold its lock through them: while dispensing, the state itself refuses every other button.
The classes#
%% title: ATM: the classes
classDiagram
class ATM {
+atm_id str
+state AtmState
+card str
+journal list
+insert_card(card) str
+enter_pin(pin) str
+balance() str
+withdraw(amount) str
+eject() str
+service()
}
class CashDispenser {
+cassettes dict
+plan(amount) dict
+dispense(notes)
}
class Bank {
<<interface>>
+verify_pin(card, pin) PinResult
+balance(card) int
+debit(card, amount, txn_id)
+reverse(txn_id)
}
class Transaction {
+txn_id str
+card str
+amount int
+status TxnStatus
+notes dict
}
ATM --> Bank
ATM *-- CashDispenser
ATM "1" o-- "many" Transaction
Bank <|.. InMemoryBank
InMemoryBank <|-- FlakyBankThe ATM owns hardware state (the card, the notes) and its journal. The bank owns money: balances, PIN tries, blocks. The ATM never stores a PIN or decides whether there's enough money; it asks, and it reverses.
The interfaces#
# atm.py, part 1: states, transactions and results
from __future__ import annotations
import itertools
import threading
from abc import ABC, abstractmethod
from collections import defaultdict
from dataclasses import dataclass, field
from enum import Enum
class AtmState(Enum):
IDLE = "idle"
CARD_INSERTED = "card inserted"
AUTHENTICATED = "authenticated"
DISPENSING = "dispensing"
OUT_OF_SERVICE = "out of service"
class TxnStatus(Enum):
PENDING = "pending"
COMPLETED = "completed"
DECLINED = "declined"
REVERSED = "reversed"
class PinResult(Enum):
OK = "ok"
WRONG = "wrong"
BLOCKED = "blocked"
@dataclass
class Transaction:
txn_id: str
card: str
amount: int
status: TxnStatus = TxnStatus.PENDING
notes: dict[int, int] = field(default_factory=dict)
class AtmError(Exception): ...
class InsufficientFunds(AtmError): ...
class BankTimeout(Exception): ... # no reply: the debit may or may not have happened
class DispenseError(Exception): ...The bank. debit is idempotent per transaction id (a retry can't debit twice), and reverse is idempotent and safe for a debit that never happened, which lets the ATM treat "no reply" as "reverse, to be sure".
# part 2: the bank behind an interface: idempotent debit, safe reversal
class Bank(ABC):
@abstractmethod
def verify_pin(self, card: str, pin: str) -> PinResult: ...
@abstractmethod
def balance(self, card: str) -> int: ...
@abstractmethod
def debit(self, card: str, amount: int, txn_id: str) -> None: ...
@abstractmethod
def reverse(self, txn_id: str) -> None: ...
class InMemoryBank(Bank):
def __init__(self, cards: dict[str, tuple[str, str]], balances: dict[str, int], max_tries: int = 3) -> None:
self.cards = cards # card -> (account, PIN); a stand-in: real systems never hold a clear PIN
self.balances = dict(balances)
self.max_tries = max_tries
self.tries: dict[str, int] = defaultdict(int)
self.blocked: set[str] = set()
self.debits: dict[str, tuple[str, int]] = {} # txn_id -> (account, amount)
self.reversed: set[str] = set()
self._mu = threading.Lock() # in a real bank: one database transaction per call
def verify_pin(self, card: str, pin: str) -> PinResult:
with self._mu:
if card in self.blocked:
return PinResult.BLOCKED
if self.cards[card][1] == pin:
self.tries[card] = 0
return PinResult.OK
self.tries[card] += 1 # counted at the bank, so tries on different ATMs add up
if self.tries[card] >= self.max_tries:
self.blocked.add(card)
return PinResult.BLOCKED
return PinResult.WRONG
def balance(self, card: str) -> int:
with self._mu:
return self.balances[self.cards[card][0]]
def debit(self, card: str, amount: int, txn_id: str) -> None:
with self._mu:
if txn_id in self.debits:
return # a retry of a debit that already happened
account = self.cards[card][0]
if self.balances[account] < amount:
raise InsufficientFunds("insufficient funds")
self.balances[account] -= amount
self.debits[txn_id] = (account, amount)
def reverse(self, txn_id: str) -> None:
with self._mu:
if txn_id not in self.debits or txn_id in self.reversed:
return # never debited, or already reversed: nothing to do
account, amount = self.debits[txn_id]
self.balances[account] += amount
self.reversed.add(txn_id)
class FlakyBank(InMemoryBank):
"""A test double that can lose one reply: the debit happens, the answer never arrives."""
lose_next_reply = False
def debit(self, card: str, amount: int, txn_id: str) -> None:
super().debit(card, amount, txn_id)
if self.lose_next_reply:
self.lose_next_reply = False
raise BankTimeout("no reply from the bank")The dispenser: notes from what's loaded#
Greedy note-picking (as many ₹500s as fit, then ₹200s, then ₹100s) fails on the notes actually loaded: ₹600 from ₹500s and ₹200s with no ₹100s, greedy takes a ₹500 and is stuck at ₹100, though three ₹200s would do. The planner is greedy with backtracking: the most of the biggest note first, and one fewer whenever the rest can't be made. With three denominations and a ₹10,000 cap, that's a few thousand steps at most. (The vending machine lesson solves the same problem with dynamic programming.)
# part 3: the dispenser: plan notes from what's loaded, then dispense
class CashDispenser:
def __init__(self, cassettes: dict[int, int]) -> None:
self.cassettes = dict(cassettes) # note value -> notes loaded
self.jam_next = False # a test hook standing in for a hardware fault
def plan(self, amount: int) -> dict[int, int] | None:
notes = sorted(self.cassettes, reverse=True)
def go(i: int, left: int) -> dict[int, int] | None:
if left == 0:
return {}
if i == len(notes):
return None
n = notes[i]
for k in range(min(self.cassettes[n], left // n), -1, -1): # most big notes first, then fewer
rest = go(i + 1, left - k * n)
if rest is not None:
return {n: k, **rest} if k else rest
return None
return go(0, amount)
def dispense(self, notes: dict[int, int]) -> None:
if self.jam_next:
self.jam_next = False
raise DispenseError("notes jammed; none presented")
for n, k in notes.items():
self.cassettes[n] -= kThe patterns, and why#
| Pattern | Where | Why it earns its place |
|---|---|---|
| State (as an enum with guards) | AtmState, _require() |
Each button is legal in one or two states and refused everywhere else. |
| Adapter / interface | Bank |
The ATM talks to a bank switch in production and to InMemoryBank in the round. Same calls. |
| Test double | FlakyBank |
Failure injection for the demo and tests, with no if testing: in real code. |
| Compensating transaction | reverse(txn_id) after a jam or a timeout |
Two systems (the bank's ledger and a motor) can't share a database transaction. Undo the first when the second fails. |
| Idempotency key | the txn_id on debit and reverse |
Retries and "reverse to be sure" are safe because repeating them changes nothing. |
Concurrency and edge cases#
Inside one ATM, the keypad, card reader and dispenser report on different threads. The lock is held only to check and change state, never across the bank call or the motor, or one slow reply would freeze the machine. DISPENSING keeps other buttons out while the lock is free.
Across ATMs, two cards on one joint account can withdraw at once. If each ATM read the balance and then debited, both would see ₹10,000 and take ₹6,000. So the bank checks and debits in one atomic step, and the ATM never decides from a balance it read earlier.
- ₹250, ₹0 or ₹15,000: rejected at the ATM; the bank never sees it
- An amount the cassettes can't make: rejected before the debit, never after
- No reply from the bank: reverse; it's a no-op if the debit never happened. A failed reversal is queued and retried until acknowledged
- A jam after the debit: reverse, return the card, go out of service until serviced
- Notes presented but not taken: machines commonly take them back after a timeout; count them and reconcile with a reversal
The code#
The ATM. Every public method starts with a state guard. withdraw validates and plans under the lock, moves to DISPENSING, releases the lock for the slow calls, and finishes each path, success or failure, with one locked transition.
# part 4: the ATM: state guards, and a withdrawal where every failure after the debit is reversed
class ATM:
def __init__(self, atm_id: str, bank: Bank, dispenser: CashDispenser, max_withdrawal: int = 10_000) -> None:
self.atm_id, self.bank, self.dispenser, self.max_withdrawal = atm_id, bank, dispenser, max_withdrawal
self.state = AtmState.IDLE
self.card: str | None = None
self.journal: list[Transaction] = []
self.retained: list[str] = [] # cards kept after too many wrong PINs
self._ids = itertools.count(1)
self._lock = threading.Lock()
def _require(self, *states: AtmState) -> None:
if self.state not in states:
raise AtmError(f"not allowed while {self.state.value}")
def insert_card(self, card: str) -> str:
with self._lock:
self._require(AtmState.IDLE)
self.card, self.state = card, AtmState.CARD_INSERTED
return "enter PIN"
def enter_pin(self, pin: str) -> str:
with self._lock:
self._require(AtmState.CARD_INSERTED)
result = self.bank.verify_pin(self.card, pin)
if result is PinResult.OK:
self.state = AtmState.AUTHENTICATED
return "PIN ok"
if result is PinResult.WRONG:
return "wrong PIN"
self.retained.append(self.card)
self.card, self.state = None, AtmState.IDLE
return "card blocked and kept: contact your bank"
def balance(self) -> str:
with self._lock:
self._require(AtmState.AUTHENTICATED)
return f"balance ₹{self.bank.balance(self.card):,}"
def withdraw(self, amount: int) -> str:
with self._lock:
self._require(AtmState.AUTHENTICATED)
if amount <= 0 or amount % 100 or amount > self.max_withdrawal:
raise AtmError(f"enter a multiple of ₹100, up to ₹{self.max_withdrawal:,}")
notes = self.dispenser.plan(amount)
if notes is None:
raise AtmError(f"this ATM can't make ₹{amount:,} right now")
txn = Transaction(f"{self.atm_id}-{next(self._ids):04d}", self.card, amount)
self.journal.append(txn) # recorded before the bank call, so a power cut can be reconciled
self.state = AtmState.DISPENSING # refuses every other button while the lock is free
try: # slow calls happen outside the lock
self.bank.debit(txn.card, amount, txn.txn_id)
except InsufficientFunds:
self._finish(txn, TxnStatus.DECLINED, AtmState.AUTHENTICATED)
raise
except BankTimeout:
self.bank.reverse(txn.txn_id) # unknown outcome: undo whatever did happen
self._finish(txn, TxnStatus.REVERSED, AtmState.AUTHENTICATED)
raise AtmError("the bank didn't answer; nothing has been taken from your account")
try:
self.dispenser.dispense(notes)
except DispenseError:
self.bank.reverse(txn.txn_id)
self._finish(txn, TxnStatus.REVERSED, AtmState.OUT_OF_SERVICE)
raise AtmError("couldn't dispense; the debit was reversed and your card returned")
txn.notes = notes
self._finish(txn, TxnStatus.COMPLETED, AtmState.AUTHENTICATED)
return f"take ₹{amount:,}: " + " + ".join(f"{k} x ₹{n}" for n, k in notes.items())
def _finish(self, txn: Transaction, status: TxnStatus, state: AtmState) -> None:
with self._lock:
txn.status, self.state = status, state
if state is AtmState.OUT_OF_SERVICE:
self.card = None # the card goes back to the customer
def eject(self) -> str:
with self._lock:
self._require(AtmState.CARD_INSERTED, AtmState.AUTHENTICATED)
self.card, self.state = None, AtmState.IDLE
return "card returned"
def service(self) -> None:
with self._lock:
self._require(AtmState.OUT_OF_SERVICE)
self.state = AtmState.IDLEA morning at one ATM, then two cards on a joint account at two ATMs:
# part 5: wrong PINs, odd amounts, a lost reply, a jam, a blocked card, and a joint-account race
if __name__ == "__main__":
bank = FlakyBank(
cards={"CARD-1": ("ASHA", "1234"), "CARD-2": ("JOINT", "2222"), "CARD-3": ("JOINT", "3333"), "CARD-4": ("RAVI", "4444")},
balances={"ASHA": 25_000, "JOINT": 10_000, "RAVI": 5_000},
)
atm = ATM("ATM1", bank, CashDispenser({500: 20, 200: 10, 100: 0})) # the ₹100 cassette is empty
print(atm.insert_card("CARD-1"), "|", atm.enter_pin("1111"), "|", atm.enter_pin("1234"), "|", atm.balance())
print(atm.withdraw(2_600))
print(atm.withdraw(600)) # greedy would take a ₹500 and get stuck
for bad in (250, 15_000):
try:
atm.withdraw(bad)
except AtmError as e:
print(f"₹{bad:,}:", e)
bank.lose_next_reply = True # the bank debits, but the reply is lost
try:
atm.withdraw(1_000)
except AtmError as e:
print("lost reply:", e, "|", atm.balance())
atm.dispenser.jam_next = True
try:
atm.withdraw(1_000)
except AtmError as e:
print("jam:", e, "| state:", atm.state.value)
atm.service()
print("journal:", [(t.txn_id, t.amount, t.status.value) for t in atm.journal])
print("Asha's balance at the bank:", bank.balances["ASHA"], "| notes left:", atm.dispenser.cassettes)
atm.insert_card("CARD-4")
print("Ravi:", [atm.enter_pin(p) for p in ("0000", "1111", "9999")], "| kept:", atm.retained, "| state:", atm.state.value)
atms = [ATM(f"ATM{i}", bank, CashDispenser({500: 20, 200: 10, 100: 10})) for i in (2, 3)]
outcomes: list[str] = []
def joint_withdraw(machine: ATM, card: str, pin: str) -> None:
machine.insert_card(card)
machine.enter_pin(pin)
try:
outcomes.append(machine.withdraw(6_000))
except InsufficientFunds as e:
outcomes.append(str(e))
ts = [threading.Thread(target=joint_withdraw, args=(m, c, p)) for m, c, p in zip(atms, ["CARD-2", "CARD-3"], ["2222", "3333"])]
for t in ts:
t.start()
for t in ts:
t.join()
print("joint account race:", sorted(outcomes), "| balance:", bank.balances["JOINT"])enter PIN | wrong PIN | PIN ok | balance ₹25,000
take ₹2,600: 4 x ₹500 + 3 x ₹200
take ₹600: 3 x ₹200
₹250: enter a multiple of ₹100, up to ₹10,000
₹15,000: enter a multiple of ₹100, up to ₹10,000
lost reply: the bank didn't answer; nothing has been taken from your account | balance ₹21,800
jam: couldn't dispense; the debit was reversed and your card returned | state: out of service
journal: [('ATM1-0001', 2600, 'completed'), ('ATM1-0002', 600, 'completed'), ('ATM1-0003', 1000, 'reversed'), ('ATM1-0004', 1000, 'reversed')]
Asha's balance at the bank: 21800 | notes left: {500: 16, 200: 4, 100: 0}
Ravi: ['wrong PIN', 'wrong PIN', 'card blocked and kept: contact your bank'] | kept: ['CARD-4'] | state: idle
joint account race: ['insufficient funds', 'take ₹6,000: 12 x ₹500'] | balance: 4000Asha's first PIN is wrong; the second works. ₹2,600 comes out as four ₹500s and three ₹200s because the ₹100 cassette is empty, and ₹600 as three ₹200s. In the lost reply, the bank did debit ₹1,000; the ATM never heard, reversed it, and her balance reads ₹21,800 (₹25,000 − ₹2,600 − ₹600). The jam is reversed too, and the machine stays out of service until serviced. The journal shows both reversals, and the cassettes lost only the notes that left. Ravi's third wrong PIN keeps his card. On the joint account exactly one ₹6,000 withdrawal succeeds, leaving ₹4,000.
Extending it: the follow-ups#
- "Add cash deposits." A
DEPOSIT_COUNTINGstate: count and validate the notes, show the total, credit only after the customer confirms, and return rejected notes first. This is where State classes start to earn their place. - "Card-less withdrawal with a code from the bank's app." A second way into
AUTHENTICATED:enter_code()calls the bank instead of reading a card. Everything after authentication is unchanged. - "Survive a power cut mid-withdrawal." On start-up, reverse every
PENDINGtransaction in the journal (reversal is idempotent) and recount the cassettes. - "Report low cash." Observer on the dispenser: a
low_notesevent per cassette below a threshold. - "Prefer a mix of notes." Make the planner a strategy: fewest notes today, or one that always includes a few smaller notes.
Common mistakes#
- Dispensing before the debit, or debiting with no plan to reverse when the notes jam
- Treating a timeout as a failure and walking away, when the debit may have happened
- Checking the balance at the ATM, then debiting, so two cards on one account both succeed
- Holding a lock across the bank call, so one slow reply freezes the machine
- Storing or comparing the PIN in the ATM, and counting wrong tries per machine instead of per card
ATM
Q1/3 The ATM sends a debit and gets no reply. What should it do?
Answer & explanation
Answer: C
No reply means "unknown". A reversal keyed by the same id undoes the debit if it happened and does nothing if it didn't. A new id would risk debiting twice.
Q2/3 The ATM has ₹500 and ₹200 notes but no ₹100s. A customer asks for ₹600. What happens?
Answer & explanation
Answer: C
Greedy takes the ₹500 and is stuck at ₹100. Trying one fewer ₹500 finds 200 + 200 + 200.
Q3/3 Two cards on one joint account with ₹10,000 withdraw ₹6,000 at two ATMs at the same moment. What prevents both succeeding?
Answer & explanation
Answer: C
Two ATMs share nothing but the bank. Only an atomic check-and-debit at the bank stops both reading ₹10,000 and both taking ₹6,000.
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.