Home › Low-Level Design › Design an ATM
D

Design an ATM

An ATM is a vending machine that happens to dispense money. The interesting part is not the cash, it is the strict order of steps and what happens when any one of them fails. Get the order wrong and you either hand out money you never debited, or debit a customer who never got money. Both are the kind of bug that ends up in a news headline.

Low-Level DesignMedium11 min read

This is a classic LLD interview problem, and it sits right at the Medium bar in loops at Amazon, Uber, and most fintech companies. Walk through it like you would at a whiteboard. Budget about 35 to 40 minutes to clear all six parts, because that is the realistic clock you get.

If you remember five things

The whole answer in one glance. Everything below is the defense of these five calls.

  1. It is a state machine. The machine behaves completely differently per screen (idle, has-card, authenticated), so model each screen as a State class and illegal moves become impossible, not guarded by ifs.
  2. The withdraw order is sacred. Check funds, check notes, debit at the bank, then and only then dispense. Every step is ordered so no path can hand out cash before the debit commits.
  3. Keep the bank at arm's length. The machine owns the metal, not the ledger. Talk to the backend through a thin BankService interface and treat it as the source of truth for balances.
  4. Plan before you commit. The dispenser computes a full note plan first and only decrements inventory if the plan covers the amount, so canDispense and dispense never drift and you never half-dispense.
  5. No locks, idempotent debits. One physical machine serves one person, so there is no concurrency inside it. The only race lives at the bank, solved with an atomic, idempotent debit keyed by a transaction ID.

1Clarify the requirements#

Before I draw a single box, I ask questions. The interviewer is watching whether I scope the problem or just start coding.

  • ATM is a trap because people over-build it into a banking system.
  • I want to stay inside the machine, the metal box on the wall, not the bank behind it.
  • Here is roughly how that conversation goes in my head.

What does the machine actually do? Pin down the happy path:

  • A customer inserts a card and enters a PIN.
  • They pick an operation: withdraw cash, deposit cash, check balance.
  • On withdraw, the machine checks the account balance, checks it physically has enough cash, dispenses notes, and the card comes back.

Functional scope I will commit to:

  • Authenticate a card plus PIN.
  • Withdraw cash (the headline feature, so this is where I spend my code budget).
  • Check balance.
  • Deposit cash (I keep this light, one or two sentences).
  • Eject the card and reset.

What I explicitly push out of scope:

  • The bank's core ledger, fraud systems, the network protocol to the bank.
  • Cheque deposits and multi-currency.
  • I talk to the bank through a thin BankService interface and assume it is the source of truth for balances.
  • I own the machine, not the bank. Saying this out loud in the first two minutes is half the battle: it tells the interviewer I know where the boundary is.

Non-functional things that actually matter here:

  • Correctness over everything. Dispensing the wrong amount is a disaster. No double-dispense, ever.
  • One session at a time. A physical ATM serves exactly one person at one moment. That simplifies concurrency a lot, and it is worth saying out loud because it changes my whole answer later.
  • Fail safe. If anything goes wrong mid-transaction, the card comes out and no money moves unless the customer got the cash.

That last point drives the whole design. An ATM is really a state machine with a cash box bolted on.

From the interviewer's chair

I score the first two minutes hard. I want to hear you draw the boundary ("I own the machine, the bank owns the ledger") and name the one-session-at-a-time property on your own. Candidates who start listing database tables for accounts have already told me they will spend 40 minutes building the wrong thing.

2Identify the core entities#

Name the nouns, then justify each one. If I cannot justify a class, it does not exist yet. That discipline keeps me from drawing fifteen boxes I will never use.

  • ATM is the top-level machine. It holds the current state and the hardware. It is the context in the State pattern, nothing more.
  • ATMState is the heart of it. The machine behaves completely differently depending on whether a card is inserted, a PIN is pending, or an operation is selected. That is a State pattern begging to be used.
  • Card carries the card number and a reference to the account. Dumb data holder, no logic.
  • Account is a local view of balance and account number. The real truth lives at the bank, so this is mostly a handle I pass around.
  • CashDispenser manages the physical note inventory and figures out which notes to hand out. This is the one genuinely algorithmic class.
  • BankService is the boundary to the backend. Validate PIN, read balance, debit, credit. I keep it an interface so I can stub it in tests and so the state classes never know about the network.
  • Transaction records what happened: type, amount, result. Useful for receipts and auditing, and a clean hook for the Observer follow-up.
  • Screen / Keypad / CardReader are hardware I model thinly. I do not want the interviewer to think I forgot the physical side, but I also will not write a driver for a keypad.

