Design an ATM

The state-machine showcase — card, PIN, menu, dispense — plus the question that matters: debit first or dispense first?

LLDOODstate-patternconsistency

The analogy: a physical lockbox and a ledger

Imagine a safe deposit box inside a bank vault. To access it, you must walk through a series of checkpoints:

  1. Insert your key (Card).
  2. Enter your password (PIN).
  3. The guard unlocks the door (Session enters Authenticated state).

Once inside, if you withdraw cash, the bank must do two things that cannot fail separately:

  • Hand you the actual physical banknotes.
  • Deduct the amount from your bank statement (the ledger).

If the machine hands you the cash first, and then the power drops before the ledger is updated, the bank loses money forever. If the bank updates the ledger first, and the mechanical rollers jam, you are charged for cash you never received.

Our job is to design the state machine of the ATM session, hide the hardware (dispenser, card reader) behind pluggable ports, and coordinate the withdrawal transaction so that every network failure is safe and reversible.

Scope it first

"Card-based ATM: authenticate with PIN, check balance, withdraw cash, deposit. It talks to the bank over a network. Deep-dive withdrawal — including what happens when the network dies mid-transaction. OK?"

Two design centers: the session state machine (the cleanest State- pattern showcase in the catalog) and the dispense-vs-debit ordering problem — a distributed-consistency question wearing a metal box, and the reason this "easy" classic appears in senior loops.

The state machine

An ATM session is nothing but states — every button means something different depending on where you are:

Run a session below: card → PIN → withdraw → done. Then try the attack the State pattern exists to stop — put withdraw first, while still Idle. There's no transition for it, so it's refused; "dispense without auth" isn't checked for, it has no code path at all.

ATM sessiontime O(1) per eventspace O(states)
insertCardpinOkpinBadwithdrawdoneIdleCardAuthCash
events:insertCardpinOkwithdrawdone

1/5Start in Idle. Each event is handled by the current state — the State pattern moves this branching out of one giant switch and into the state objects themselves.

state = Idle

The State pattern makes each state a class owning its behavior; illegal actions become unrepresentable instead of un-checked:

Python
from abc import ABC, abstractmethod
import threading

class InvalidAction(Exception): pass

class AtmState(ABC):
    def insert_card(self, atm, card_number: str): raise InvalidAction()
    def enter_pin(self, atm, pin: str): raise InvalidAction()
    def withdraw(self, atm, amount: int): raise InvalidAction()
    def eject_card(self, atm): raise InvalidAction()

class IdleState(AtmState):
    def insert_card(self, atm, card_number: str):
        atm.card_number = card_number
        atm.set_state(atm.has_card_state)
        print("Card inserted.")

class HasCardState(AtmState):
    def enter_pin(self, atm, pin: str):
        if atm.bank_network.verify(atm.card_number, pin):
            atm.set_state(atm.authenticated_state)
            atm.failed_attempts = 0
            print("Authenticated.")
        else:
            atm.failed_attempts += 1
            if atm.failed_attempts >= 3:
                print("Card retained for security.")
                atm.set_state(atm.idle_state)
            else:
                print("Incorrect PIN.")
    def eject_card(self, atm):
        atm.card_number = ""
        atm.set_state(atm.idle_state)
        print("Card ejected.")

class AuthenticatedState(AtmState):
    def withdraw(self, atm, amount: int):
        atm.set_state(atm.dispensing_state)
        atm.dispense_cash(amount)
    def eject_card(self, atm):
        atm.card_number = ""
        atm.set_state(atm.idle_state)
        print("Card ejected.")

class DispensingState(AtmState):
    pass

1. Java

Java
import java.util.concurrent.locks.ReentrantLock;

interface AtmState {
    void insertCard(ATMController atm, String cardNumber);
    void enterPin(ATMController atm, String pin);
    void withdraw(ATMController atm, int amount);
    void ejectCard(ATMController atm);
}

class IdleState implements AtmState {
    public void insertCard(ATMController atm, String cardNumber) {
        atm.setCardNumber(cardNumber);
        atm.setState(atm.hasCardState);
        System.out.println("Card inserted.");
    }
    public void enterPin(ATMController atm, String pin) { throw new IllegalStateException("Insert card first"); }
    public void withdraw(ATMController atm, int amount) { throw new IllegalStateException("Insert card first"); }
    public void ejectCard(ATMController atm) {}
}

