SevyDevy

Guide · System design

LLD: Design an ATM, With Transactions and Failure States

18 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • ATM LLD (low-level design) is the class-by-class design of the software inside a cash machine: its parts, its states and the steps of each transaction. The hard part is failure, not the happy path.
  • Model the machine as a state machine: it is always in exactly one state, such as Idle or Dispensing, and each state allows only some actions. A withdrawal before the PIN is simply refused.
  • Ask the bank first, then dispense. Write each step to a journal, a log on the machine's own disk that is only ever added to, so a restarted machine knows where it stopped.
  • If the cash does not fully come out after the bank says yes, send a reversal: a message asking the bank to put the money back. Reuse the same transaction id, a unique label for that one withdrawal, so the bank can spot repeats and a retried reversal never refunds twice.
  • Beginners can stop after the step-by-step withdrawal. Engineers then get the deeper parts: why a timeout means unknown, restart recovery from the journal, a runnable Java test of each failure, and India's refund deadline.

What goes wrong when an ATM debits you but gives no cash?

The bank has already taken the money from your account, and then something inside the machine fails before the notes come out. The money left your account but never reached your hand.

ATM LLD is how you design the machine's software so that it notices this and gets the money back by itself. It is also a classic low-level design interview question.

Picture a common setup. A company runs 140 white-label ATMs in small towns, and a white-label ATM is a cash machine run by a company that is not a bank. The setup is made up, but the problem is real.

A customer asks for Rs 5,000. The screen freezes, no notes come out, and a minute later his phone shows the debit. A torn note jammed the dispenser, the part that counts notes and pushes them out.

In one month the operations team counts 38 cases like this (made up). Each one needs a person to read the machine's logs and call the bank. This guide designs the software so the machine settles most of them on its own.

What is ATM LLD, and what should the design cover?

ATM LLD is the low-level design of an ATM: its classes, their methods, the states the machine moves through and the rules for each transaction. It looks easy, which is why interviewers like it.

Low-level design (LLD) means designing the code inside one system. You decide which classes exist, what each one owns and how they call each other. High-level design is about servers, networks and databases instead.

Most answers stop at the happy path: insert card, enter PIN, take cash. Plan to spend about half your time on what happens when a step breaks, because that is where money gets lost. The design covers six areas.

  • Hardware parts: card reader, keypad, screen, cash dispenser and receipt printer.
  • Sessions: one customer and one card, from the card going in to the card coming out.
  • Transactions: withdrawal, balance check and PIN change. Each one gets an id and a result.
  • States: what the machine is doing right now, and which actions it allows.
  • Failures: amounts the machine cannot pay, bank timeouts, declined requests, note jams, partial payouts, power cuts and retained cards.
  • Recovery: the journal, reversals and matching the cash at every refill.

One thing stays outside the ATM. The account balance lives at the customer's bank, so in this design the machine never decides whether someone has enough money. It asks and obeys the answer.

What classes do you need to design an ATM?

You need one controller class for the machine, one class for each hardware part, plus a session, a transaction, a journal and a client that talks to the bank. Keep each class small and give it one job.

Wrap every hardware part behind an interface, such as CashDispenser or CardReader. An interface is a list of methods a class promises to provide. Your logic talks to the interface, so tests can plug in a fake dispenser that jams on purpose.

Real ATM software has a standard for this layer: XFS (Extensions for Financial Services), a device interface specification published as a CEN Workshop Agreement. CEN is a European standards body.

The main classes in the ATM design
ClassWhat it ownsExample method
AtmThe current state and the hardware partssetState(AtmState next)
CardReaderReading, ejecting or keeping the cardretainCard()
CashDispenserCounting notes from cassettes, the locked boxes of notes inside, and pushing them outdispense(int amount) returns rupees that came out
SessionOne customer's visit, card and PIN triesnewTxnId()
TransactionOne request, its id, amount and statusmarkApproved()
JournalAn append-only log of every step on local diskwrite(txnId, step, amount)
BankClientSending requests and reversals to the bank networkauthorise(txnId, card, amount)
ReversalQueueReversals waiting for the bank to confirm themenqueue(txnId, amount)

The bank is reached through a switch, a network that routes each request from the ATM to the customer's bank. Card networks carry these requests as ISO 8583 messages, an international standard format for card transaction messages.