The entity I lean on hardest is state. Let me show that.

3Class design#

The design splits into three groups.

  • The state machine. An ATMState interface with one concrete class per screen the user can be on. Each state only allows the operations that make sense there. Try to withdraw before entering a PIN and IdleState just refuses. That is the whole point: illegal transitions become impossible instead of guarded by a pile of if checks that someone eventually forgets to update.
  • The hardware and services. CashDispenser, CardReader, BankService. The ATM delegates to these. The dispenser owns the note-counting logic, the one genuinely algorithmic piece.
  • The operations. Withdraw, deposit, balance. I model these as transactions so I get a uniform record and an easy place to hang a receipt.

Here is the class diagram.

classDiagram class ATM { -ATMState currentState -Card currentCard -CashDispenser dispenser -BankService bank +insertCard(card) void +enterPin(pin) void +selectWithdraw(amount) void +ejectCard() void +setState(state) void } class ATMState { <> +insertCard(atm card) void +enterPin(atm pin) void +withdraw(atm amount) void +ejectCard(atm) void } class IdleState class HasCardState class AuthenticatedState class DispensingState class CashDispenser { -Map notes +canDispense(amount) bool +dispense(amount) Map } class BankService { <> +validatePin(card pin) bool +getBalance(account) double +debit(account amount) bool } class Card { -String number -Account account } class Account { -String id -double balance } class Transaction { -TxnType type -double amount -boolean success } ATMState <|-- IdleState ATMState <|-- HasCardState ATMState <|-- AuthenticatedState ATMState <|-- DispensingState ATM o-- ATMState ATM *-- CashDispenser ATM --> BankService Card *-- Account ATM ..> Transaction
The class map. Notice the three arrow types: the ATM aggregates ATMState (hollow diamond, the state swaps as the user moves), composes CashDispenser (filled diamond, born and dies with the machine), and merely associates to BankService (plain arrow, it lives outside the box entirely).

The relationships are not decoration, they tell the interviewer what owns what:

  • The ATM aggregates its current ATMState: the state object gets swapped as the user moves through the flow, so it is an aggregation, not a composition.
  • The ATM composes the CashDispenser: it is born and dies with the machine.
  • The bank is an association because it lives outside the machine entirely.

4Key implementation#

Write the parts that carry the design. Start with the state contract.

public interface ATMState {
    void insertCard(ATM atm, Card card);
    void enterPin(ATM atm, String pin);
    void selectWithdraw(ATM atm, double amount);
    void ejectCard(ATM atm);
}

Then a base class so each concrete state only overrides the moves that are legal for it. Everything else fails loudly, which is exactly what I want: a bug surfaces as an exception, not as a silent no-op.

public abstract class AbstractState implements ATMState {
    public void insertCard(ATM atm, Card card) { reject(); }
    public void enterPin(ATM atm, String pin)  { reject(); }
    public void selectWithdraw(ATM atm, double amount) { reject(); }
    public void ejectCard(ATM atm) { reject(); }

    private void reject() {
        throw new IllegalStateException("Operation not allowed in this state");
    }
}

IdleState is the resting state. The only legal move is inserting a card.

public class IdleState extends AbstractState {
    @Override
    public void insertCard(ATM atm, Card card) {
        atm.setCurrentCard(card);
        atm.setState(new HasCardState());
        atm.getScreen().show("Enter your PIN");
    }
}

HasCardState takes a PIN and asks the bank. Three wrong tries and we eat the card. Success moves us to authenticated.

public class HasCardState extends AbstractState {
    private int attempts = 0;