class HasCardState implements AtmState {
    public void insertCard(ATMController atm, String cardNumber) { throw new IllegalStateException("Card already in machine"); }
    public void enterPin(ATMController atm, String pin) {
        if ("1234".equals(pin)) {
            atm.setState(atm.authenticatedState);
            atm.resetFailedAttempts();
            System.out.println("Authenticated.");
        } else {
            atm.incrementFailedAttempts();
            if (atm.getFailedAttempts() >= 3) {
                System.out.println("Card retained for security.");
                atm.setState(atm.idleState);
            } else {
                System.out.println("Incorrect PIN.");
            }
        }
    }
    public void withdraw(ATMController atm, int amount) { throw new IllegalStateException("Enter PIN first"); }
    public void ejectCard(ATMController atm) {
        atm.setCardNumber(null);
        atm.setState(atm.idleState);
        System.out.println("Card ejected.");
    }
}

class AuthenticatedState implements AtmState {
    public void insertCard(ATMController atm, String cardNumber) { throw new IllegalStateException("Already authenticated"); }
    public void enterPin(ATMController atm, String pin) { throw new IllegalStateException("Already authenticated"); }
    public void withdraw(ATMController atm, int amount) {
        atm.setState(atm.dispensingState);
        atm.dispenseCash(amount);
    }
    public void ejectCard(ATMController atm) {
        atm.setCardNumber(null);
        atm.setState(atm.idleState);
        System.out.println("Card ejected.");
    }
}

class DispensingState implements AtmState {
    public void insertCard(ATMController atm, String cardNumber) {}
    public void enterPin(ATMController atm, String pin) {}
    public void withdraw(ATMController atm, int amount) {}
    public void ejectCard(ATMController atm) {}
}

class ATMController {
    public final AtmState idleState = new IdleState();
    public final AtmState hasCardState = new HasCardState();
    public final AtmState authenticatedState = new AuthenticatedState();
    public final AtmState dispensingState = new DispensingState();

    private AtmState state = idleState;
    private String cardNumber;
    private int failedAttempts = 0;
    private final ReentrantLock lock = new ReentrantLock();

    public void setState(AtmState s) { this.state = s; }
    public void setCardNumber(String num) { this.cardNumber = num; }
    public void resetFailedAttempts() { this.failedAttempts = 0; }
    public void incrementFailedAttempts() { this.failedAttempts++; }
    public int getFailedAttempts() { return failedAttempts; }

    public void insertCard(String cardNumber) {
        lock.lock();
        try { state.insertCard(this, cardNumber); } finally { lock.unlock(); }
    }

    public void enterPin(String pin) {
        lock.lock();
        try { state.enterPin(this, pin); } finally { lock.unlock(); }
    }

    public void withdraw(int amount) {
        lock.lock();
        try { state.withdraw(this, amount); } finally { lock.unlock(); }
    }

    public void dispenseCash(int amount) {
        String txnId = "txn-" + System.currentTimeMillis();
        boolean debitSuccess = true;
        if (debitSuccess) {
            boolean dispenseSuccess = true;
            if (dispenseSuccess) {
                System.out.println("Dispensed " + amount + " cash.");
                setState(idleState);
            } else {
                System.out.println("Dispenser jammed. Reversing transaction.");
                setState(idleState);
            }
        }
    }
}

2. C++

C++
#include <iostream>
#include <string>
#include <memory>
#include <mutex>
#include <stdexcept>

class ATMController;

class AtmState {
public:
    virtual ~AtmState() = default;
    virtual void insertCard(ATMController& atm, const std::string& card) = 0;
    virtual void enterPin(ATMController& atm, const std::string& pin) = 0;
    virtual void withdraw(ATMController& atm, int amount) = 0;
    virtual void ejectCard(ATMController& atm) = 0;
};

class ATMController {
private:
    std::shared_ptr<AtmState> state;
    std::string card_number;
    int failed_attempts = 0;
    std::mutex mtx;
public:
    std::shared_ptr<AtmState> idle;
    std::shared_ptr<AtmState> has_card;
    std::shared_ptr<AtmState> authenticated;
    std::shared_ptr<AtmState> dispensing;