Here is how the parts connect. Notice that the Atm controller sits in the middle, and the hardware parts never talk to the bank directly.

Parts of one ATM

The controller owns the flow. Hardware sits behind interfaces, and every step lands in the journal before the next one starts.

Diagram connections:

  • Customer to Atm controller (Card, PIN, amount)
  • Atm controller to Card reader (Read or retain card)
  • Atm controller to Cash dispenser (Dispense notes)
  • Atm controller to Journal on local disk (Log each step)
  • Atm controller to Bank network switch (Authorise or reverse)
  • Atm controller to Operations dashboard (Faults and cash levels)
  • Customer: Inserts card, types PIN and amount
  • Atm controller: Holds the current state, runs each step
  • Card reader: Reads, ejects or keeps the card
  • Cash dispenser: Counts notes from four cassettes
  • Journal on local disk: Append-only record of every step
  • Bank network switch: Approves, declines and reverses
  • Operations dashboard: Faults and cash levels for the ops team

How do you model ATM states with the State pattern?

Give the ATM one current state object, and let that object decide what each button press does. This is the State pattern, and it replaces long if-else chains spread across every method.

A state machine is a model where a system is always in exactly one state, and events move it to the next one. For the ATM, the events are things like card inserted, PIN entered and cash taken.

The full design has seven states and one decision box, PIN correct. Follow the arrows from Idle around to Eject card and back. Look closely at Out of service: arrows go into it, but none come out in software.

ATM states and the events that move them

Each state allows only a few events. A person, not code, takes the machine out of service mode.

Diagram connections:

  • Idle to Card inserted (Card inserted)
  • Card inserted to PIN correct? (PIN entered)
  • PIN correct? to Choosing a transaction (Yes)
  • PIN correct? to Eject card, end session (No, 3 times)
  • Choosing a transaction to Waiting for the bank (Withdraw Rs 2,000)
  • Waiting for the bank to Dispensing cash (Approved)
  • Waiting for the bank to Eject card, end session (Declined or timeout)
  • Dispensing cash to Eject card, end session (Cash taken)
  • Dispensing cash to Out of service (Jam detected)
  • Eject card, end session to Idle (Ready for next card)
  • Idle: Welcome screen, waiting for a card
  • Card inserted: Card read, asking for the PIN
  • PIN correct?: Up to 3 tries, checked by the bank
  • Choosing a transaction: Withdraw, balance or PIN change
  • Waiting for the bank: Request sent, timer running
  • Dispensing cash: Notes counted and presented
  • Eject card, end session: Card returned, receipt printed
  • Out of service: Refuses cards until staff fix it

In Java, each state is a class that implements one shared interface. The Atm class holds the current state and passes every event to it. Here is the interface and the Idle state from the runnable program later in this guide.

java
interface AtmState {
    default void insertCard(Atm atm, String card) { atm.show("Busy. Please wait."); }
    default void enterPin(Atm atm, String pin) { atm.show("Please insert your card first."); }
    default void withdraw(Atm atm, int amount) { atm.show("Please insert your card first."); }
}

static class IdleState implements AtmState {
    public void insertCard(Atm atm, String card) {
        atm.startSession(card);
        atm.setState(new CardInsertedState());
    }
}

Each default method in the interface is the refusal, so a state overrides only the events it allows. Idle accepts a card, starts a session and calls setState, which swaps in the next state object.

In Idle, entering a PIN or asking for cash just shows a message, so those steps cannot run out of order. The demo program checks this: a withdrawal before a card prints Please insert your card first.

Growth stays cheap, but not free. A new state, such as a deposit screen, is one new class plus a change in the state that moves into it. A new event goes into the interface as a default method, so existing states need no edits.

The list of transaction results is a Java enum. An enum is a type whose variables must hold one of a fixed set of named constants, here OK, CANNOT_PAY, DECLINED, PARTIAL and UNKNOWN_REVERSED.

How does an ATM cash withdrawal work, step by step?

The machine reads the card and PIN, asks the bank to approve and debit the amount, dispenses the notes, and then records how much really came out. The bank answers before a single note moves.

The order matters. If the machine paid out first and the bank then declined, the cash would be gone for good. Asking first, after the machine has checked it can pay, means the worst case is a debit with no cash, and a debit can be undone.