    @Override
    public void enterPin(ATM atm, String pin) {
        Card card = atm.getCurrentCard();
        if (atm.getBank().validatePin(card, pin)) {
            atm.setState(new AuthenticatedState());
            atm.getScreen().show("Select a transaction");
            return;
        }
        attempts++;
        if (attempts >= 3) {
            atm.getScreen().show("Card retained. Contact your bank.");
            atm.retainCard();
            atm.setState(new IdleState());
        } else {
            atm.getScreen().show("Wrong PIN. Try again.");
        }
    }

    @Override
    public void ejectCard(ATM atm) {
        atm.releaseCard();
        atm.setState(new IdleState());
    }
}
Pause before you read on: in the withdraw flow, which single step is the point of no return, and what has to be true before you reach it?
Reveal the answer
The bank debit. Once the account is debited, the customer is owed cash, so everything that can fail cheaply (amount validation, balance check, and the physical can-dispense check) must run and pass before the debit. Dispensing comes strictly after. Order it any other way and you get a double-dispense or a debit with no cash.

AuthenticatedState is where withdraw lives, and the ordering is the whole point. Check the account has the money, check the machine has the notes, debit at the bank, then and only then dispense. Every one of those four steps is in that order for a reason, and I say each reason aloud as I write it.

public class AuthenticatedState extends AbstractState {
    @Override
    public void selectWithdraw(ATM atm, double amount) {
        Account account = atm.getCurrentCard().getAccount();
        BankService bank = atm.getBank();

        if (amount <= 0 || amount % 100 != 0) {
            atm.getScreen().show("Enter a multiple of 100");
            return;
        }
        if (bank.getBalance(account) < amount) {
            atm.getScreen().show("Insufficient funds");
            return;
        }
        if (!atm.getDispenser().canDispense(amount)) {
            atm.getScreen().show("Machine cannot dispense this amount");
            return;
        }

        // Debit first. If the bank fails, we never touch the cash.
        if (!bank.debit(account, amount)) {
            atm.getScreen().show("Transaction declined");
            return;
        }

        atm.setState(new DispensingState());
        atm.getDispenser().dispense(amount);
        atm.getScreen().show("Please collect your cash");
        atm.releaseCard();
        atm.setState(new IdleState());
    }

    @Override
    public void ejectCard(ATM atm) {
        atm.releaseCard();
        atm.setState(new IdleState());
    }
}

Why the two cheap checks run before the debit:

  • canDispense confirms the physical notes exist, so I never debit an account only to find I cannot make the amount.
  • The debit is the point of no return, so everything that can fail cheaply happens before it.
The ordering is the entire grade

Funds check, cash check, debit, dispense. A senior interviewer is watching for exactly one thing here: that no code path can dispense before the debit commits, and that the debit comes after the cheap checks that can still bail out for free. Say each reason as you write the line and you have answered the question before it is asked.

The ATM itself is thin. It forwards every call to the current state. No business logic lives here, which is exactly what I want.

public class ATM {
    private ATMState state = new IdleState();
    private Card currentCard;
    private final CashDispenser dispenser;
    private final BankService bank;
    private final Screen screen;

    public ATM(CashDispenser dispenser, BankService bank, Screen screen) {
        this.dispenser = dispenser;
        this.bank = bank;
        this.screen = screen;
    }

    public void insertCard(Card card)       { state.insertCard(this, card); }
    public void enterPin(String pin)         { state.enterPin(this, pin); }
    public void selectWithdraw(double amt)   { state.selectWithdraw(this, amt); }
    public void ejectCard()                  { state.ejectCard(this); }

    public void setState(ATMState s) { this.state = s; }
    // getters/setters for card, dispenser, bank, screen...
}

Finally the dispenser, which owns the note math. Greedy pass over denominations sorted high to low, computed into a temporary plan first, committed only if the plan fully covers the amount.

  • Greedy can fail (asking for 30 when you only have 20s), so I never half-dispense.
  • Separating plan from commit makes canDispense and dispense share one honest source of truth instead of drifting apart.
public class CashDispenser {
    private final TreeMap<Integer, Integer> notes; // denom -> count, descending

