The idea
What it is
“Design an ATM.” Most candidates hear “vending machine, but for cash” and start writing states. States are part of it — but they are not why this problem is asked.
An ATM is asked because it is the smallest realistic system where one action must change two independent things, and one of those things is a physical machine that can jam. Debit the account and the notes never come out, and you have taken someone's money. Push the notes out and fail to debit, and the bank has given money away. There is no ordering that is safe by itself — you need a way to undo.
The whole system in three sentences
A session starts when a card goes in and ends when it comes out — nothing survives it. Every transaction runs its cheap checks first (balance, daily limit, can the notes even add up) and only then touches anything. If the physical dispense fails, the account debit is undone.
What separates this from a vending machine
Mechanics
How it works
Step 1 · Clarify — 4 minutes
- Which transactions? — “Balance, withdraw, deposit.” Three is plenty. Transfers add an account-to-account story that eats your clock.
- Does the ATM hold the balance, or does a bank? — “A bank service holds it; the ATM asks.” This one answer creates the two-resource problem you want to talk about.
- What denominations, and how many? — ₹2000/500/200/100 with finite counts. Finite is the interesting version.
- PIN rules? — three attempts, then the card is retained. Cheap to build, and it shows you thought about security.
- Daily limit? — yes, say ₹20,000. It gives you a second, policy kind of failure that is clearly not physical.
- Out of scope — cheque deposits, receipts, card networks, multi-currency, the bank's own persistence.
The question that unlocks the good conversation
Ask: “what happens if the dispenser jams after we've debited the account?” Most candidates never raise it, and it is the single thing this problem exists to test. Asking it in minute three tells the interviewer you have seen a real system before.
Step 2 · The session — everything dies with the card
An ATM is a machine used by strangers, one after another. The most important structural rule is that nothing survives the card ejecting: not the PIN attempts, not the authenticated flag, not the account reference, not the last-viewed balance.
attempts or currentAccount on the ATM instead of on the session is how the next customer sees somebody else's balance. Notation: State diagrams.One field, one bug avoided
Model the session as an object (Session { card, account, attempts, startedAt }) and hold it as a nullable field on the ATM. ejectCard() becomes session = null. Now “did I remember to clear that?” is not a question you can get wrong.
Step 3 · The withdrawal — three checks, then two writes
A withdrawal fails in three genuinely different ways, and the order you check them in is not arbitrary. Cheapest and most reversible first; the one that touches hardware last.
Now the part that makes this an ATM and not a vending machine. The two writes at the bottom are not equal:
public Receipt withdraw(Money amount) {
requireSession();
// ---- every check that can fail, before the first write ----
if (!account.hasFunds(amount)) throw new InsufficientFundsException(amount);
if (!policy.withinDailyLimit(account, amount)) throw new DailyLimitExceededException();
NotePlan plan = dispenser.planFor(amount) // can the CASSETTES build it?
.orElseThrow(() -> new CannotDispenseAmountException(amount));
// ---- two writes; the second one can physically fail ----
account.debit(amount);
try {
dispenser.dispense(plan);
} catch (DispenserFaultException e) {
account.credit(amount); // <-- the whole problem, in one line
throw new TransactionReversedException(e);
}
return new Receipt(account.id(), amount, plan, clock.now());
}“Just dispense first, then debit” does not fix it
Then a failure after dispensing means the bank handed out cash and never charged for it. There is no ordering of two independent writes that is safe on its own — which is exactly why the answer is a compensating action, not a clever order. Say that sentence out loud in the interview; it is the one that lands.
Where this goes if they push further
Real ATMs do not roll back optimistically — the network can drop between the debit and the acknowledgement. They use an idempotency key per transaction so a retry cannot double-charge, plus overnight reconciliation against the machine's physical note count. You do not need to build any of that. Naming it in two sentences is worth more than building half of it.
Choosing the notes
Same greedy walk as making change, with one difference that trips people up: an ATM can only pay in the notes it physically holds, so “₹150” is not a rounding problem, it is impossible.
The class diagram
session is nullable — that is “no card inserted”, modelled instead of flagged. And BankService is an interface with a FakeBank, because you cannot demo a rollback against a real bank. Notation: Class diagrams.The FakeBank is not a shortcut, it is the deliverable
A FakeBank you can tell to fail on demand is how you show the rollback working in your main(). Interviewers remember the demo that printed balance restored: ₹12,000. Depending on an interface rather than a concrete bank is Dependency Inversion (DIP) doing real work for you.
The follow-ups
- “The network dies right after the debit — you never learn if it succeeded.” → an idempotency key per transaction, so the retry is safe, plus reconciliation later. This is the follow-up they most want to hear you handle.
- “Two ATMs, one account, at the same moment.” → the balance lives in the bank, so the bank serialises it. Notice that the ATM's own concurrency problem is trivial — one person stands at it. Contrast with Parking Lot, where many gates hit one lot.
- “Add transfers between accounts.” → now two accounts change, and you are looking at the same both-or-neither problem with no physical component. Same answer, cleaner.
- “Different limits for premium customers.” → the limit is a policy object on the account, not a constant in the ATM. Swappable, like pricing in Parking Lot.
- “Print a receipt / send an SMS.” → the transaction publishes an event; printers and notifiers subscribe. Observer, and it keeps the withdrawal path clean.
- “Support multiple currencies.” →
Moneybecomes amount + currency, and cassettes are per-currency. Mostly a value-object exercise; see Immutability & value objects.
How this round is lost
- No rollback. The single most common miss, and the one the problem exists to catch.
- Session state on the ATM.
attemptsas an ATM field means the next customer inherits it. - Only one kind of decline. Merging insufficient funds, over the limit and cannot dispense into one message throws away the entire modelling insight.
- A real
Bankclass with no seam. Then you cannot demonstrate the failure path, which is the best thing you had to show. doublefor money. On an ATM. Use a minor-unitlongorBigDecimaland say why.
Interactive prototype
See it. Build it. Break it.
A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.
About this simulation
A working ATM, with the two resources shown side by side: the cash cassettes and the account balance. Get the PIN wrong three times and the card is retained. Then find the three different ways a withdrawal can be declined — ₹99,000 (not enough money), ₹150 (no combination of notes makes it), and ₹2,700 after 💸 Run out of small notes (the money exists, the paper doesn't). Finally, the one that matters: hit 🔧 Jam the dispenser and withdraw ₹5,000. Watch the balance drop to ₹7,000 — and then watch it come back, because the notes never left the machine.
Hands-on
Try these yourself
Open the prototype above, predict what happens, then verify.
Get the card eaten
Insert the card, then press 🔢 9999 (wrong) three times. The counter is visible on screen each time, and on the third the card is retained. Note what the machine never tells you: which digit was wrong, or whether the card number even exists.
Find all three declines
Reset, authenticate with 1234, then 💵 Withdraw. Try ₹99,000 — insufficient funds, decided purely from the balance. Try ₹150 — the balance is fine, but no combination of ₹2000/500/200/100 makes 150. Two failures, two completely different causes, two different messages.
Separate the account from the paper
Press 💸 Run out of small notes, then try ₹2,700. The account has ₹12,000 sitting right there and the machine still says no: ₹2,000 + ₹500 = ₹2,500 and nothing can make the last ₹200. Then try ₹5,000 — it works, because 2000×2 + 500×2 fits. The money and the notes are different resources.
Break it on purpose — the one that matters
Press 🔧 Jam the dispenser, then withdraw ₹5,000. Watch the balance panel: ₹12,000 → ₹7,000 → back to ₹12,000. The tray stays empty the whole time. That flicker is a compensating transaction, and it is the reason this problem is in the interview set.
Watch the session die
Authenticate, check the balance, then ⏏ Eject card. Everything on screen resets to WELCOME and the balance panel goes back to ₹—. Ask yourself which fields had to be cleared for that to be true, and what would happen if attempts were stored on the ATM instead of the session.
Build it from memory
Blank file: Money → Cassette and CashDispenser.planFor() → Session → BankService interface with a FakeBank → withdraw() with all three checks and the try/catch rollback → main(). Make your demo call the fake bank in failure mode at least once, and print the balance before, during and after.
In practice
When to use it — and what trips people up
The pattern behind the ATM, and where else it shows up
Strip away the cards and cassettes and you are left with a shape that appears everywhere: one user action that must change two systems, where at most one of them can be rolled back. Once you recognise it, a lot of “hard” design questions become the same question.
- Checkout — charge the card, then reserve the stock. Payment succeeded but the last item just sold? Refund. Same compensating action.
- Booking a seat — take the money, then hold the seat. Any system where the second half is contended has this problem; it is what makes BookMyShow the advanced version of it.
- Sending a file and recording that you sent it — the send is irreversible in exactly the way dispensing cash is.
- Any two-service write without a distributed transaction — which is nearly all of them. The industry name for the general solution is a saga: a sequence of local transactions, each with a compensating action.
The vocabulary that earns the point
“I'd treat this as a saga: local transaction plus compensating action, with an idempotency key so a retry can't double-charge, and reconciliation as the backstop.” One sentence, and you have covered what a real payments team would actually do — without building any of it.
Where this design would stop working
- When the network can fail between debit and acknowledgement. The try/catch only helps if you learn the dispense failed. In production you cannot always tell, which is exactly why idempotency keys and reconciliation exist.
- When the ATM must work offline. Then it needs its own ledger and a sync protocol, and the single source of truth is gone.
- When one account is used from many machines at once. The bank has to serialise it; the ATM cannot. Worth saying explicitly, because it shows you know where the concurrency actually lives.
If you only remember one thing
Do all the checks that can fail, then do the reversible write, then the irreversible one — and be ready to undo the reversible one. That single ordering rule is the ATM problem.
What it gives you
- The rollback path is explicit and demonstrable — a FakeBank plus a faulty dispenser proves it in the main() method.
- Three distinct decline reasons keep the account, the bank policy and the physical machine as separate concerns instead of one vague failure.
- Session-as-a-nullable-object makes “no card inserted” a modelled state, so nothing can leak between customers.
- BankService as an interface means the ATM is testable with no bank at all, and the daily limit lives with the account where it belongs.
- Money as whole paise removes an entire class of rounding bugs from a system that handles cash.
Common mistakes
- The try/catch rollback only works when the failure is observable — a network timeout after the debit is a genuinely harder problem this design does not solve.
- Greedy note selection can decline an amount that a smarter search could have paid, so the machine occasionally refuses a valid request.
- Checking the note plan and then dispensing is two steps; between them another process could in principle change the cassettes, so a real machine needs the dispenser to hold its own lock.
- Modelling Withdrawal / Deposit / BalanceEnquiry as a Transaction hierarchy is often over-engineering at this size — three methods on the ATM are usually enough.
- The design assumes one customer at a time, so it says nothing useful about how the bank keeps a shared account consistent across machines.
Reference
Code & further reading
A minimal reference implementation and pointers worth bookmarking.
import java.util.*;
// ---------- money as whole paise; never a double ----------
record Money(long paise) {
static Money rupees(long r) { return new Money(r * 100); }
Money plus(Money o) { return new Money(paise + o.paise); }
Money minus(Money o) { return new Money(paise - o.paise); }
boolean gt(Money o) { return paise > o.paise; }
long rupees() { return paise / 100; }
public String toString() { return "₹" + rupees(); }
}
class Account {
private final String id;
private Money balance;
private Money withdrawnToday = new Money(0);
Account(String id, Money opening) { this.id = id; this.balance = opening; }
String id() { return id; }
Money balance() { return balance; }
Money usedToday() { return withdrawnToday; }
boolean hasFunds(Money amount) { return !amount.gt(balance); }
void debit(Money amount) {
if (!hasFunds(amount)) throw new IllegalStateException("overdraw");
balance = balance.minus(amount);
withdrawnToday = withdrawnToday.plus(amount);
}
/** The compensating action. Not "undo" — a real, recorded credit. */
void credit(Money amount) {
balance = balance.plus(amount);
withdrawnToday = withdrawnToday.minus(amount);
}
}
// ---------- the bank is behind an interface so failure is demoable ----------
interface BankService {
Account authenticate(Card card, String pin);
void debit(Account a, Money amount);
void credit(Account a, Money amount);
}
record Card(String number, String pin) {}
class FakeBank implements BankService {
private final Map<String, Account> accounts = new HashMap<>();
void register(Card c, Account a) { accounts.put(c.number(), a); }
public Account authenticate(Card card, String pin) {
if (!card.pin().equals(pin)) throw new WrongPinException();
return accounts.get(card.number());
}
public void debit(Account a, Money amount) { a.debit(amount); }
public void credit(Account a, Money amount) { a.credit(amount); }
}
class WrongPinException extends RuntimeException {}
class InsufficientFundsException extends RuntimeException {}
class DailyLimitExceededException extends RuntimeException {}
class CannotDispenseAmountException extends RuntimeException {}
class DispenserFaultException extends RuntimeException {}
class TransactionReversedException extends RuntimeException {
TransactionReversedException(Throwable cause) { super("dispenser fault — debit reversed", cause); }
}
// ---------- the physical half ----------
class Cassette {
final int denomination; // in rupees
int count;
Cassette(int denomination, int count) { this.denomination = denomination; this.count = count; }
}
class CashDispenser {
private final List<Cassette> cassettes; // highest denomination first
private boolean faulty = false;
CashDispenser(List<Cassette> cassettes) {
this.cassettes = new ArrayList<>(cassettes);
this.cassettes.sort(Comparator.comparingInt((Cassette c) -> c.denomination).reversed());
}
void setFaulty(boolean f) { this.faulty = f; }
/** Greedy over what is physically held. Empty == this machine cannot pay that number. */
Optional<Map<Integer, Integer>> planFor(Money amount) {
Map<Integer, Integer> plan = new LinkedHashMap<>();
long left = amount.rupees();
for (Cassette c : cassettes) {
int n = (int) Math.min(left / c.denomination, c.count);
if (n > 0) { plan.put(c.denomination, n); left -= (long) n * c.denomination; }
}
return left == 0 ? Optional.of(plan) : Optional.empty();
}
void dispense(Map<Integer, Integer> plan) {
if (faulty) throw new DispenserFaultException(); // the jam
for (Cassette c : cassettes) {
Integer n = plan.get(c.denomination);
if (n != null) c.count -= n;
}
System.out.println(" [tray] " + plan);
}
}
/** Everything that must die when the card comes out. */
class Session {
final Card card;
final Account account;
Session(Card card, Account account) { this.card = card; this.account = account; }
}
class ATM {
private static final int MAX_PIN_ATTEMPTS = 3;
private static final Money DAILY_LIMIT = Money.rupees(20_000);
private final BankService bank;
private final CashDispenser dispenser;
private Session session; // null == NO_CARD. The state, modelled.
private Card insertedCard;
private int pinAttempts;
ATM(BankService bank, CashDispenser dispenser) { this.bank = bank; this.dispenser = dispenser; }
void insertCard(Card card) { this.insertedCard = card; this.pinAttempts = 0; }
void enterPin(String pin) {
try {
session = new Session(insertedCard, bank.authenticate(insertedCard, pin));
} catch (WrongPinException e) {
if (++pinAttempts >= MAX_PIN_ATTEMPTS) { retainCard(); throw new IllegalStateException("card retained"); }
throw e;
}
}
Money balance() { return requireSession().account.balance(); }
Money withdraw(Money amount) {
Account account = requireSession().account;
// ---- three checks, cheapest first, before ANY write ----
if (!account.hasFunds(amount)) throw new InsufficientFundsException();
if (account.usedToday().plus(amount).gt(DAILY_LIMIT)) throw new DailyLimitExceededException();
Map<Integer, Integer> plan = dispenser.planFor(amount)
.orElseThrow(CannotDispenseAmountException::new);
// ---- two writes; only the first can be taken back ----
bank.debit(account, amount);
try {
dispenser.dispense(plan);
} catch (DispenserFaultException e) {
bank.credit(account, amount); // compensate
throw new TransactionReversedException(e);
}
return amount;
}
void deposit(Money amount) { bank.credit(requireSession().account, amount); }
void ejectCard() { session = null; insertedCard = null; pinAttempts = 0; } // the wipe
private void retainCard() { session = null; insertedCard = null; }
private Session requireSession() {
if (session == null) throw new IllegalStateException("no authenticated session");
return session;
}
}
public class Main {
public static void main(String[] args) {
Card card = new Card("4821", "1234");
Account account = new Account("A-1", Money.rupees(12_000));
FakeBank bank = new FakeBank();
bank.register(card, account);
CashDispenser dispenser = new CashDispenser(List.of(
new Cassette(2000, 2), new Cassette(500, 6), new Cassette(200, 3), new Cassette(100, 5)));
ATM atm = new ATM(bank, dispenser);
atm.insertCard(card);
try { atm.enterPin("9999"); } catch (WrongPinException e) { System.out.println("wrong pin 1/3"); }
atm.enterPin("1234");
System.out.println("balance " + atm.balance());
try { atm.withdraw(Money.rupees(99_000)); }
catch (InsufficientFundsException e) { System.out.println("declined: insufficient funds"); }
try { atm.withdraw(Money.rupees(150)); }
catch (CannotDispenseAmountException e) { System.out.println("declined: no note combination makes ₹150"); }
// ---- the one that matters ----
dispenser.setFaulty(true);
try { atm.withdraw(Money.rupees(5_000)); }
catch (TransactionReversedException e) {
System.out.println("dispenser jammed → balance restored to " + atm.balance());
}
dispenser.setFaulty(false);
atm.withdraw(Money.rupees(5_000));
System.out.println("after clean withdrawal: " + atm.balance());
atm.ejectCard();
System.out.println("card out — session cleared");
}
}References & further reading
6 sources- Docsmicroservices.io
Saga pattern — microservices.io
The general name for “local transaction + compensating action”. This is the ATM's rollback, formalised.
- Docslearn.microsoft.com
Compensating Transaction pattern — Microsoft Azure Architecture
A short, concrete write-up of exactly the undo-the-debit move, with the caveats about when it is not enough.
- Articlegithub.com
awesome-low-level-design — ATM
A second take on the same problem — compare its state set and transaction hierarchy against yours.
- Docsdocs.stripe.com
Idempotency keys — Stripe API reference
How a real payments system makes a retry safe. This is the follow-up answer when the interviewer says “what if the network drops?”
- Articlemartinfowler.com
Patterns of Enterprise Application Architecture — Unit of Work
The classic framing of “a set of changes that commit or roll back together”, which is what the withdrawal is trying to be.
- Articlemartinfowler.com
Money value object — Martin Fowler
Why Money is its own type with integer minor units, and never a floating-point number.
Knowledge check
Did it land?
Quick questions, answers revealed on submit. Sign in to save your best score.
question 01 / 08
The account is debited and then the dispenser jams. What must happen?
question 02 / 08
Why not simply dispense the cash first and debit the account afterwards?
question 03 / 08
A customer with ₹12,000 asks for ₹2,700, but the machine holds only ₹2,000 and ₹500 notes. What is the correct response?
question 04 / 08
Where should the PIN-attempt counter live?
question 05 / 08
Why should BankService be an interface with a FakeBank implementation?
question 06 / 08
Which failure is a policy rule rather than a physical or account fact?
question 07 / 08
The interviewer asks: “what if the network drops after the debit, so you never learn whether the dispense succeeded?”
question 08 / 08
Why check the note plan before debiting rather than while dispensing?
0/8 answered