Think of a bus conductor. He takes your Rs 50 note first, punches a ticket on his machine, and hands back Rs 10 change. If his ticket machine jams, a good conductor writes your fare in his notebook, then gives you a paper ticket or your money back.

The ATM maps onto this neatly. The notebook is the journal, an append-only log on the machine's own disk, where lines are only ever added, never changed. Giving the note back is the reversal.

Where the picture breaks: the conductor knows whether he took your money. The ATM sometimes does not know whether the bank debited you, which is why the timeout case below is the hard one.

  1. Read the card

    The card reader reads the card, and the Atm moves from Idle to Card inserted. The session starts with zero PIN tries used.

  2. Check the PIN

    The keypad is an encrypting PIN pad: it encrypts the PIN as you type it. The bank checks it and sets how many wrong tries it allows; this design uses three.

  3. Check the machine can pay

    The customer picks Rs 2,000. The machine checks it can make that amount from the notes in its cassettes. If it cannot, it refuses now, and the bank is never asked.

  4. Create a transaction id

    The machine creates a unique id for this one withdrawal, and the journal writes STARTED with that id.

  5. Ask the bank

    BankClient sends the request with the id and starts a timer. The bank checks the balance, debits Rs 2,000 and replies approved.

  6. Record the approval

    The journal writes APPROVED, with the dispenser's presented counter at that moment, before the dispenser moves. Now a restart can see that money left the account.

  7. Dispense and record

    The dispenser counts four Rs 500 notes and reports how many rupees really came out. The journal writes DISPENSED with that amount.

  8. End the session

    The card comes out, a receipt prints, and the machine returns to Idle for the next customer.

Here is the same flow as messages between parts. Start at the top and notice the two question boxes. A timeout from the first and a jam from the second lead to the same place: a reversal.

One withdrawal, with its two failure branches

A decline needs nothing undone. A missing reply or a short payout ends in a reversal with the same transaction id.

Diagram connections:

  • Read card and PIN to Journal: STARTED
  • Journal: STARTED to Request Rs 2,000
  • Request Rs 2,000 to Bank approves in time?
  • Bank approves in time? to Journal: APPROVED (Yes)
  • Journal: APPROVED to Dispense four notes
  • Bank approves in time? to Show declined message (Declined)
  • Bank approves in time? to Queue a reversal (No reply)
  • Dispense four notes to All notes came out?
  • All notes came out? to Journal: DISPENSED (Yes)
  • All notes came out? to Queue a reversal (No, jam)
  • Read card and PIN: Customer → ATM
  • Journal: STARTED: ATM → Journal
  • Request Rs 2,000: ATM → Bank switch
  • Bank approves in time?: Bank switch → ATM
  • Journal: APPROVED: ATM → Journal
  • Show declined message: ATM → Customer
  • Dispense four notes: ATM → Dispenser
  • All notes came out?: Dispenser → ATM
  • Journal: DISPENSED: ATM → Journal
  • Queue a reversal: ATM → Reversal queue

What happens when an ATM transaction fails halfway?

It depends on where it stopped. If the bank never approved, nothing needs undoing. If the bank approved but the cash did not fully come out, the machine must send a reversal for the missing amount.

A reversal is a message that cancels an earlier approved transaction, so the bank puts the money back. ISO 8583 gives it its own message type, 0400, separate from the 0200 request for funds that an ATM typically sends.

The table below lists each place this design lets a withdrawal stop, and what it does at each one. It is the core of the whole design.

Where a withdrawal can fail, and what the design does
Where it failsCustomer's moneyWhat the machine does
PIN wrong three timesNot touchedEnds the session, may keep the card if the bank says so
Amount the notes cannot make, or too little cashNot touchedRefuses before asking the bank and stays in service
Bank declinesNot touchedJournals DECLINED, shows the reason and ejects the card
No reply before the timeoutUnknown: maybe debitedQueues a reversal for the full amount
Approved, then jam before any noteDebited, no cashReverses the full amount, goes out of service
Approved, some notes out, then jamDebited, part cashReverses only the missing amount, goes out of service
Power cut during dispensingUnknown until restartOn restart, reads the journal and the dispenser counters
Cash presented but not takenDebited, cash at the slotLogs it; the debit stands, since the cash was offered. If the machine pulls the notes back, its retract counter records them, and operations checks whether a reversal is due

The timeout case