    public CashDispenser(TreeMap<Integer, Integer> notes) {
        this.notes = notes; // use a descending comparator when building
    }

    public boolean canDispense(double amount) {
        return plan((int) amount) != null;
    }

    public Map<Integer, Integer> dispense(double amount) {
        Map<Integer, Integer> plan = plan((int) amount);
        if (plan == null) throw new IllegalStateException("Cannot dispense");
        plan.forEach((denom, count) -> notes.merge(denom, -count, Integer::sum));
        return plan;
    }

    private Map<Integer, Integer> plan(int amount) {
        Map<Integer, Integer> result = new HashMap<>();
        int remaining = amount;
        for (Map.Entry<Integer, Integer> e : notes.entrySet()) {
            int denom = e.getKey();
            int take = Math.min(remaining / denom, e.getValue());
            if (take > 0) {
                result.put(denom, take);
                remaining -= take * denom;
            }
        }
        return remaining == 0 ? result : null; // null means impossible
    }
}

One honest caveat I volunteer:

  • Greedy is correct for canonical note sets (like 100, 200, 500, 2000) but not for every arbitrary set.
  • If the interviewer pushes on exact-change with a weird denomination set, the clean fix is a small dynamic programming pass bounded by note counts.
  • I would swap only plan(), since the rest of the class does not care how the plan was built.

5Design decisions and patterns used#

State pattern is the backbone.

  • The machine's allowed actions change completely by screen, so each screen gets its own class.
  • This kills the giant switch (currentScreen) that these designs usually rot into, and makes the valid transitions self-documenting.
  • Asked "what happens if someone tries to withdraw before entering a PIN," I point at IdleState and HasCardState: the method simply is not implemented there, so it throws.
  • The compiler and the structure enforce the rules, not my memory.

Strategy shows up in BankService and transaction types.

  • BankService is an interface so the real network client and a test stub are interchangeable.
  • If I needed different withdrawal rules per account type (a daily limit on checking, none on a business line), I would make WithdrawStrategy implementations rather than branching inside the state.

Factory is a light touch.

  • A StateFactory or TransactionFactory keeps construction out of the states.
  • I mention it but do not over-engineer: four new SomeState() calls do not justify a factory on their own.

Observer is my follow-up answer for receipts and audit logging.

  • When a transaction completes, notify a ReceiptPrinter and an AuditLog without the state knowing about either.
  • The state fires one event; who listens is somebody else's problem.

The bigger principle: the ATM class holds no logic. It is a context that delegates. That separation is what the interviewer is grading, more than any single pattern name.

The State pattern is not the answer because it is a pattern. It is the answer because it makes illegal transitions impossible to write.

6Handling edge cases and concurrency#

This problem is really a list of failure cases, so I treat it like one. This section is where Medium candidates separate from strong ones.

Debit succeeds but dispense fails (the scary one).

  • I debited the account, then the dispenser jams.
  • My ordering debits first because the bank is the source of truth and far more reliable than hardware.
  • On a physical dispense failure I need a compensating credit back to the account and I flag the machine out of service.
  • In the interview: wrap the dispense in a try/catch, on failure call bank.credit(account, amount) and move to an OutOfServiceState.
  • Never leave the customer debited with no cash.

Wrong PIN.

  • Counted per session, card retained after three.
  • The counter lives in HasCardState, so a fresh insert starts fresh.
  • I would not store the lifetime count locally: the bank owns lockout policy, because the real limit spans all ATMs, not just this one.

The machine runs out of a denomination.

  • Handled by plan() returning null.
  • If I cannot make the exact amount from available notes, I decline before debiting.
  • That is why canDispense runs before the bank call.

Card yanked out mid-transaction, or a timeout.

  • Each state should honor a timeout that ejects the card and resets to idle.
  • I model it as the state scheduling a reset that fires if no input arrives, say after 30 seconds of silence.

Concurrency, the honest answer:

  • A single physical ATM serves one customer at a time, so there is no concurrency inside the machine.
  • The one shared resource is the account balance, and that race lives at the bank, not here.
  • So I do not sprinkle locks around the ATM. Instead I make the bank debit atomic and idempotent.
  • I attach a transaction ID to each withdrawal so a retry after a timeout cannot double-debit.
  • If the interviewer insists on thread safety for a simulator, I guard the dispenser's note map, since the inventory is the one piece of mutable shared state.
