LLD · Two Patterns, One Machine

Low Level Design: ATM System (State + Chain of Responsibility)

Card and PIN handling as a State machine, note dispensing as a Chain of Responsibility that does not fail when the greedy answer does, and the money-safety rules (debit, dispense, reverse) that interviewers really care about.

✍️ Pranjal Jain, Ex-Microsoft · IIT Kanpur 📅 10 October 2026 ⏱ 19 min read
ATM machine cut open showing linked note cassettes and a floating state diagram
TL;DR
  • Model the session with the State pattern: IDLE → CARD_INSERTED → AUTHENTICATED → back to IDLE, plus OUT_OF_SERVICE. Three wrong PINs blocks the card.
  • Model the cash dispenser as a Chain of Responsibility: ₹500 → ₹200 → ₹100 cassettes, each handling what it can and passing the rest on.
  • The textbook greedy chain fails with limited notes (₹600 from one ₹500 and three ₹200). Let each handler try fewer notes when the rest of the chain fails.
  • Money safety order: plan notes → debit with a unique transaction id → dispense → reverse on hardware failure. Never debit for an amount you already know you cannot dispense.
  • Full Java code with a demo: wrong PIN, greedy-defeating withdrawal, invalid amount, dispenser jam with reversal, low cash, and card lockout.

The ATM question is a two-pattern problem. Half of it is about a session that moves through well-defined states, where the same button means different things at different times. The other half is about a piece of hardware that has to turn an amount into physical notes from a limited supply. Interviewers like it because both halves have a textbook answer that breaks under one good follow-up question.

The deeper reason it is asked, especially at fintech companies in India, is money safety. What happens when the account is debited and the cash does not come out? A candidate who answers that crisply, with transaction ids and reversals, is showing exactly the instinct payment teams hire for. For broader LLD preparation, the LLD interview guide covers the format.

ATM Requirements

Functional

  1. Insert card; the ATM rejects expired or blocked cards.
  2. Enter PIN; verified by the bank. Three consecutive wrong attempts block the card.
  3. After authentication: withdraw cash, check balance, (optionally) change PIN; multiple transactions per session until the card is ejected.
  4. Withdrawals must be multiples of ₹100, within a per-transaction limit, within the account's daily limit and balance.
  5. The ATM dispenses from ₹500, ₹200 and ₹100 cassettes, the denominations Indian ATMs typically stock today.
  6. If cash cannot be dispensed after a debit, the debit is reversed.
  7. Operators can take the ATM out of service and refill cassettes.

Non-functional

  • Correctness of money above all: no double debits, no debits without cash.
  • Security: the ATM never stores the clear PIN; PIN verification happens at the issuer.
  • Auditability: every transaction has an id and a log line, which is how disputes are resolved.

ATM Hardware and Software Components

Naming the real components helps you draw the right boundaries:

ComponentRoleIn the design
Card readerReads chip or magstripe, can retain the cardCard passed to insertCard; retain on lockout
Encrypting PIN padEncrypts the PIN into a PIN blockPIN passed straight to the bank service
Cash dispenserCassettes per denomination, counts notes, detects jamsCashDispenser with a chain of Cassette handlers
Screen, keypad, receipt printerUser interactionOut of scope; the facade methods are what the UI calls
Network link to switch / issuer bankAuthorisation, debit, reversalBankService interface

The BankService boundary matters. The ATM does not know balances or PINs; it asks. That keeps the ATM's LLD focused on session handling and dispensing, and it is the same separation that real ATM networks have between the terminal, the switch and the issuer.

Card and PIN Handling with the State Pattern

State \ ActioninsertCardenterPinwithdraw / balanceejectCard
IDLEValidate card → CARD_INSERTEDRejectRejectReject
CARD_INSERTEDRejectCorrect → AUTHENTICATED; wrong → stay (3rd wrong: block, retain → IDLE)Reject: enter PIN→ IDLE
AUTHENTICATEDRejectRejectExecute, stay→ IDLE
OUT_OF_SERVICERejectRejectRejectReject
insert valid card correct PIN ┌──────┐ ─────────────────► ┌───────────────┐ ──────────► ┌───────────────┐ │ IDLE │ │ CARD_INSERTED │ │ AUTHENTICATED │ └──────┘ ◄───────────────── └───────────────┘ └───────────────┘ ▲ ▲ eject, or 3rd wrong PIN │ ▲ wrong PIN │ ▲ withdraw / │ │ (card blocked + retained) └──┘ (attempts < 3) │ │ balance │ └──────────────────────────────────────── eject ─────────┘───┘ │ refill done ┌────────────────┐ │ OUT_OF_SERVICE │ ◄── operator, or cash below threshold when idle └────────────────┘