The hardest case is a timeout. The ATM sent the request, and no reply came within the time limit, say 30 seconds (made up; your network sets the real value). The bank may have debited the account, or the request may never have arrived.

A missing reply means unknown, not failed. So the machine assumes the worst and sends a reversal anyway. If the bank never debited, the reversal has nothing to undo, and the design needs only an acknowledgement so the queue can move on.

The power cut case

When power returns, the controller reads the journal before it accepts any card. For each transaction, it finds the last line written. That last line tells it what to do next.

  • STARTED with nothing after it: the bank may have approved, so queue a full reversal.
  • APPROVED with no DISPENSED: compare the presented counter, which counts notes offered to the customer, now with the one saved in the APPROVED line. The difference is what came out.
  • DISPENSED with less than was asked: make sure a reversal for the gap is in the queue.
  • DISPENSED with the full amount, or DECLINED: nothing to fix, close the transaction.

The counter comparison works because the machine serves one customer at a time, so only one withdrawal can be in flight when the power goes.

Dispensers keep their own counters for each cassette, and each cassette holds notes of one value. The XFS specification defines counters for notes dispensed, notes presented to the customer, notes rejected and notes retracted.

Rejected notes, such as two stuck together, go to a reject cassette and never reach the customer. Retracted notes are ones the customer could reach but the machine pulled back in.

How do you make sure an ATM never debits a customer twice?

Give every transaction one id, reuse that id on every retry and reversal, and have the bank side treat a repeat of the same id as the same request. Then a repeated request is not debited twice, and a retried reversal refunds only once.

This property is called idempotency: doing the same request twice has the same effect as doing it once. The transaction id works as an idempotency key, a label the receiver uses to spot repeats.

In ISO 8583 the match does not rest on one field. A reversal carries the original's details in field 90, such as the original message type and trace number, so the bank can match it.

Here is the withdraw method from the runnable program. The journal writes a line to disk, the bank sends the request, and reversals is the queue that keeps retrying until the bank confirms.

java
TxnResult withdraw(int amount) {
    if (!dispenser.canPay(amount)) {           // refuse before the bank is asked
        show("This machine cannot pay that amount.");
        return TxnResult.CANNOT_PAY;
    }
    String txnId = "T" + (++txnCounter);
    journal.write(txnId, Step.STARTED, amount);
    HostReply reply = bank.authorise(txnId, card, amount);
    if (reply == null) {                       // timeout: outcome unknown
        reversals.enqueue(txnId, amount);
        return TxnResult.UNKNOWN_REVERSED;
    }
    if (!reply.approved()) {
        journal.write(txnId, Step.DECLINED, amount);
        return TxnResult.DECLINED;
    }
    journal.write(txnId, Step.APPROVED, dispenser.presentedRupees);
    int given = dispenser.dispense(amount);    // rupees that really came out
    journal.write(txnId, Step.DISPENSED, given);
    if (given < amount) {
        reversals.enqueue(txnId, amount - given);
        setState(new OutOfServiceState());
        return TxnResult.PARTIAL;
    }
    return TxnResult.OK;
}

Four lines matter most. The canPay check refuses an amount the notes cannot make, before an id is created or the bank is asked.

The null check treats a timeout as unknown and queues a reversal straight away. The dispense call returns the rupees that really came out, not the amount asked for.

The last important line queues a reversal for only the missing amount, then moves the machine out of service. A jammed dispenser should not serve the next customer.

The demo's journal opens its file with Java's SYNC option. For a file on a local disk, Java documents that each write then returns only after the change reaches the device, which protects critical data in a crash.

In production the reversal queue lives on disk, like the journal, so it survives a power cut. A background worker sends each reversal, waits longer after each failed try, and stops only when the bank acknowledges it.

ISO 8583 also has advice messages, such as 0420 for a reversal advice. An advice tells the receiver that something has already happened, and the receiver can only accept it, not reject it.

How can you test every ATM failure path yourself?

Run the whole design as one Java file with a fake bank and a fake dispenser. Save it as AtmDemo.java and run java AtmDemo.java with Java 17 or later. No build tool is needed.

The fake bank starts every account at Rs 10,000 and accepts PIN 1234. The fake dispenser holds 40 Rs 500 notes and can jam after a set number. The demo folds Session into Atm and keeps the reversal queue in memory to stay short.