Red flag that fails you here

Reaching for synchronized on every ATM method. It signals you did not notice the machine is single-session, so you are solving a race that does not exist while missing the real one (the shared balance at the bank). The strong answer is idempotent debits keyed by a transaction ID, not a wall of locks.

7Extensions and follow-ups#

  • Receipts and audit: wire in the Observer I mentioned. Each completed Transaction fans out to printer and log.
  • Deposits with note counting: add a DepositState and a cash acceptor that validates and tallies inserted notes before crediting.
  • Multiple accounts per card: after auth, add an AccountSelectionState.
  • Admin mode: a separate state tree for refilling cash and running diagnostics, entered with a service key, not a customer card.
  • Network partition: if BankService is unreachable, fail into OutOfServiceState rather than guessing. Offline withdrawal is a business decision, not an engineering default.
Follow-ups the interviewer will ask (and your answers)
"The debit succeeds but the dispenser jams. Now what?"
I debit first because the bank is the reliable source of truth. If the physical dispense then throws, I catch it, call bank.credit(account, amount) to compensate, and move the machine to OutOfServiceState so the next customer is not served by broken hardware. The customer is never left debited with no cash.
"Someone tries to withdraw before entering a PIN. What happens?"
Nothing bad, by construction. selectWithdraw is not overridden in IdleState or HasCardState, so it falls through to AbstractState.reject() and throws IllegalStateException. The State pattern makes the illegal transition impossible to perform, instead of relying on an if-check I might forget to add.
"The machine cannot make the exact amount from the notes it has. How do you avoid debiting?"
plan() returns null when the greedy pass cannot reach exactly zero remaining, and canDispense is just plan() != null. That check runs before bank.debit, so I decline on the screen and the account is never touched. Plan-then-commit keeps canDispense and dispense honest about the same inventory.
"Do I need locks for concurrency?"
Not inside the ATM: one machine serves one person, so there is no in-process race. The only shared state is the account balance at the bank, which I make atomic and idempotent with a transaction ID so a timeout retry cannot double-debit. If you want a thread-safe simulator, the one thing to guard is the dispenser's note map.
"Greedy note selection can fail on odd denominations. How would you fix it?"
Greedy is correct for canonical sets like 100/200/500/2000 but not arbitrary ones. The fix is a small dynamic programming pass bounded by the note counts, and I only swap the private plan() method. Nothing else in CashDispenser cares how the plan was built, so canDispense and dispense stay unchanged.

8What the interviewer is really checking#

  • They want to see you recognized the State pattern without being told, because the ordered flow is the entire problem.
  • They want the withdraw sequence correct: funds check, cash check, debit, then dispense, with no path that dispenses before debiting.
  • They watch whether you keep the bank at arm's length behind an interface instead of pretending the ATM owns balances.
  • They want to hear the failure story: debit-then-jam, and how you compensate.
  • If you reach for locks everywhere, that is a small red flag. The better answer is idempotent debits and a single-session machine.
  • Scope discipline counts too. The candidates who try to build the whole bank run out of time and never get to the part that actually matters.

What you can now explain in the room

  • Why an ATM is a state machine and how the State pattern makes illegal transitions impossible to write.
  • The exact withdraw order (funds, cash, debit, dispense) and why the debit is the point of no return.
  • How to keep the bank behind a thin interface so the machine never pretends to own balances.
  • Why the dispenser plans before it commits, and when greedy note selection needs a DP fallback.
  • How to recover from debit-then-jam with a compensating credit and an out-of-service state.
  • Why one machine needs no locks, and how an idempotent transaction-ID debit kills the double-debit race.

If you liked the state machine here, try Design a Vending Machine next, and read up on the State and Strategy patterns side by side.

Get a prep plan built around your Design an ATM interview

Tell us the role and your interview date. We will send back a dated, day by day plan. Free.

Thanks. Check your inbox, your Design an ATM prep plan is on the way.