Design an ATM
Design an ATM's software: card authentication, the state machine that drives a transaction, and the denomination-dispensing algorithm — with atomicity as the central concern.
Design an ATM
An ATM looks simple from the customer side — insert card, enter PIN, withdraw cash — but the design interview is really testing two things: can you model a state machine cleanly (a card in the wrong state must never dispense cash), and can you reason about atomicity across two systems that don't share a transaction (the bank's ledger and the physical cash dispenser). Get those two right and the rest of the design falls into place.
1. Requirements
Functional requirements
- Authenticate a user via card (or card number) + PIN.
- Check account balance.
- Withdraw cash, dispensed in physical denominations.
- Deposit cash or a cheque.
- Print a transaction receipt.
- Enforce a daily withdrawal limit per account/card.
- Handle insufficient funds gracefully (reject before dispensing).
- Handle the ATM itself running low or out of specific denominations.
- Support multiple transactions in one session, then eject the card.
Non-functional requirements
- Atomicity above all: an account must never be debited without cash actually dispensed, and cash must never be dispensed without the account being debited. A crash mid-transaction must leave the system in a recoverable, auditable state — not silently lose money either direction.
- Thread/process safety: multiple ATMs (and multiple channels — mobile, web) hitting the same account concurrently must not allow overdrawing via a race condition.
- PINs are never stored or logged in plaintext.
- The machine must fail safe: on hardware fault (dispenser jam, printer out of paper), abort the transaction without committing the debit.
Out of scope: physical card manufacturing, ATM network switch protocols (ISO 8583), cash replenishment logistics.
2. Actors & Use Cases
Actors
- Customer — inserts card, enters PIN, selects a transaction.
- Bank server — the system of record for account balances; authenticates and authorizes.
- ATM hardware — CardReader, CashDispenser, ReceiptPrinter, Screen/Keypad.
- Bank operator — replenishes cash, services the machine.
Primary use cases
- Customer inserts card →
CardReaderreads the card → ATM prompts for PIN → bank server authenticates. - Authenticated customer requests withdrawal → ATM checks daily limit and (via the bank server) account balance → if approved, ATM computes a denomination breakdown → dispenses cash → debits the account → prints receipt.
- Customer requests deposit → ATM accepts cash/cheque → credits account (cash may credit immediately; cheque credits pending clearance) → prints receipt.
- Customer requests balance inquiry → ATM queries bank server → displays balance, no state change.
- ATM cannot fulfill a withdrawal amount with its current denomination stock → rejects with a clear reason before any debit occurs.
- Three consecutive wrong PIN attempts → card is retained/blocked, session terminated.
3. Class Diagram
4. Core Class Design
State machine — State pattern
public interface ATMState {
default void insertCard(ATM atm, Card card) { throw invalid(); }
default void enterPin(ATM atm, String pin) { throw invalid(); }
default void selectTransaction(ATM atm, TransactionType type) { throw invalid(); }
default void ejectCard(ATM atm) { throw invalid(); }
private static IllegalStateException invalid() {
return new IllegalStateException("Operation not valid in current ATM state");
}
}
public final class IdleState implements ATMState {
public void insertCard(ATM atm, Card card) {
atm.setCurrentCard(card);
atm.setState(new CardInsertedState());
}
}
public final class CardInsertedState implements ATMState {
private int attemptsRemaining = 3;
public void enterPin(ATM atm, String pin) {
if (atm.getBankClient().authenticate(atm.getCurrentCard(), pin)) {
atm.setState(new AuthenticatedState());
} else if (--attemptsRemaining == 0) {
atm.getCardReader().retainCard();
atm.setState(new IdleState());
}
// else: remain in CardInsertedState, let the customer retry
}
public void ejectCard(ATM atm) {
atm.getCardReader().ejectCard();
atm.setState(new IdleState());
}
}
public final class AuthenticatedState implements ATMState {
public void selectTransaction(ATM atm, TransactionType type) {
atm.setState(new TransactionInProgressState());
atm.beginTransaction(type);
}
public void ejectCard(ATM atm) {
atm.getCardReader().ejectCard();
atm.setState(new IdleState());
}
}
public final class TransactionInProgressState implements ATMState {
// Withdraw/deposit logic lives in ATM.withdraw()/deposit(), invoked while in this state.
// On completion (success or failure), ATM transitions back to AuthenticatedState
// to allow another transaction, or IdleState after ejectCard().
public void ejectCard(ATM atm) {
atm.getCardReader().ejectCard();
atm.setState(new IdleState());
}
}
public final class OutOfServiceState implements ATMState {
// All operations throw — machine is down for cash replenishment or fault recovery.
}Domain model
public final class Account {
private final String accountId;
private long balanceMinorUnits; // store money as integer minor units (paise/cents)
private final long dailyWithdrawLimitMinorUnits;
private long withdrawnTodayMinorUnits;
private LocalDate withdrawnTodayDate = LocalDate.now();
public synchronized boolean canWithdraw(long amountMinorUnits) {
resetIfNewDay();
return balanceMinorUnits >= amountMinorUnits
&& withdrawnTodayMinorUnits + amountMinorUnits <= dailyWithdrawLimitMinorUnits;
}
public synchronized void debit(long amountMinorUnits) {
resetIfNewDay();
if (!canWithdraw(amountMinorUnits)) throw new IllegalStateException("Debit not permitted");
balanceMinorUnits -= amountMinorUnits;
withdrawnTodayMinorUnits += amountMinorUnits;
}
public synchronized void credit(long amountMinorUnits) {
balanceMinorUnits += amountMinorUnits;
}
private void resetIfNewDay() {
if (!LocalDate.now().equals(withdrawnTodayDate)) {
withdrawnTodayMinorUnits = 0;
withdrawnTodayDate = LocalDate.now();
}
}
// constructor, getters omitted for brevity
private String getAccountId() { return accountId; } // placeholder to keep example self-contained
}
public enum Denomination {
TWO_THOUSAND(2000), FIVE_HUNDRED(500), TWO_HUNDRED(200), HUNDRED(100);
final int value;
Denomination(int value) { this.value = value; }
}
public enum TransactionType { WITHDRAWAL, DEPOSIT, BALANCE_INQUIRY }
public enum TransactionStatus { PENDING, SUCCESS, FAILED, REVERSED }Money is modeled as long minor units (cents/paise), never double. Floating point cannot represent currency exactly, and a rounding error in an ATM is not an academic concern — it is a discrepancy in someone's bank balance.
5. Design Patterns Applied
| Pattern | Where used | Why |
|---|---|---|
| State | ATMState hierarchy (Idle → CardInserted → Authenticated → TransactionInProgress) | Every operation is only valid in specific states; encoding this as classes instead of a flag matrix eliminates whole categories of "dispensed cash while unauthenticated" bugs. |
| Strategy | Denomination-dispensing algorithm as a pluggable DispensingStrategy | Lets the bank swap a greedy dispenser for an inventory-aware optimal one without touching ATM or CashDispenser callers. |
| Command | Each transaction (WithdrawCommand, DepositCommand) encapsulated as an object with execute()/compensate() | Enables a uniform retry/reversal path and an auditable transaction log — same shape as the command queue in a payments pipeline. |
| Facade | BankServerClient | ATM code talks to one simple interface (authenticate, debitAccount, ...) instead of the bank's internal ledger, fraud, and account microservices directly. |
| Template Method | Transaction flow: validate → reserve → dispense/credit → confirm/reverse, same skeleton for withdrawal and deposit | Shared skeleton, different steps per transaction type — avoids duplicating the atomicity-critical sequencing logic. |
6. Key Algorithms, Concurrency & Edge Cases
Denomination dispensing — greedy algorithm
Given available denominations and current inventory, compute the minimum-note breakdown for a requested amount.
public final class CashDispenser {
private final Map<Denomination, Integer> inventory; // sorted access via TreeMap-like ordering
public boolean canDispense(long amount) {
return dispensePlan(amount) != null;
}
/** Greedy: largest denomination first. Optimal when denominations are "canonical"
* (each divides evenly into reasonable combinations, as most currency systems are). */
public Map<Denomination, Integer> dispensePlan(long amount) {
Map<Denomination, Integer> plan = new EnumMap<>(Denomination.class);
long remaining = amount;
List<Denomination> byValueDesc = Arrays.stream(Denomination.values())
.sorted(Comparator.comparingInt((Denomination d) -> d.value).reversed())
.toList();
for (Denomination d : byValueDesc) {
int available = inventory.getOrDefault(d, 0);
int notesNeeded = (int) Math.min(remaining / d.value, available);
if (notesNeeded > 0) {
plan.put(d, notesNeeded);
remaining -= (long) notesNeeded * d.value;
}
}
return remaining == 0 ? plan : null; // null = cannot fulfill with current inventory
}
public synchronized Map<Denomination, Integer> dispense(long amount) {
Map<Denomination, Integer> plan = dispensePlan(amount);
if (plan == null) throw new InsufficientCashException(amount);
plan.forEach((denom, count) -> inventory.merge(denom, -count, Integer::sum));
return plan;
}
}Greedy is optimal for "canonical" denomination systems (like ₹2000/500/200/100 or USD notes) but is not guaranteed optimal in general — a classic algorithms trap. For an arbitrary denomination set, minimizing note count is a coin-change DP problem (O(amount × denominations)), not a greedy one. Mentioning this distinction explicitly is a strong interview signal.
Transaction atomicity: the debit-then-dispense problem
The hard part of this design isn't the state machine — it's that debiting the account (a call to the bank server) and dispensing cash (a local hardware action) are two separate operations that can each independently fail, and they are not part of one database transaction.
public final class WithdrawalTransaction {
private final BankServerClient bankClient;
private final CashDispenser dispenser;
private final ReceiptPrinter printer;
public TransactionResult execute(String accountId, long amount) {
// Step 1: reserve funds (bank-side hold, not yet a final debit)
String reservationId = bankClient.reserveFunds(accountId, amount);
if (reservationId == null) {
return TransactionResult.failed("Insufficient funds or limit exceeded");
}
// Step 2: attempt physical dispensing BEFORE committing the debit
Map<Denomination, Integer> plan;
try {
plan = dispenser.dispense(amount);
} catch (InsufficientCashException e) {
bankClient.releaseReservation(reservationId); // undo the hold — no money moved
return TransactionResult.failed("ATM cannot dispense requested amount");
} catch (HardwareFaultException e) {
bankClient.releaseReservation(reservationId);
return TransactionResult.failed("Dispenser hardware fault");
}
// Step 3: only now, with cash physically out, commit the debit
try {
bankClient.commitReservation(reservationId);
} catch (Exception e) {
// Cash is already out and cannot be un-dispensed. This is the one case where
// ATM and bank ledger can disagree — log for manual reconciliation, never retry blindly.
AuditLog.critical("Cash dispensed but debit commit failed: " + reservationId);
return TransactionResult.reconciliationRequired(reservationId);
}
printer.print(new Transaction(reservationId, TransactionType.WITHDRAWAL, amount, TransactionStatus.SUCCESS));
return TransactionResult.success(plan);
}
}Why reserve-then-commit, not debit-then-dispense: debiting first and then dispensing means a dispenser jam leaves the account debited with no cash out — the worse failure mode from the customer's perspective, and the one that generates support tickets. Reserve/commit (a two-phase pattern) narrows the un-recoverable window to just the final commitReservation call, which is a single, idempotent, retryable network call against the bank's ledger — not a physical, non-retryable dispense action.
Concurrency
- Two channels (a physical ATM and a mobile app) hitting the same account concurrently must not both succeed past a balance check that would jointly overdraw it. This requires the bank server, not the ATM, to serialize debits — typically a
SELECT ... FOR UPDATErow lock or an atomic conditional-decrement (UPDATE accounts SET balance = balance - ? WHERE balance >= ?) at the database layer. CashDispenser.dispenseissynchronizedat the single-machine level since exactly one physical dispenser exists per ATM — no cross-machine coordination needed there.
Edge cases
- Card retained after 3 failed PIN attempts — must transition out of any transaction state cleanly and notify the bank to flag the card.
- Power loss mid-dispense — recovery on reboot must reconcile against the bank's reservation log: any reservation with no matching commit/release after a timeout is auto-reversed.
- Partial dispense (jam after some notes are out) — the dispenser should report actual notes dispensed (via a sensor count), and the committed debit amount must match the actually-dispensed amount, not the requested amount.
- Deposited cheque — credited as
PENDINGuntil clearance;Account.credit()for cheques should not increase available (withdrawable) balance immediately.
7. Trade-offs & Extensions
| Decision | Trade-off |
|---|---|
| Reserve/commit vs. direct debit | Reserve/commit narrows the atomicity risk window at the cost of an extra network round-trip and added bank-side reservation-expiry logic. |
| Greedy denomination dispensing | Simple and fast (O(denominations)), correct for canonical currency systems; would need DP for a hypothetical non-canonical denomination set. |
| Synchronous bank-server calls | Simpler reasoning about atomicity; a slow bank server directly slows the customer's transaction — a queue-based async design would need compensating-transaction complexity instead. |
| Local vs. central transaction log | Logging transactions at both the ATM (for a paper receipt/local audit) and the bank server is redundant but is exactly what lets manual reconciliation happen when they disagree. |
Natural extensions:
- Multi-currency dispensing for international ATMs.
- Contactless/mobile-initiated withdrawals (QR/NFC) — same
ATMstate machine, differentCardReaderimplementation. - Fraud/velocity checks as an additional filter before
reserveFunds. - Cash-recycling ATMs where deposited notes are validated and re-dispensed, adding a
CashValidatorcomponent.
Interview Questions
- Walk through the exact sequence of calls for a withdrawal, and identify the single step where a failure is hardest to recover from.
- Why is "debit the account, then dispense cash" a worse design than "reserve funds, dispense, then commit"? What failure mode does reserve/commit eliminate, and which one does it still leave open?
- Is the greedy denomination-dispensing algorithm always optimal? When would it fail, and what would you use instead?
- How would you prevent two concurrent withdrawals (ATM + mobile app) from together overdrawing an account, given the ATM itself has no visibility into the mobile app's transaction?
- How does the
Statepattern prevent a transaction from starting before authentication, compared to a design using boolean flags likeisAuthenticated? - What would you log, and where, so that a "cash dispensed but debit failed" incident can be reconciled without guessing?
- How would you extend this design for a cash-recycling ATM that also accepts and revalidates deposited notes for later dispensing?