Its ids, T1 and T2, restart from T1 in every run. A real machine needs ids that stay unique across restarts, for example the terminal id, the date and a counter saved on disk.

The demo writes no journal line once a reversal is acknowledged, so a restart would send it again. A real journal records REVERSED too, so settled reversals stay settled.

The demo also has fewer states than the diagram. ReadyState plays the Choosing state, and the bank call and the payout happen inside one method call, so Waiting for the bank and Dispensing need no classes of their own.

java
import java.nio.file.*;
import java.util.*;

public class AtmDemo {
    enum Step { STARTED, APPROVED, DECLINED, DISPENSED }
    enum TxnResult { OK, CANNOT_PAY, DECLINED, PARTIAL, UNKNOWN_REVERSED }
    record HostReply(boolean approved) {}

    interface AtmState {
        default void insertCard(Atm atm, String card) { atm.show("Busy. Please wait."); }
        default void enterPin(Atm atm, String pin) { atm.show("Please insert your card first."); }
        default void withdraw(Atm atm, int amount) { atm.show("Please insert your card first."); }
    }

    static class IdleState implements AtmState {
        public void insertCard(Atm atm, String card) {
            atm.startSession(card);
            atm.setState(new CardInsertedState());
        }
    }

    static class CardInsertedState implements AtmState {
        int tries = 0;
        public void enterPin(Atm atm, String pin) {
            if (atm.bank.pinOk(atm.card, pin)) { atm.setState(new ReadyState()); return; }
            if (++tries == 3) { atm.show("Too many wrong PINs."); atm.endSession(); }
            else atm.show("Wrong PIN. Try again.");
        }
        public void withdraw(Atm atm, int amount) { atm.show("Please enter your PIN first."); }
    }

    static class ReadyState implements AtmState {
        public void withdraw(Atm atm, int amount) {
            TxnResult r = atm.withdraw(amount);
            atm.show("Result: " + r);
            atm.endSession();
        }
    }

    static class OutOfServiceState implements AtmState {
        public void insertCard(Atm atm, String card) { atm.show("Out of service. Card returned."); }
    }

    // Fake bank: debits once per transaction id, refunds once per id.
    static class FakeBank {
        int balance = 10_000;
        boolean loseNextReply = false;
        int reversalFailuresLeft = 0;
        final Map<String, Integer> debited = new HashMap<>();
        final Set<String> reversed = new HashSet<>();

        boolean pinOk(String card, String pin) { return pin.equals("1234"); }

        HostReply authorise(String txnId, String card, int amount) {
            if (!debited.containsKey(txnId)) {
                if (amount > balance) return new HostReply(false);
                balance -= amount;
                debited.put(txnId, amount);
            }
            if (loseNextReply) { loseNextReply = false; return null; }
            return new HostReply(true);
        }

        boolean reverse(String txnId, int amount) {
            if (reversalFailuresLeft > 0) { reversalFailuresLeft--; return false; }
            if (debited.containsKey(txnId) && reversed.add(txnId))
                balance += Math.min(amount, debited.get(txnId));
            return true; // acknowledged, even if there was nothing to undo
        }
    }

    // Fake dispenser: one cassette of Rs 500 notes; jams after jamAfter notes.
    static class FakeDispenser {
        int notesLeft = 40;
        int jamAfter = Integer.MAX_VALUE;
        int presentedRupees = 0; // like the presented counter a real dispenser keeps

        boolean canPay(int amount) {
            return amount > 0 && amount % 500 == 0 && amount / 500 <= notesLeft;
        }

        int dispense(int amount) {
            int notes = Math.min(amount / 500, jamAfter);
            notesLeft -= notes;
            presentedRupees += notes * 500;
            return notes * 500; // rupees that really came out
        }
    }

    static class Journal {
        final Path file;
        Journal(Path file) { this.file = file; }

        void write(String txnId, Step step, int amount) {
            try {
                Files.writeString(file, txnId + "," + step + "," + amount + "\n",
                        StandardOpenOption.CREATE, StandardOpenOption.APPEND,
                        StandardOpenOption.SYNC);
            } catch (Exception e) { throw new RuntimeException(e); }
        }
    }

    static class ReversalQueue {
        final Deque<String[]> pending = new ArrayDeque<>();
        void enqueue(String txnId, int amount) { pending.add(new String[]{txnId, "" + amount}); }

