~learn.

LLD 1113 min+60 XP

Design an ATM.

Design an ATM's state machine, cash dispenser and bank calls, then break them

6 code

Lesson · 13 min

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#

Interviewer

"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."

A good 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
Your turn · checkpoint 1not answered

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?

Think it through, or jot it down. Then compare.

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#

DiagramATM: states and events
%% 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#

DiagramATM: 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 <|-- FlakyBank

The 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#

pythonAtmState · 51 lines
# 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".

pythonBank · 72 lines
# 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.)

pythonCashDispenser · 29 lines
# 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] -= k

The 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.

Edge cases
  • ₹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.

pythonATM · 85 lines
# 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.IDLE

A morning at one ATM, then two cards on a joint account at two ATMs:

pythonjoint_withdraw · 52 lines
# 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"])
text11 lines
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: 4000

Asha'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_COUNTING state: 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 PENDING transaction in the journal (reversal is idempotent) and recount the cassettes.
  • "Report low cash." Observer on the dispenser: a low_notes event 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.
✗ Pitfall

Common mistakes#

What costs candidates the round
  • 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
Quick check3 questions · +5 XP each

ATM

  1. 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.

  2. 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.

  3. 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.

End of LLD 11 · Design an ATM
Finish LLD 11Stages clear as you read them (0/1 so far). Skipping ahead doesn't count: marking complete unlocks after ~3 more min of reading.

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.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…