    ATMController();
    void setState(std::shared_ptr<AtmState> s) { state = s; }
    void setCardNumber(const std::string& num) { card_number = num; }
    std::string getCardNumber() const { return card_number; }
    void resetFailedAttempts() { failed_attempts = 0; }
    void incrementFailedAttempts() { failed_attempts++; }
    int getFailedAttempts() const { return failed_attempts; }

    void insertCard(const std::string& card) {
        std::lock_guard<std::mutex> lock(mtx);
        state->insertCard(*this, card);
    }
    void enterPin(const std::string& pin) {
        std::lock_guard<std::mutex> lock(mtx);
        state->enterPin(*this, pin);
    }
    void withdraw(int amount) {
        std::lock_guard<std::mutex> lock(mtx);
        state->withdraw(*this, amount);
    }
    void dispenseCash(int amount);
};

class IdleState : public AtmState {
public:
    void insertCard(ATMController& atm, const std::string& card) override;
    void enterPin(ATMController& atm, const std::string& pin) override { throw std::runtime_error("Insert card first"); }
    void withdraw(ATMController& atm, int amount) override { throw std::runtime_error("Insert card first"); }
    void ejectCard(ATMController& atm) override {}
};

class HasCardState : public AtmState {
public:
    void insertCard(ATMController& atm, const std::string& card) override { throw std::runtime_error("Card already in machine"); }
    void enterPin(ATMController& atm, const std::string& pin) override;
    void withdraw(ATMController& atm, int amount) override { throw std::runtime_error("Enter PIN first"); }
    void ejectCard(ATMController& atm) override;
};

class AuthenticatedState : public AtmState {
public:
    void insertCard(ATMController& atm, const std::string& card) override { throw std::runtime_error("Already authenticated"); }
    void enterPin(ATMController& atm, const std::string& pin) override { throw std::runtime_error("Already authenticated"); }
    void withdraw(ATMController& atm, int amount) override;
    void ejectCard(ATMController& atm) override;
};

class DispensingState : public AtmState {
public:
    void insertCard(ATMController& atm, const std::string& card) override {}
    void enterPin(ATMController& atm, const std::string& pin) override {}
    void withdraw(ATMController& atm, int amount) override {}
    void ejectCard(ATMController& atm) override {}
};

ATMController::ATMController() {
    idle = std::make_shared<IdleState>();
    has_card = std::make_shared<HasCardState>();
    authenticated = std::make_shared<AuthenticatedState>();
    dispensing = std::make_shared<DispensingState>();
    state = idle;
}

void IdleState::insertCard(ATMController& atm, const std::string& card) {
    atm.setCardNumber(card);
    atm.setState(atm.has_card);
    std::cout << "Card inserted.\n";
}

void HasCardState::enterPin(ATMController& atm, const std::string& pin) {
    if (pin == "1234") {
        atm.setState(atm.authenticated);
        atm.resetFailedAttempts();
        std::cout << "Authenticated.\n";
    } else {
        atm.incrementFailedAttempts();
        if (atm.getFailedAttempts() >= 3) {
            std::cout << "Card retained for security.\n";
            atm.setState(atm.idle);
        } else {
            std::cout << "Incorrect PIN.\n";
        }
    }
}

void HasCardState::ejectCard(ATMController& atm) {
    atm.setCardNumber("");
    atm.setState(atm.idle);
    std::cout << "Card ejected.\n";
}

void AuthenticatedState::withdraw(ATMController& atm, int amount) {
    atm.setState(atm.dispensing);
    atm.dispenseCash(amount);
}

void AuthenticatedState::ejectCard(ATMController& atm) {
    atm.setCardNumber("");
    atm.setState(atm.idle);
    std::cout << "Card ejected.\n";
}

void ATMController::dispenseCash(int amount) {
    std::string txn_id = "txn-cpp";
    bool debit_success = true;
    if (debit_success) {
        bool dispense_success = true;
        if (dispense_success) {
            std::cout << "Dispensed " << amount << " cash.\n";
            setState(idle);
        } else {
            std::cout << "Dispenser jammed. Reversing debit transaction.\n";
            setState(idle);
        }
    }
}

Check yourself

Check yourself0/3 answered

1. In an ATM withdrawal transaction, why does the controller debit the bank ledger *before* commanding the physical cash dispenser?

2. How does the ATM prevent double-debiting a customer's account during network timeouts?

3. Why does the ATM retain the card after 3 failed PIN attempts?