        void drain(FakeBank bank) throws InterruptedException {
            while (!pending.isEmpty()) {
                String[] r = pending.peek();
                long waitMs = 100;
                int attempt = 1;
                while (!bank.reverse(r[0], Integer.parseInt(r[1]))) {
                    System.out.println("  reversal " + r[0] + " attempt " + attempt++ + " failed");
                    Thread.sleep(waitMs);
                    waitMs *= 2;
                }
                System.out.println("  reversal " + r[0] + " Rs " + r[1] + " acknowledged");
                pending.poll();
            }
        }
    }

    static class Atm {
        AtmState state = new IdleState();
        String card;
        int txnCounter = 0;
        final FakeBank bank;
        final FakeDispenser dispenser;
        final Journal journal;
        final ReversalQueue reversals = new ReversalQueue();

        Atm(FakeBank bank, FakeDispenser dispenser, Journal journal) {
            this.bank = bank; this.dispenser = dispenser; this.journal = journal;
        }
        void setState(AtmState next) { state = next; }
        void show(String msg) { System.out.println("  screen: " + msg); }
        void startSession(String c) { card = c; }
        void endSession() {
            card = null;
            show("Card returned.");
            if (!(state instanceof OutOfServiceState)) setState(new IdleState());
        }

        TxnResult withdraw(int amount) {
            if (!dispenser.canPay(amount)) {           // refuse before the bank is asked
                show("This machine cannot pay that amount.");
                return TxnResult.CANNOT_PAY;
            }
            String txnId = "T" + (++txnCounter);
            journal.write(txnId, Step.STARTED, amount);
            HostReply reply = bank.authorise(txnId, card, amount);
            if (reply == null) {                       // timeout: outcome unknown
                reversals.enqueue(txnId, amount);
                return TxnResult.UNKNOWN_REVERSED;
            }
            if (!reply.approved()) {
                journal.write(txnId, Step.DECLINED, amount);
                return TxnResult.DECLINED;
            }
            journal.write(txnId, Step.APPROVED, dispenser.presentedRupees);
            int given = dispenser.dispense(amount);    // rupees that really came out
            journal.write(txnId, Step.DISPENSED, given);
            if (given < amount) {
                reversals.enqueue(txnId, amount - given);
                setState(new OutOfServiceState());
                return TxnResult.PARTIAL;
            }
            return TxnResult.OK;
        }

        // After a restart: read the last line for each transaction and decide.
        void recover() throws Exception {
            Map<String, String[]> last = new LinkedHashMap<>();
            Map<String, Integer> asked = new HashMap<>();
            for (String line : Files.readAllLines(journal.file)) {
                String[] p = line.split(",");
                last.put(p[0], p);
                if (p[1].equals("STARTED")) asked.put(p[0], Integer.parseInt(p[2]));
            }
            for (String[] p : last.values()) {
                String id = p[0];
                int want = asked.get(id);
                int missing = switch (Step.valueOf(p[1])) {
                    case STARTED -> want;
                    case APPROVED -> want - (dispenser.presentedRupees - Integer.parseInt(p[2]));
                    case DISPENSED -> want - Integer.parseInt(p[2]);
                    case DECLINED -> 0;
                };
                System.out.println("  " + id + " last step " + p[1] + ", missing Rs " + missing);
                if (missing > 0) reversals.enqueue(id, missing);
            }
        }
    }

    static Atm newAtm(FakeBank bank) throws Exception {
        Path f = Files.createTempFile("journal", ".log");
        return new Atm(bank, new FakeDispenser(), new Journal(f));
    }