The State pattern is a good fit for the same reason as in the vending machine: each action's meaning depends on the session state, and illegal combinations (withdraw before PIN) should be impossible rather than guarded by flags. The wrong-PIN counter lives on the ATM session (reset on eject), while the block is recorded at the bank, because a fraudster can move to another ATM; a per-terminal counter alone is not security.

Cash Dispenser with Chain of Responsibility

The dispenser has one cassette per denomination. A request for ₹2,800 should come out as five ₹500, one ₹200 and one ₹100. The classic structure is a chain: the ₹500 handler takes what it can, passes ₹300 to the ₹200 handler, which takes one note and passes ₹100 to the ₹100 handler.

Why a chain rather than a loop over denominations? Each handler encapsulates its own cassette (count, jam sensor, maybe its own rules such as "keep a reserve of ₹100 notes"), and adding a denomination is linking a new handler, not editing a loop. That is Chain of Responsibility: a request travels along a list of handlers, each handling part or all of it. The design patterns guide shows the same pattern in middleware and logging pipelines.

Where the textbook chain breaks

The textbook handler is greedy: take min(available, amount / value) notes, pass the rest. Now load the ATM with ten ₹500 notes, three ₹200 notes and zero ₹100 notes, and ask for ₹600:

  • ₹500 handler takes one note; ₹100 remains.
  • ₹200 handler cannot use a ₹200 for ₹100; passes ₹100.
  • ₹100 handler has no notes. Failure, although three ₹200 notes would have worked.

Cassettes running out of one denomination is routine on a busy day (₹100s go first), so this is not a contrived case. The same issue appears in vending-machine change; see the vending machine LLD for the DP version.

The fix: handlers that back off

Each handler tries k notes from its maximum down to zero, and for each k asks the rest of the chain to handle the remainder. The first k that works wins. The structure is still a chain (each handler only knows its own cassette and the next handler), but it now explores alternatives. Trying the largest k first keeps the familiar "big notes first" behaviour whenever it works.