Compare the alternative — one class with if self.state == "HAS_CARD" and action == "withdraw"... for every combination — and the pattern sells itself: adding a state (maintenance mode, deposit flow) is a new class, not 12 new elifs. Two timers ride along: every non-Idle state has a timeout → eject → Idle edge (abandoned sessions self-heal — the TTL philosophy), and the card is held by the machine until the session ends for a human-factors reason interviewers enjoy: dispense-before-eject is how people historically left cards behind.

Hardware behind interfaces

The controller never touches metal — it talks to ports (Adapter territory): CardReader, CashDispenser, Screen, Keypad, BankNetwork. Tests inject fakes; a new dispenser model is a new adapter. The one with logic inside is the dispenser:

Denomination dispensing

"₹3,700 from notes of 2000/500/100" is the coin-change problem with inventory limits — and because real currencies are canonical, greedy works: largest note first, bounded by what's in each cassette; if the remainder can't be formed (out of 100s), fail before dispensing anything. A Chain of Responsibility of cassette handlers (2000s → 500s → 100s) models it cleanly — each link takes what it can, passes the remainder. Edge to volunteer: amounts not formable by any combination (₹3,750 with no 50s) are rejected at amount entry — validate early, fail cheap.

Think it through like the interview

Think it through: Design an ATMLLD Classic — state + consistency0/5 stages

PROBLEMDesign an ATM: PIN auth, balance, withdraw, deposit. It talks to the bank over a network. The interviewer will steer you into 'what if the network dies mid-withdrawal?'

  1. 1

    Spot the two design centers

    Before drawing classes: what are the TWO hard parts hiding in this 'easy' prompt?

  2. 2

    Make illegal actions unrepresentable

    Where does 'you can't withdraw before entering a PIN' live in the code?

    unlocks after the stage above
  3. 3

    Hide hardware behind ports

    How do I unit-test a machine with a cash drawer?

    unlocks after the stage above
  4. 4

    The ordering question

    Debit first or dispense first? Don't pick — compare the FAILURES.

    unlocks after the stage above
  5. 5

    Survive the ambiguous timeout

    The debit request times out — did it land? The machine can't know. Now what?

    unlocks after the stage above

The real question: debit first, or dispense first?

Withdrawal touches two systems that can't share a transaction: the bank's ledger (over a network) and the physical cash drawer. Whatever order you pick, the failure between the two steps is the interview:

  • Dispense → debit: machine pays out, network dies before the debit lands → free money, multiplied across a fleet. Unacceptable.
  • Debit → dispense (what real ATMs do): debit succeeds, dispenser jams → customer charged, no cash. Bad — but recoverable, and that asymmetry is the whole answer: money not yet given out can be refunded by software; cash in a stranger's hand cannot be recalled.

So the protocol is debit-first plus compensation (the saga shape, in miniature):

  1. Reserve/debit at the bank — with an idempotency key (transaction id), so retrying an ambiguous timeout can't double-debit.
  2. Command the dispenser; hardware confirms notes-out (sensors).
  3. Confirm settlement to the bank.
  4. Dispense failed? Send a reversal with the same transaction id; if the network is down, queue the reversal locally and replay — the customer is made whole minutes later, and the journal (an append-only local log of every step — event sourcing in a metal box) is the evidence for disputes.

Every step writes the journal before acting — after any crash, the machine replays its journal to discover what it was doing and completes or compensates. That's a write-ahead log (the database trick), reinvented at 4 AM in a gas station.

Practice — level up

An ATM is a finite state machine wrapped around money-safe transactions: each action is legal only in the right state, and the debit has to survive a network that lies. These drills isolate each half.

Practice ladder: State machines & transactions0/4 solved

Climb in order — every rung assumes the one above it. Solve on LeetCode, then tick it here; progress is saved on this device.

Warm-up — the literal machine

Dispense cash, denomination by denomination.
  1. Greedy note dispensing over per-denomination inventory — the cash half of the machine.

Core — guarded transactions

An action runs only when the rules hold.
  1. Withdraw / transfer that validate before they mutate — the rules behind a withdrawal.

  2. Token issue / renew / expire — the PIN-auth session as its own lifecycle.

Stretch — explicit, legal-only states

Model the screens; refuse illegal moves.
  1. A state object with only-legal forward/back moves — the finite-state discipline of the ATM's screens.