    public static void main(String[] args) throws Exception {
        System.out.println("1. Withdraw before inserting a card");
        FakeBank bank = new FakeBank();
        Atm atm = newAtm(bank);
        atm.state.withdraw(atm, 2000);

        System.out.println("2. Happy path, Rs 2,000");
        atm.state.insertCard(atm, "CARD-1");
        atm.state.enterPin(atm, "1234");
        atm.state.withdraw(atm, 2000);
        System.out.println("  balance Rs " + bank.balance);

        System.out.println("3. Jam after one Rs 500 note");
        atm.dispenser.jamAfter = 1;
        atm.state.insertCard(atm, "CARD-1");
        atm.state.enterPin(atm, "1234");
        atm.state.withdraw(atm, 2000);
        atm.reversals.drain(bank);
        System.out.println("  balance Rs " + bank.balance);
        atm.state.insertCard(atm, "CARD-2");

        System.out.println("4. Bank debits but the reply is lost");
        bank = new FakeBank();
        bank.loseNextReply = true;
        bank.reversalFailuresLeft = 2;
        atm = newAtm(bank);
        atm.state.insertCard(atm, "CARD-1");
        atm.state.enterPin(atm, "1234");
        atm.state.withdraw(atm, 2000);
        System.out.println("  balance before reversal Rs " + bank.balance);
        atm.reversals.drain(bank);
        System.out.println("  balance Rs " + bank.balance);

        System.out.println("5. Same id sent twice");
        bank = new FakeBank();
        bank.authorise("T9", "CARD-1", 2000);
        bank.authorise("T9", "CARD-1", 2000);
        System.out.println("  after two requests, balance Rs " + bank.balance);
        bank.reverse("T9", 2000);
        bank.reverse("T9", 2000);
        System.out.println("  after two reversals, balance Rs " + bank.balance);

        System.out.println("6. Power cut: recover from the journal");
        bank = new FakeBank();
        atm = newAtm(bank);
        bank.authorise("T7", "CARD-1", 2000);         // the bank debited
        atm.journal.write("T7", Step.STARTED, 2000);
        atm.journal.write("T7", Step.APPROVED, 500);  // counter was Rs 500
        atm.dispenser.presentedRupees = 1500;          // counter after the cut
        atm.recover();
        atm.reversals.drain(bank);
        System.out.println("  balance Rs " + bank.balance);

        System.out.println("7. Rs 1,700, which Rs 500 notes cannot make");
        bank = new FakeBank();
        atm = newAtm(bank);
        atm.state.insertCard(atm, "CARD-1");
        atm.state.enterPin(atm, "1234");
        atm.state.withdraw(atm, 1700);
        System.out.println("  balance Rs " + bank.balance);
        atm.state.insertCard(atm, "CARD-2");
        System.out.println("  next card accepted: " + (atm.state instanceof CardInsertedState));
    }
}

We ran it on OpenJDK 21.0.12.1 and 17.0.20.1 on 19 October 2026. The temporary journal file changes name each run, but the printed output should match this exactly.

text
1. Withdraw before inserting a card
  screen: Please insert your card first.
2. Happy path, Rs 2,000
  screen: Result: OK
  screen: Card returned.
  balance Rs 8000
3. Jam after one Rs 500 note
  screen: Result: PARTIAL
  screen: Card returned.
  reversal T2 Rs 1500 acknowledged
  balance Rs 7500
  screen: Out of service. Card returned.
4. Bank debits but the reply is lost
  screen: Result: UNKNOWN_REVERSED
  screen: Card returned.
  balance before reversal Rs 8000
  reversal T1 attempt 1 failed
  reversal T1 attempt 2 failed
  reversal T1 Rs 2000 acknowledged
  balance Rs 10000
5. Same id sent twice
  after two requests, balance Rs 8000
  after two reversals, balance Rs 10000
6. Power cut: recover from the journal
  T7 last step APPROVED, missing Rs 1000
  reversal T7 Rs 1000 acknowledged
  balance Rs 9000
7. Rs 1,700, which Rs 500 notes cannot make
  screen: This machine cannot pay that amount.
  screen: Result: CANNOT_PAY
  screen: Card returned.
  balance Rs 10000
  next card accepted: true

What just happened? Scenario 3 paid one Rs 500 note and reversed Rs 1,500. The balance of Rs 7,500 is the Rs 2,000 from scenario 2 plus that one note. The machine then refused the next card.

Scenario 4 shows the timeout: the bank kept the Rs 2,000 debit, two reversal attempts failed, and the third restored Rs 10,000. Scenario 5 shows idempotency: two requests with one id debit once, and two reversals refund once.

Scenario 6 is the power cut. The counter moved from Rs 500 to Rs 1,500, so Rs 1,000 came out, and recovery reversed the missing Rs 1,000.

Scenario 7 asks for Rs 1,700, which Rs 500 notes cannot make. The machine refuses before the bank is asked, the balance stays Rs 10,000, and the next card is accepted.

How does an ATM operator match cash and settle failed transactions in India?

