06-lld-interview-problems

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.

August 11, 2026
lldATMstate-machinedenominationtransactionatomicity

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

  1. Customer inserts card → CardReader reads the card → ATM prompts for PIN → bank server authenticates.
  2. 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.
  3. Customer requests deposit → ATM accepts cash/cheque → credits account (cash may credit immediately; cheque credits pending clearance) → prints receipt.
  4. Customer requests balance inquiry → ATM queries bank server → displays balance, no state change.
  5. ATM cannot fulfill a withdrawal amount with its current denomination stock → rejects with a clear reason before any debit occurs.
  6. Three consecutive wrong PIN attempts → card is retained/blocked, session terminated.

3. Class Diagram


4. Core Class Design

State machine — State pattern

java
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

java
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

PatternWhere usedWhy
StateATMState hierarchy (IdleCardInsertedAuthenticatedTransactionInProgress)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.
StrategyDenomination-dispensing algorithm as a pluggable DispensingStrategyLets the bank swap a greedy dispenser for an inventory-aware optimal one without touching ATM or CashDispenser callers.
CommandEach 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.
FacadeBankServerClientATM code talks to one simple interface (authenticate, debitAccount, ...) instead of the bank's internal ledger, fraud, and account microservices directly.
Template MethodTransaction flow: validate → reserve → dispense/credit → confirm/reverse, same skeleton for withdrawal and depositShared 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.

java
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.

java
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 UPDATE row lock or an atomic conditional-decrement (UPDATE accounts SET balance = balance - ? WHERE balance >= ?) at the database layer.
  • CashDispenser.dispense is synchronized at 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 PENDING until clearance; Account.credit() for cheques should not increase available (withdrawable) balance immediately.

7. Trade-offs & Extensions

DecisionTrade-off
Reserve/commit vs. direct debitReserve/commit narrows the atomicity risk window at the cost of an extra network round-trip and added bank-side reservation-expiry logic.
Greedy denomination dispensingSimple and fast (O(denominations)), correct for canonical currency systems; would need DP for a hypothetical non-canonical denomination set.
Synchronous bank-server callsSimpler 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 logLogging 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 ATM state machine, different CardReader implementation.
  • Fraud/velocity checks as an additional filter before reserveFunds.
  • Cash-recycling ATMs where deposited notes are validated and re-dispensed, adding a CashValidator component.

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 State pattern prevent a transaction from starting before authentication, compared to a design using boolean flags like isAuthenticated?
  • 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?