Question: Design an ATM
Companies: Amazon
Difficulty: Medium
Problem statement
Model an ATM: card and PIN authentication, balance inquiry, withdrawal with a cash dispenser of fixed denominations, deposit, and failure handling.
Requirements
- One ATM serves one customer session at a time. Amounts are integers (no floats).
- Insert card, enter PIN. Three wrong PINs in a row retain the card and end the session.
- Once authenticated: balance inquiry, withdrawal, deposit, eject card; several transactions per session, staying in
AUTHENTICATED. - Withdrawal: amount must be positive, payable with the notes in the cassettes (fixed denominations, e.g. 50 and 20), and approved by the bank.
- Deposit: the note acceptor counts the cash; the bank credits the account (pending until verified).
- Failures: insufficient funds, an amount the cassettes cannot make, bank unreachable, dispenser jam. Invariant: no debit without cash out, no cash out without a debit.
- Out of scope: card network protocol, PIN encryption hardware, receipts, transfers, multi-currency, admin UI. The bank sits behind an interface.
Core objects
-
ATM— session state machine and facade; orchestrates bank and dispenser; keeps ajournaland a store-and-forwardreversalsqueue. -
AtmState(enum) —IDLE,CARD_INSERTED,AUTHENTICATED,OUT_OF_SERVICE. -
Card— card number and linked account id. -
BankService(interface) — PIN check, balance, two-phase debit (hold/commit/release), deposit.InMemoryBankis its test double. -
CashDispenser— note inventory per denomination; finds a note mix, reserves it, ejects it. -
AtmError— every refusal shown to the customer.
Class design
{"type":"excalidraw","elements":[
{"type":"rectangle","id":"atm","x":0,"y":100,"width":280,"height":170,"label":{"text":"ATM\nstate, card, tries\njournal, reversals\ninsert_card(), enter_pin()\nbalance(), withdraw()\ndeposit(), eject_card()"}},
{"type":"rectangle","id":"bank","x":560,"y":0,"width":260,"height":150,"label":{"text":"BankService\n(interface)\nverify_pin(), balance()\nhold(), commit()\nrelease(), deposit()"}},
{"type":"rectangle","id":"inmem","x":1060,"y":40,"width":200,"height":70,"label":{"text":"InMemoryBank"}},
{"type":"rectangle","id":"disp","x":560,"y":230,"width":260,"height":120,"label":{"text":"CashDispenser\ncassettes, jammed\nreserve(), restock()\neject()"}},
{"type":"rectangle","id":"state","x":0,"y":400,"width":180,"height":70,"label":{"text":"AtmState"}},
{"type":"rectangle","id":"card","x":260,"y":400,"width":180,"height":70,"label":{"text":"Card"}},
{"type":"arrow","id":"atm-bank","x":280,"y":145,"width":280,"height":-70,"start":{"id":"atm"},"end":{"id":"bank"},"label":{"text":"uses"}},
{"type":"arrow","id":"atm-disp","x":280,"y":225,"width":280,"height":65,"start":{"id":"atm"},"end":{"id":"disp"},"label":{"text":"owns"}},
{"type":"arrow","id":"inmem-bank","x":1060,"y":75,"width":-240,"height":0,"start":{"id":"inmem"},"end":{"id":"bank"},"label":{"text":"implements"}},
{"type":"arrow","id":"atm-state","x":90,"y":270,"width":1,"height":130,"start":{"id":"atm"},"end":{"id":"state"},"label":{"text":"uses"}},
{"type":"arrow","id":"atm-card","x":220,"y":270,"width":130,"height":130,"start":{"id":"atm"},"end":{"id":"card"},"label":{"text":"uses"}}
]}
Key flows
Authenticate. insert_card (only in IDLE) stores the card and resets tries. enter_pin calls BankService.verify_pin; success moves to AUTHENTICATED. The third failure retains the card, journals it, and returns to IDLE.
Withdraw (two-phase).
-
ATMchecks the state and thatamount > 0. -
CashDispenser.reservefinds a note mix (large notes first, with backtracking: greedy fails on 60 with 50s and 20s) and removes those notes from the count. No mix: refuse, nothing else changed. -
BankService.hold(account, amount, txn_id). Declined: restock, "insufficient funds". Timeout: restock, queuetxn_idinreversals(the hold may have landed), "bank unavailable". -
CashDispenser.eject. Jam: queue the reversal, journal it, return the card, goOUT_OF_SERVICE. The reserved notes stay out of the count until a technician reconciles the cassette. -
BankService.commit(txn_id)turns the hold into a debit; journal it.
Deposit. Check state and amount, BankService.deposit with a fresh txn id, journal it. Eject / timeout: eject_card returns to IDLE from either card state; a session timer calls the same method.
{"type":"excalidraw","elements":[
{"type":"ellipse","id":"init","x":0,"y":160,"width":100,"height":50,"label":{"text":"start"}},
{"type":"rectangle","id":"idle","x":200,"y":150,"width":180,"height":70,"label":{"text":"IDLE"}},
{"type":"rectangle","id":"ci","x":620,"y":150,"width":240,"height":70,"label":{"text":"CARD_INSERTED"}},
{"type":"rectangle","id":"auth","x":1100,"y":150,"width":240,"height":70,"label":{"text":"AUTHENTICATED"}},
{"type":"rectangle","id":"oos","x":1090,"y":400,"width":260,"height":70,"label":{"text":"OUT_OF_SERVICE"}},
{"type":"arrow","id":"init-idle","x":100,"y":185,"width":100,"height":0,"start":{"id":"init"},"end":{"id":"idle"}},
{"type":"arrow","id":"idle-ci","x":380,"y":185,"width":240,"height":0,"start":{"id":"idle"},"end":{"id":"ci"},"label":{"text":"insert_card"}},
{"type":"arrow","id":"ci-auth","x":860,"y":185,"width":240,"height":0,"start":{"id":"ci"},"end":{"id":"auth"},"label":{"text":"correct PIN"}},
{"type":"arrow","id":"ci-idle","x":740,"y":220,"width":-450,"height":0,"points":[[0,0],[0,100],[-450,100],[-450,0]],"start":{"id":"ci"},"end":{"id":"idle"},"label":{"text":"3rd wrong PIN (retain)\n/ eject"}},
{"type":"arrow","id":"auth-idle","x":1220,"y":150,"width":-930,"height":0,"points":[[0,0],[0,-100],[-930,-100],[-930,0]],"start":{"id":"auth"},"end":{"id":"idle"},"label":{"text":"eject / timeout"}},
{"type":"arrow","id":"auth-oos","x":1220,"y":220,"width":1,"height":180,"start":{"id":"auth"},"end":{"id":"oos"},"label":{"text":"dispenser jam"}},
{"type":"arrow","id":"oos-idle","x":1090,"y":435,"width":-850,"height":-215,"points":[[0,0],[-850,0],[-850,-215]],"start":{"id":"oos"},"end":{"id":"idle"},"label":{"text":"technician reset"}}
]}
Code
from __future__ import annotations
import threading, uuid
from dataclasses import dataclass
from enum import Enum
from typing import Protocol
class AtmState(Enum):
IDLE, CARD_INSERTED, AUTHENTICATED, OUT_OF_SERVICE = range(4)
class AtmError(Exception): ... # every refusal shown to the customer
@dataclass(frozen=True)
class Card:
number: str
account_id: str
class BankService(Protocol):
def verify_pin(self, card: Card, pin: str) -> bool: ...
def balance(self, account_id: str) -> int: ...
def hold(self, account_id: str, amount: int, txn_id: str) -> bool: ...
def commit(self, txn_id: str) -> None: ...
def release(self, txn_id: str) -> None: ... # idempotent
def deposit(self, account_id: str, amount: int, txn_id: str) -> None: ...
class InMemoryBank: # test double; production is a network client
def __init__(self, pins: dict[str, str], balances: dict[str, int]) -> None:
self.pins, self.bal = pins, balances
self.holds: dict[str, tuple[str, int]] = {}
def verify_pin(self, card: Card, pin: str) -> bool: return self.pins.get(card.number) == pin
def balance(self, account_id: str) -> int: return self.bal[account_id]
def commit(self, txn_id: str) -> None: self.holds.pop(txn_id)
def deposit(self, account_id: str, amount: int, txn_id: str) -> None: self.bal[account_id] += amount
def hold(self, account_id: str, amount: int, txn_id: str) -> bool:
if self.bal[account_id] < amount:
return False
self.bal[account_id] -= amount # available balance drops while held
self.holds[txn_id] = (account_id, amount)
return True
def release(self, txn_id: str) -> None:
if txn_id in self.holds:
account_id, amount = self.holds.pop(txn_id)
self.bal[account_id] += amount
class CashDispenser: # owned by one ATM and only touched under its lock
def __init__(self, cassettes: dict[int, int]) -> None:
self.cassettes = dict(cassettes) # denomination -> notes available
self.jammed = False
def reserve(self, amount: int) -> dict[int, int] | None:
denoms = sorted(self.cassettes, reverse=True)
def solve(i: int, rest: int) -> dict[int, int] | None:
if rest == 0 or i == len(denoms):
return {} if rest == 0 else None
d = denoms[i]
for n in range(min(rest // d, self.cassettes[d]), -1, -1): # big notes first
sub = solve(i + 1, rest - n * d)
if sub is not None:
return {d: n, **sub} if n else sub
return None
notes = solve(0, amount)
for d, n in (notes or {}).items():
self.cassettes[d] -= n # take the notes so nobody else can plan with them
return notes
def restock(self, notes: dict[int, int]) -> None:
for d, n in notes.items():
self.cassettes[d] += n
def eject(self, notes: dict[int, int]) -> bool:
return not self.jammed # hardware reports whether every note left the slot
class ATM:
MAX_PIN_TRIES = 3
def __init__(self, bank: BankService, dispenser: CashDispenser) -> None:
self.bank, self.dispenser = bank, dispenser
self.state, self.tries = AtmState.IDLE, 0
self.card: Card | None = None
self.journal: list[str] = []
self.reversals: list[str] = [] # store-and-forward: a job resends until the bank acks
self.lock = threading.Lock() # keypad, timer and hardware events serialise here
def _require(self, *states: AtmState) -> Card:
if self.state not in states or self.card is None:
raise AtmError(f"not allowed in state {self.state.name}")
return self.card
def insert_card(self, card: Card) -> None:
with self.lock:
if self.state is not AtmState.IDLE:
raise AtmError(f"not allowed in state {self.state.name}")
self.state, self.card, self.tries = AtmState.CARD_INSERTED, card, 0
def enter_pin(self, pin: str) -> bool:
with self.lock:
card = self._require(AtmState.CARD_INSERTED)
if self.bank.verify_pin(card, pin):
self.state = AtmState.AUTHENTICATED
return True
self.tries += 1
if self.tries >= self.MAX_PIN_TRIES:
self.journal.append(f"card {card.number} retained")
self.state, self.card = AtmState.IDLE, None
return False
def balance(self) -> int:
with self.lock:
return self.bank.balance(self._require(AtmState.AUTHENTICATED).account_id)
def withdraw(self, amount: int) -> dict[int, int]:
with self.lock:
account = self._require(AtmState.AUTHENTICATED).account_id
notes = self.dispenser.reserve(amount) if amount > 0 else None
if notes is None:
raise AtmError("invalid amount or cannot make it with the notes on hand")
txn = uuid.uuid4().hex
try:
approved: bool | None = self.bank.hold(account, amount, txn)
except ConnectionError:
approved = None
self.reversals.append(txn) # the hold may have landed
if not approved:
self.dispenser.restock(notes)
raise AtmError("insufficient funds" if approved is False else "bank unavailable")
if not self.dispenser.eject(notes): # jam: notes stay quarantined
self.reversals.append(txn)
self.journal.append(f"{txn} jam, reversal queued, {notes} quarantined")
self.state, self.card = AtmState.OUT_OF_SERVICE, None # card returned
raise AtmError("dispenser fault, transaction cancelled")
self.bank.commit(txn)
self.journal.append(f"{txn} withdraw {amount} as {notes}")
return notes
def deposit(self, amount: int) -> None:
with self.lock:
account = self._require(AtmState.AUTHENTICATED).account_id
if amount <= 0:
raise AtmError("amount must be positive")
self.bank.deposit(account, amount, uuid.uuid4().hex)
self.journal.append(f"deposit {amount}") # bank credits it as pending
def eject_card(self) -> None:
with self.lock:
self._require(AtmState.CARD_INSERTED, AtmState.AUTHENTICATED)
self.state, self.card = AtmState.IDLE, None
atm = ATM(InMemoryBank({"4111": "1234"}, {"acc-1": 500}), CashDispenser({50: 2, 20: 10}))
atm.insert_card(Card("4111", "acc-1"))
assert not atm.enter_pin("0000") and atm.enter_pin("1234")
print(atm.withdraw(60)) # {20: 3}: greedy would take a 50 and strand 10
atm.deposit(100); print(atm.balance()) # 540
atm.eject_card()
release is idempotent, so a background job can resend every queued reversal until the bank acknowledges it. One lock on ATM serialises keypad input, the session timer and hardware callbacks; the dispenser is only touched under it.
Design patterns
-
State —
AtmStateplus_requireguards every operation. With more states (maintenance, transaction in progress) this becomes oneStateclass per state, andATMdelegates each event to the current state object. -
Dependency inversion —
ATMdepends on theBankServiceinterface, so a network client orInMemoryBankplugs in unchanged. - Two-phase commit (hold / commit / release) — keeps the account and the cash in step across a hardware step that can fail.
-
Facade —
ATMis the single entry point the screen and keypad talk to.
Extensions
-
Transfers, mini-statements: new
ATMmethods guarded byAUTHENTICATEDand newBankServicecalls; the state machine does not change. -
Daily withdrawal limit: enforced by the bank inside
hold, since only the bank sees every ATM. -
Note-mix policy: extract the search in
reserveinto aNoteMixStrategy(fewest notes vs. spend denominations with the most stock) — a Strategy. -
Crash recovery: persist each journal entry with
txn_idand phase before the next step. On restart, a hold with noCOMMITTEDorRELEASEDand no confirmed eject is reversed; a confirmed eject is committed.
Interviewer expectations
Mid-level (L4)
- Clear classes for ATM, card, bank, dispenser, and a state enum.
- PIN-attempt limit and state checks before every operation.
- A working note-mix algorithm, and knows pure greedy can fail.
Senior (L5)
- Two-phase withdrawal: reserve notes, hold, dispense, commit; every failure undoes the earlier steps.
- Bank behind an interface; idempotent transaction ids; refusals vs. hardware faults.
- Thread safety across keypad, timer and hardware events.
Staff (L6+)
- Handles ambiguous outcomes (timeout after a hold may have landed) with store-and-forward reversals and reconciliation.
- Persistent journal, crash recovery, quarantined notes and cassette reconciliation.
- Puts limits and fraud in the bank and cash inventory and sessions in the ATM; explains when to move from an enum to the State pattern.