At every refill, staff count the notes left in each cassette and in the reject cassette, then match that against the journal and the counters. Any gap points to a jam, a partial payout or a missed reversal.

Matching the cash count with the records is called reconciliation. The journal is also evidence: when a customer disputes a withdrawal, it shows each step with a time, next to the counters and the cash count.

Regulation sets the deadline. The Reserve Bank of India (RBI), India's central bank, issued a circular dated 20 September 2019 on failed transactions, in force from 15 October 2019.

For an ATM where the account was debited but cash was not dispensed, the bank must reverse it within T plus 5 days. T is the calendar date of the transaction.

After that, the bank must credit the customer Rs 100 for each day of delay. The circular says this must happen suo moto, meaning on its own, without waiting for a complaint. The same rule covers micro-ATMs, small portable card devices that banking agents, called business correspondents, use to pay out cash.

A second RBI circular, dated 10 August 2021 and in force from 1 October 2021, targets empty machines. If an ATM is out of cash for more than ten hours in a month, a flat penalty of Rs 10,000 per ATM applies.

For a white-label ATM, that penalty is charged to the bank that supplies its cash. The bank may, at its discretion, recover it from the operator. So the operator cares about empty cassettes as much as jams.

This is why the controller reports cash levels, not just faults. The design raises a low-cash event when any cassette drops below a set level, and the operations team sees it on the dashboard.

How do you explain ATM LLD in a system design interview?

Explain it as a state machine with a safe transaction flow, and spend real time on failure states. Follow this order, and talk while you draw.

  1. Agree the scope

    Ask which transactions to support, and confirm that the balance lives at the bank. Say you will focus on withdrawal and its failures.

  2. List the classes

    Atm, CardReader, CashDispenser, Session, Transaction, Journal, BankClient and ReversalQueue. Put hardware behind interfaces so you can test with fakes.

  3. Draw the states

    Idle, Card inserted, Choosing, Waiting for the bank, Dispensing, Eject card and Out of service, with the PIN check between. Show which events each allows.

  4. Walk the happy path

    Bank approves first, then dispense, with journal lines before and after each risky step.

  5. Walk the failures

    An amount the machine cannot pay, decline, timeout, full jam, partial payout and power cut. For each, say what happens to the customer's money.

  6. Show the reversal

    One transaction id, reused on every retry. A disk-backed queue that retries until the bank confirms.

  7. Close with operations

    Cash counts, low-cash alerts and the regulator's deadline for reversals. This shows you think past the code.

Questions people ask

Should the ATM check the account balance itself?
No. The balance lives at the customer's bank, and the approval comes from the bank side, not the machine. The ATM sends the request and obeys the answer. A balance copied onto the machine would go stale at once, because the same account can be used by cards, apps and branches at the same time.
Which design patterns are used in an ATM LLD?
The State pattern is the main one, because each screen allows different actions. You can add Chain of Responsibility to pick notes, where a Rs 500 handler pays what it can and passes the rest to a Rs 200 handler, and so on. A simple factory can create transaction objects such as Withdrawal or BalanceCheck.
What is the difference between a reversal and a refund?
A reversal cancels a transaction that should never have completed, such as a debit with no cash. It points back to the original transaction. A refund is a new transaction that gives money back for one that did complete. ATM failures use reversals, because the withdrawal itself failed.
Do you need locks or threads in an ATM LLD?
Very few. One machine serves one customer at a time, so the session flow runs in order. The real concurrency lives at the bank, where the same account may be debited from many places at once. On the ATM, keep the reversal worker separate and make its queue safe to read and write from two threads.
What should happen if the ATM keeps my card?
In this design, the machine moves to a card retained step, writes it to the journal and ends the session. It keeps a card when the bank tells it to, for example after too many wrong PINs, or when the customer leaves it behind. Staff then follow the bank's process to return or destroy it.
How long does a bank in India take to return money from a failed ATM withdrawal?
Under the RBI's 2019 circular, the bank must reverse a debit with no cash within T plus 5 days, where T is the transaction date. After that, it owes the customer Rs 100 per day of delay, paid without waiting for a complaint. If it is still not resolved, the customer can complain under the RBI's Integrated Ombudsman Scheme, 2021. Checked on 19 October 2026.

Sources and further reading

Practise this with Lala

Practise