Python (the algorithm in isolation)
class Cassette:
    def __init__(self, value, count, nxt=None):
        self.value, self.count, self.next = value, count, nxt

    def plan(self, amount):
        """Return {denomination: notes} for amount, or None if impossible."""
        for k in range(min(self.count, amount // self.value), -1, -1):
            rest = amount - k * self.value
            tail = {} if rest == 0 else (self.next.plan(rest) if self.next else None)
            if tail is not None:
                if k:
                    tail[self.value] = k
                return tail
        return None

chain = Cassette(500, 10, Cassette(200, 3, Cassette(100, 0)))
print(chain.plan(600))    # {200: 3}  (greedy would fail)
print(chain.plan(2500))   # {500: 5}
print(chain.plan(5700))   # None: only 5600 in the machine

Complexity. With three denominations and a per-transaction cap of ₹10,000, the ₹500 handler tries at most 21 values and the ₹200 handler at most 51 for each, so about a thousand calls in the worst case. That is nothing. For many denominations, switch to the bounded coin-change DP.

💡
Note mix is a product decisionFewest notes is not always what banks want. Some prefer giving a few smaller notes so customers can spend them, or preserving ₹100s for small withdrawals. That is a different order of trying k, or a reserve per cassette, both local changes inside a handler.

Money Safety: Plan, Debit, Dispense, Reverse

This is the section that separates good answers from great ones. A withdrawal touches two systems that can fail independently: the bank ledger and the dispenser hardware. The order of operations decides which failures are recoverable.

  1. Validate locally. Multiple of ₹100, within the per-transaction limit, session authenticated.
  2. Plan the notes. If the dispenser cannot make the amount, stop here. Nothing has happened to the account.
  3. Debit with a transaction id. The ATM generates a unique id and sends it with the debit. The bank checks balance and daily limit atomically and records the id. If the network times out and the ATM retries, the same id prevents a double debit (idempotency).
  4. Dispense. Hardware counts and presents notes. Cassette counts change only on success.
  5. On hardware failure, reverse. Send a reversal referencing the transaction id. If the ATM itself loses power mid-dispense, the switch's reconciliation process later matches the ATM's journal against the debit and reverses it.
⚠️
Why not dispense first, then debit?If the debit then fails (insufficient funds, network down), cash is already in the customer's hand and the bank has to chase it. Debit-first with reversal is the safer failure mode for the bank, and regulation is built around it: in India, RBI's turnaround-time framework for failed transactions requires auto-reversal of failed ATM withdrawals within a fixed window and compensation to the customer for delays. Check the current RBI circular for exact timelines.

Idempotency and reversals are general payment-system ideas; the UPI payment system design shows them at scale, and ACID transactions explained covers why the bank's "check balance and debit" must be one atomic step.

ATM Class Design

┌──────────────────────────────────────────────┐ │ Atm (State context + facade) │ │ insertCard · enterPin · withdraw · balance │ │ ejectCard · refill · state │ └───┬───────────────┬──────────────────────┬───┘ │ delegates │ uses │ uses ▼ ▼ ▼ ┌──────────────┐ ┌───────────────────┐ ┌─────────────────────────┐ │ «AtmState» │ │ CashDispenser │ │ «BankService» │ │ Idle │ │ head: Cassette │ │ verifyPin · isBlocked │ │ CardInserted │ │ plan · dispense │ │ block · balance │ │ Authenticated│ └───────┬───────────┘ │ debit(txnId) · reverse │ │ OutOfService │ ▼ └────────────△────────────┘ └──────────────┘ Cassette(500) ─► Cassette(200) ─► Cassette(100) InMemoryBank (demo)

Full Java Code for the ATM

Java 17+, single file, run with java AtmDemo.java. The bank is simulated in memory; in production it is a network client.

Java
import java.time.YearMonth;
import java.util.*;

public class AtmDemo {

    record Card(String number, YearMonth expiry) { }

    static final class AtmException extends RuntimeException {
        AtmException(String message) { super(message); }
    }

    static final class DispenseException extends RuntimeException {
        DispenseException(String message) { super(message); }
    }

    // ===================== Bank side =====================
    interface BankService {
        boolean isBlocked(String card);
        void block(String card);
        boolean verifyPin(String card, String pin);          // real ATMs send an encrypted PIN block
        long balance(String card);
        void debit(String card, long amount, String txnId);   // idempotent per txnId
        void reverse(String txnId);
    }

    static final class InMemoryBank implements BankService {
        private static final class Account {
            final String pin;
            long balance;
            long withdrawnToday;
            final long dailyLimit;
            boolean blocked;
            Account(String pin, long balance, long dailyLimit) {
                this.pin = pin;
                this.balance = balance;
                this.dailyLimit = dailyLimit;
            }
        }

        private record Debit(String card, long amount) { }

        private final Map<String, Account> accounts = new HashMap<>();
        private final Map<String, Debit> debits = new HashMap<>();   // txnId -> debit

        void open(String card, String pin, long balance, long dailyLimit) {
            accounts.put(card, new Account(pin, balance, dailyLimit));
        }

        private Account acc(String card) {
            Account a = accounts.get(card);
            if (a == null) throw new AtmException("Unknown card");
            return a;
        }

        public synchronized boolean isBlocked(String card) { return acc(card).blocked; }
        public synchronized void block(String card) { acc(card).blocked = true; }
        public synchronized boolean verifyPin(String card, String pin) { return acc(card).pin.equals(pin); }
        public synchronized long balance(String card) { return acc(card).balance; }

        public synchronized void debit(String card, long amount, String txnId) {
            if (debits.containsKey(txnId)) return;                       // retry of the same request
            Account a = acc(card);
            if (a.blocked) throw new AtmException("Card is blocked");
            if (amount > a.balance) throw new AtmException("Insufficient balance");
            if (a.withdrawnToday + amount > a.dailyLimit) throw new AtmException("Daily withdrawal limit exceeded");
            a.balance -= amount;
            a.withdrawnToday += amount;
            debits.put(txnId, new Debit(card, amount));
        }

        public synchronized void reverse(String txnId) {
            Debit d = debits.remove(txnId);
            if (d == null) return;                                       // nothing to reverse, or already reversed
            Account a = acc(d.card());
            a.balance += d.amount();
            a.withdrawnToday -= d.amount();
        }
    }

    // ===================== Cash dispenser (Chain of Responsibility) =====================
    static final class Cassette {
        final int denomination;
        private int count;
        private Cassette next;

        Cassette(int denomination, int count) {
            this.denomination = denomination;
            this.count = count;
        }

        Cassette linkTo(Cassette next) {
            this.next = next;
            return next;
        }

        /** Notes for amount from this cassette and the rest of the chain; prefers larger notes, backs off if needed. */
        Optional<Map<Integer, Integer>> plan(int amount) {
            for (int k = Math.min(count, amount / denomination); k >= 0; k--) {
                int rest = amount - k * denomination;
                Optional<Map<Integer, Integer>> tail;
                if (rest == 0) tail = Optional.of(new TreeMap<Integer, Integer>(Comparator.reverseOrder()));
                else tail = (next == null) ? Optional.empty() : next.plan(rest);
                if (tail.isPresent()) {
                    if (k > 0) tail.get().put(denomination, k);
                    return tail;
                }
            }
            return Optional.empty();
        }

        void dispense(Map<Integer, Integer> plan) {
            count -= plan.getOrDefault(denomination, 0);
            if (next != null) next.dispense(plan);
        }

        void refill(int denom, int notes) {
            if (denom == denomination) count += notes;
            else if (next != null) next.refill(denom, notes);
        }

        int total() { return denomination * count + (next == null ? 0 : next.total()); }
    }

    static final class CashDispenser {
        private final Cassette head;
        private boolean jamNext;   // simulates a hardware fault for the demo

        CashDispenser(Cassette head) { this.head = head; }

        Optional<Map<Integer, Integer>> plan(int amount) { return head.plan(amount); }

        void dispense(Map<Integer, Integer> plan) {
            if (jamNext) {
                jamNext = false;
                throw new DispenseException("Notes jammed in the dispenser");
            }
            head.dispense(plan);       // counts change only after the hardware succeeds
        }

        void jamNextDispense() { jamNext = true; }
        void refill(int denom, int notes) { head.refill(denom, notes); }
        int total() { return head.total(); }
    }

    // ===================== State pattern =====================
    interface AtmState {
        String name();
        default void insertCard(Atm atm, Card card) { throw new AtmException("Cannot insert a card now (" + name() + ")"); }
        default void enterPin(Atm atm, String pin) { throw new AtmException("Insert your card first"); }
        default Map<Integer, Integer> withdraw(Atm atm, int amount) { throw new AtmException("Not authenticated"); }
        default long balance(Atm atm) { throw new AtmException("Not authenticated"); }
        default void ejectCard(Atm atm) { throw new AtmException("No card to eject"); }
    }

    static final class IdleState implements AtmState {
        static final IdleState INSTANCE = new IdleState();
        public String name() { return "IDLE"; }

        public void insertCard(Atm atm, Card card) {
            if (card.expiry().isBefore(atm.currentMonth)) throw new AtmException("Card expired");
            if (atm.bank.isBlocked(card.number())) throw new AtmException("Card is blocked; contact your bank");
            atm.card = card;
            atm.wrongPins = 0;
            atm.state = CardInsertedState.INSTANCE;
        }
    }

    static final class CardInsertedState implements AtmState {
        static final CardInsertedState INSTANCE = new CardInsertedState();
        public String name() { return "CARD_INSERTED"; }

        public void enterPin(Atm atm, String pin) {
            if (atm.bank.verifyPin(atm.card.number(), pin)) {
                atm.state = AuthenticatedState.INSTANCE;
                return;
            }
            atm.wrongPins++;
            if (atm.wrongPins >= Atm.MAX_PIN_ATTEMPTS) {
                atm.bank.block(atm.card.number());
                atm.endSession();                                     // card retained by the reader
                throw new AtmException("Wrong PIN entered " + Atm.MAX_PIN_ATTEMPTS
                        + " times; card retained and blocked. Contact your bank.");
            }
            throw new AtmException("Wrong PIN, " + (Atm.MAX_PIN_ATTEMPTS - atm.wrongPins) + " attempt(s) left");
        }

        public void ejectCard(Atm atm) { atm.endSession(); }
    }

    static final class AuthenticatedState implements AtmState {
        static final AuthenticatedState INSTANCE = new AuthenticatedState();
        public String name() { return "AUTHENTICATED"; }

        public Map<Integer, Integer> withdraw(Atm atm, int amount) {
            if (amount <= 0 || amount % 100 != 0) throw new AtmException("Amount must be a multiple of Rs 100");
            if (amount > Atm.MAX_PER_TRANSACTION) throw new AtmException("Max Rs " + Atm.MAX_PER_TRANSACTION + " per transaction");

            Map<Integer, Integer> notes = atm.dispenser.plan(amount)        // 1. plan before touching the account
                    .orElseThrow(() -> new AtmException("ATM cannot dispense Rs " + amount + " right now"));

            String txnId = atm.nextTxnId();
            atm.bank.debit(atm.card.number(), amount, txnId);               // 2. debit (idempotent)
            try {
                atm.dispenser.dispense(notes);                              // 3. dispense
            } catch (DispenseException e) {
                atm.bank.reverse(txnId);                                    // 4. undo the debit
                throw new AtmException("Cash could not be dispensed; Rs " + amount + " has been reversed to your account");
            }
            atm.journal.add(txnId + " WITHDRAW " + amount + " " + notes);
            return notes;
        }

        public long balance(Atm atm) { return atm.bank.balance(atm.card.number()); }

        public void ejectCard(Atm atm) { atm.endSession(); }
    }

    static final class OutOfServiceState implements AtmState {
        static final OutOfServiceState INSTANCE = new OutOfServiceState();
        public String name() { return "OUT_OF_SERVICE"; }
    }

    // ===================== Context =====================
    static final class Atm {
        static final int MAX_PIN_ATTEMPTS = 3;
        static final int MAX_PER_TRANSACTION = 10_000;
        static final int LOW_CASH_THRESHOLD = 1_000;

        final String atmId;
        final BankService bank;
        final CashDispenser dispenser;
        final YearMonth currentMonth;
        final List<String> journal = new ArrayList<>();
        AtmState state = IdleState.INSTANCE;
        Card card;
        int wrongPins;
        private long txnSeq;

        Atm(String atmId, BankService bank, CashDispenser dispenser, YearMonth currentMonth) {
            this.atmId = atmId;
            this.bank = bank;
            this.dispenser = dispenser;
            this.currentMonth = currentMonth;
        }

        synchronized void insertCard(Card c) { state.insertCard(this, c); }
        synchronized void enterPin(String pin) { state.enterPin(this, pin); }
        synchronized Map<Integer, Integer> withdraw(int amount) { return state.withdraw(this, amount); }
        synchronized long balance() { return state.balance(this); }
        synchronized void ejectCard() { state.ejectCard(this); }
        synchronized String stateName() { return state.name(); }

        synchronized void refill(int denomination, int notes) {
            if (state != IdleState.INSTANCE && state != OutOfServiceState.INSTANCE) {
                throw new AtmException("Session in progress");
            }
            dispenser.refill(denomination, notes);
            state = IdleState.INSTANCE;
        }

        void endSession() {
            card = null;
            wrongPins = 0;
            state = dispenser.total() < LOW_CASH_THRESHOLD ? OutOfServiceState.INSTANCE : IdleState.INSTANCE;
        }

        String nextTxnId() { return atmId + "-" + (++txnSeq); }
    }

    static void attempt(Runnable action) {
        try {
            action.run();
        } catch (AtmException e) {
            System.out.println("   ! " + e.getMessage());
        }
    }

    public static void main(String[] args) {
        InMemoryBank bank = new InMemoryBank();
        bank.open("4111-0000-0000-1111", "1234", 25_000, 20_000);
        bank.open("5222-0000-0000-2222", "4321", 8_000, 20_000);

        Cassette head = new Cassette(500, 10);
        head.linkTo(new Cassette(200, 3)).linkTo(new Cassette(100, 0));
        Atm atm = new Atm("BLR-0042", bank, new CashDispenser(head), YearMonth.of(2026, 10));

        Card riya = new Card("4111-0000-0000-1111", YearMonth.of(2029, 6));
        atm.insertCard(riya);
        attempt(() -> atm.enterPin("1111"));
        atm.enterPin("1234");
        System.out.println("Withdraw 600   -> " + atm.withdraw(600));
        System.out.println("Withdraw 2500  -> " + atm.withdraw(2500));
        attempt(() -> atm.withdraw(350));
        atm.dispenser.jamNextDispense();
        attempt(() -> atm.withdraw(1000));
        attempt(() -> atm.withdraw(3000));
        System.out.println("Balance        -> Rs " + atm.balance());
        System.out.println("Cash in ATM    -> Rs " + atm.dispenser.total());
        atm.ejectCard();

        Card karan = new Card("5222-0000-0000-2222", YearMonth.of(2028, 1));
        atm.insertCard(karan);
        for (String pin : List.of("0000", "1111", "2222")) attempt(() -> atm.enterPin(pin));
        attempt(() -> atm.insertCard(karan));
        System.out.println("State: " + atm.stateName() + ", journal: " + atm.journal);
    }
}

Demo Walkthrough

Output
   ! Wrong PIN, 2 attempt(s) left
Withdraw 600   -> {200=3}
Withdraw 2500  -> {500=5}
   ! Amount must be a multiple of Rs 100
   ! Cash could not be dispensed; Rs 1000 has been reversed to your account
   ! ATM cannot dispense Rs 3000 right now
Balance        -> Rs 21900
Cash in ATM    -> Rs 2500
   ! Wrong PIN, 2 attempt(s) left
   ! Wrong PIN, 1 attempt(s) left
   ! Wrong PIN entered 3 times; card retained and blocked. Contact your bank.
   ! Card is blocked; contact your bank
State: IDLE, journal: [BLR-0042-1 WITHDRAW 600 {200=3}, BLR-0042-2 WITHDRAW 2500 {500=5}]
  1. Wrong PIN, then right PIN. The first attempt fails with two left; the second authenticates.
  2. ₹600 with no ₹100 notes. The ₹500 handler tries one note, the rest of the chain fails on ₹100, so it backs off to zero; the ₹200 handler serves three notes. A greedy chain would have refused this withdrawal.
  3. ₹2,500 comes out as five ₹500 notes.
  4. ₹350 is rejected locally; the bank is never called.
  5. Jam on ₹1,000. Plan {500=2}, debit with transaction id BLR-0042-3, dispense throws, the debit is reversed by id, and cassette counts are untouched because they only change after successful dispensing. The journal does not record it as a withdrawal.
  6. ₹3,000 exceeds the ₹2,500 left in the machine. The plan fails before the debit, so the customer's account is never touched.
  7. Balance is ₹25,000 − ₹600 − ₹2,500 = ₹21,900; the reversed ₹1,000 is back.
  8. Lockout. After ejecting Riya's card, the ATM returns to IDLE (₹2,500 is above the ₹1,000 low-cash threshold). Karan enters three wrong PINs; the bank blocks the card and the session ends. Trying the card again is refused by the blocked check at insertion, as it would be at any ATM, because the block lives at the bank.

Extensions Interviewers Ask

More transaction types

Balance inquiry, mini statement, PIN change, fund transfer, cash deposit on recycler ATMs. Once there are more than two, model each as a Transaction with execute(Atm) (Command), and have AuthenticatedState run whichever the user picks. Deposits reverse the dispenser direction: notes are validated by a chain of counterfeit detectors (Chain of Responsibility again) before crediting.

Cardless cash withdrawal (UPI ATM)

Some Indian ATMs support withdrawing cash by scanning a QR with a UPI app. That is a new entry path: IDLE → (QR shown) → AWAITING_UPI_APPROVAL → dispense. The dispenser, journal and reversal logic are reused unchanged. A new state and a new BankService call, nothing more, which is exactly the benefit of the State pattern.

Timeouts

If the user walks away mid-session, a timer returns the card and goes to IDLE; if they do not take the presented cash within a few seconds, the dispenser retracts it and the switch reverses the debit. Both are timer events handled by the current state.

Monitoring and low cash

Observer: the ATM publishes CashLow, CassetteEmpty, DispenseFailure events to the bank's monitoring system, which schedules cash-replenishment visits. Our endSession already moves the ATM out of service below a threshold.

Network partition

If the ATM sends a debit and the response never arrives, it does not know whether the account was debited. It must not dispense, and it must not simply retry with a new id. It retries with the same id (idempotent) or sends a reversal for that id. This is the same "unknown outcome" problem every payment system solves; see digital wallet design for how ledgers handle it.

Interview Tips for ATM LLD

A 45-minute structure

  1. Requirements and components (5 min). Denominations, limits, PIN attempts, out of service.
  2. State table (5 min). Four states, five actions.
  3. Dispenser chain (10 min). Greedy first, then the counterexample and the backing-off fix.
  4. Transaction safety (5 min). Plan, debit with id, dispense, reverse.
  5. Code and walkthrough (20 min).

Strong signals

  • You keep PIN verification and blocking on the bank side.
  • You plan notes before debiting and can explain why.
  • You use a transaction id for idempotent debits and reversals.
  • Cassette counts change only after the hardware reports success.
  • You know greedy dispensing fails with limited notes.

Common mistakes

  • Storing account balance inside the ATM object.
  • Withdraw callable without authentication because state is a boolean that someone forgot to check.
  • Debiting after dispensing, or not handling dispense failure at all.
  • A dispenser loop that silently dispenses less than requested.
  • Wrong-PIN counter only on the terminal, so a thief retries at the next ATM.

For more State pattern practice, the vending machine LLD is the closest sibling; for timed coding rounds, use the plan in the machine coding round guide.

Frequently Asked Questions

Which design patterns are used in ATM design?▾
State for the session lifecycle (idle, card inserted, authenticated, out of service), Chain of Responsibility for the cash dispenser where each note cassette handles part of the amount and passes the rest on, Strategy or Command for transaction types, and Facade for the bank interface. Observer is used for monitoring and low-cash alerts.
How does chain of responsibility work in an ATM cash dispenser?▾
Cassettes are linked from the largest note to the smallest. Each handler takes as many of its notes as it can for the requested amount and forwards the remainder to the next handler. If the end of the chain is reached with a non-zero remainder, the amount cannot be dispensed. A robust version backtracks by trying fewer notes when the rest of the chain fails.
Why can greedy note dispensing fail in an ATM?▾
Because cassettes have limited notes. To dispense 600 rupees with one 500 note, three 200 notes and no 100 notes, greedy takes the 500 and cannot make the remaining 100, even though three 200s would work. Letting each handler try fewer of its notes before giving up fixes this.
What happens if the ATM debits the account but fails to dispense cash?▾
The ATM or switch must reverse the debit. Good designs debit with a unique transaction id, attempt the dispense, and send a reversal for that id if the hardware reports a failure. In India, RBI's turnaround-time rules require failed ATM withdrawals to be reversed within a fixed number of days, with compensation for delays; check the current circular for exact limits.
How many wrong PIN attempts before an ATM blocks the card?▾
Commonly three consecutive wrong attempts lead to the card being blocked or retained, though the exact policy is set by the issuing bank. In a design, keep the limit as configuration and enforce it at the bank side as well, since an attacker could try PINs across several ATMs.
Is PIN verification done by the ATM?▾
No. The ATM's encrypting PIN pad turns the PIN into an encrypted PIN block that is sent through the network to the issuer, where a hardware security module verifies it. The ATM never sees or stores the clear PIN. In an LLD, model this as a call to a bank service interface.
How do you make ATM withdrawals idempotent?▾
Give every withdrawal a unique transaction id generated by the ATM and send it with the debit request. If the request times out and is retried, the bank sees the same id and returns the original result instead of debiting twice. Reversals reference the same id.

Design Money-Safe Systems in Interviews

Prepflix's course covers ATM, wallet and payment designs from LLD to HLD, with the idempotency and reversal reasoning that fintech interviewers at Razorpay, PhonePe and banks look for.

Explore the Prepflix Course →
Pranjal Jain
Pranjal Jain

Ex-Microsoft SDE · IIT Kanpur · Founder of Prepflix. Helps engineers crack startup and product company interviews across India.