Skip to content

ATM Machine#

Problem statement (interviewer prompt)

Design the software inside an ATM: authenticate by card + PIN, present a menu (balance / withdraw / deposit / transfer), validate balance + daily limits, dispense cash, print receipts, and handle exceptions (no cash, no network, card retained).

classDiagram
  class ATM
  class CardReader
  class CashDispenser
  class Display
  class Keypad
  class BankSwitch
  class State
  ATM --> CardReader
  ATM --> CashDispenser
  ATM --> Display
  ATM --> Keypad
  ATM --> BankSwitch
  ATM --> State
classDiagram
  direction TB

  class ATM {
    -state: ATMState
    -reader: CardReader
    -dispenser: CashDispenser
    -depositSlot: DepositSlot
    -display: Display
    -keypad: Keypad
    -bank: BankSwitch
    -session: Session
    +insertCard(c)
    +enterPin(p)
    +withdraw(amount)
    +deposit()
    +balance()
    +eject()
  }

  class ATMState {
    <<interface>>
    +next(atm, input)
  }
  class IdleState
  class HasCardState
  class AuthenticatedState
  class TransactionState
  class OutOfServiceState

  class CardReader
  class CashDispenser
  class DepositSlot
  class Display
  class Keypad
  class Receipt

  class BankSwitch {
    +authenticate(card, pin)
    +authorize(tx)
    +debit(account, amount)
    +credit(account, amount)
    +balance(account)
  }

  class Transaction {
    <<interface>>
    +execute(atm)
  }
  class Withdraw
  class Deposit
  class Balance
  class Transfer

  ATM --> ATMState
  ATMState <|.. IdleState
  ATMState <|.. HasCardState
  ATMState <|.. AuthenticatedState
  ATMState <|.. TransactionState
  ATMState <|.. OutOfServiceState
  ATM *-- CardReader
  ATM *-- CashDispenser
  ATM *-- DepositSlot
  ATM *-- Display
  ATM *-- Keypad
  ATM --> BankSwitch
  ATM ..> Transaction
  Transaction <|.. Withdraw
  Transaction <|.. Deposit
  Transaction <|.. Balance
  Transaction <|.. Transfer

ATM state machine#

Most of the design effort in an ATM LLD goes into the state machine, because every screen, button press, and hardware event must be valid for exactly one current state. Modelling the lifecycle as an explicit ATMState interface (rather than a switch on an enum) is what unlocks the State pattern and keeps the ATM god-object from growing dozens of conditional branches. The diagram below shows the happy path plus the three error states an interviewer will probe: PinFailed, InsufficientFunds, and CashUnavailable.

stateDiagram-v2
  direction TB
  [*] --> Idle
  Idle --> CardInserted: insertCard
  CardInserted --> PinEntered: enterPin
  CardInserted --> CardEjected: cancel
  PinEntered --> MenuShown: pinValid
  PinEntered --> PinFailed: pinInvalid
  PinFailed --> PinEntered: retry left
  PinFailed --> CardRetained: 3 strikes
  MenuShown --> TransactionInProgress: selectTxn
  MenuShown --> CardEjected: exit
  TransactionInProgress --> DispensingCash: withdraw ok
  TransactionInProgress --> InsufficientFunds: balance low
  TransactionInProgress --> CashUnavailable: dispenser empty
  InsufficientFunds --> MenuShown: ack
  CashUnavailable --> MenuShown: ack
  DispensingCash --> CardEjected: cash taken
  CardEjected --> Idle: card removed
  CardRetained --> Idle: ops resets

Three transitions in this diagram carry the most interview weight. First, the PinFailed -> CardRetained edge after three strikes is the security gate that justifies a retry counter on the Session object rather than on the ATM. Second, the DispensingCash -> CardEjected ordering is deliberate: the ATM must commit the debit and confirm the cash drawer fired before releasing the card, otherwise a quick-witted attacker can pull the card mid-debit. Third, CashUnavailable is a hardware-level failure that should not roll back the entire session; only the current Withdraw transaction is aborted, and the user is sent back to MenuShown so they can try a balance enquiry instead.

State pattern in Java#

The Java sketch below pins down the ATMState interface and three concrete states. The key idea is that ATM delegates every user event to state.next(this, input), and each state object decides which transition to fire and which next state to set on the context. This keeps ATM small and lets new states (for example OutOfServiceState during maintenance) be added without touching existing code.

public interface ATMState {
    void onInsertCard(ATM atm, Card card);
    void onEnterPin(ATM atm, String pin);
    void onSelectTxn(ATM atm, Transaction t);
    void onCancel(ATM atm);
}

public final class IdleState implements ATMState {
    public void onInsertCard(ATM atm, Card card) {
        atm.session().bindCard(card);
        atm.setState(new CardInsertedState());
    }
    public void onEnterPin(ATM atm, String pin) { /* ignored */ }
    public void onSelectTxn(ATM atm, Transaction t) { /* ignored */ }
    public void onCancel(ATM atm) { /* no-op */ }
}

public final class PinEnteredState implements ATMState {
    public void onEnterPin(ATM atm, String pin) {
        Session s = atm.session();
        if (atm.bank().authenticate(s.card(), pin)) {
            atm.setState(new MenuShownState());
        } else if (s.incrementFailures() >= 3) {
            atm.reader().retainCard();
            atm.setState(new IdleState());
        }
    }
    /* other handlers omitted */
}

public final class DispensingCashState implements ATMState {
    public void onSelectTxn(ATM atm, Transaction t) {
        try {
            atm.dispenser().dispense(t.amount());
            atm.bank().confirmDebit(t.id());
            atm.printer().print(Receipt.of(t));
            atm.setState(new CardEjectedState());
        } catch (HardwareException e) {
            atm.bank().reverse(t.id());            // journal-based reversal
            atm.setState(new CashUnavailableState());
        }
    }
}

public final class ATM {
    private ATMState state = new IdleState();
    public void setState(ATMState s) { this.state = s; }
    public void insertCard(Card c) { state.onInsertCard(this, c); }
    public void enterPin(String p) { state.onEnterPin(this, p); }
    public void select(Transaction t) { state.onSelectTxn(this, t); }
}

Two non-obvious details: the Session (not the ATM) tracks the PIN-failure counter so resetting state after CardRetained is a single object swap, and the dispenser-failure path uses bank.reverse(t.id()) for a journal-backed reversal rather than rolling back local state. That separation is what lets the bank reconcile mid-flight crashes; see the "Edge cases" section below.

Design patterns used#

  • State is the dominant pattern. Each menu, screen, and hardware mode is its own ATMState implementation, and the ATM context simply forwards events to the current state. Adding a new state (MaintenanceState, LowCashState) does not modify any existing class.
  • Strategy drives the cash-dispensing algorithm. DenominationStrategy.split(amount, inventory) is typically a greedy denomination breakdown (largest notes first), but an interviewer may ask for a variant that rebalances the cassette across the day; swapping the strategy is one constructor argument.
  • Chain of Responsibility handles multi-step transaction validation. A Withdraw flows through PinCheck -> BalanceCheck -> DailyLimitCheck -> FraudCheck -> AmlCheck, each link short-circuits on failure, and new checks (geo-velocity, device-fingerprint) plug in without touching the call site.
  • Singleton is the right fit for the CashDispenser hardware controller because the JVM owns exactly one physical drawer per ATM. The bank switch client and printer driver are usually singletons for the same reason.
  • Command wraps each Transaction so it can be logged, retried, and reversed by id; this is what makes journal-based recovery possible.

Common variants & follow-ups#

  • EMV chip + PIN vs mag-stripe swipe: the chip path runs an offline data authentication step before the PIN is even sent to the bank, which adds a CardAuthenticatingState between CardInserted and PinEntered. Newer terminals also support NFC tap for sub-limit amounts (typically under 5,000 INR or 100 USD) with no PIN at all.
  • Deposit transactions flip the dispenser for an envelope or cash-acceptor module. The acceptor counts and validates notes, so DepositState must wait for CashCountedEvent from the hardware before crediting the account; the credit is intentionally provisional until end-of-day reconciliation.
  • Currency-conversion ATMs at airports add a RateProvider strategy and quote the user in both currencies before debiting; the FX margin is recorded as a separate journal entry for compliance.
  • Mini-statement is a read-only transaction that hits a different RPC on the bank switch and renders the last N entries on the printer; it should never block a withdraw queue.
  • Multi-account on a single card (savings + current) prompts the user to pick an account at MenuShown time, then carries the chosen account id through the Transaction object so daily-limit checks scope to the right account.
  • Biometric authentication (fingerprint or iris) is a drop-in replacement for the PIN step. Modelled as a different AuthenticationStrategy, it leaves the rest of the state machine untouched.

Edge cases#

  • Cash dispensed but receipt printer jams: the transaction is already committed at the bank; the printer failure is an observability event, not a rollback trigger. The screen shows a graceful "receipt unavailable, transaction successful" message and the journal stores the txn id for the user to quote in support calls.
  • Card eaten mid-transaction: if the card reader detects a stuck card during TransactionInProgress, the ATM completes the in-flight transaction (cash is already counted) and then transitions to CardRetained. The retained card is logged so the branch operator can return it.
  • Power loss during dispense: this is why the dispenser writes a journal entry before opening the cassette door. On reboot, the ATM replays the journal, asks the dispenser for its post-fault counters, and either confirms or reverses the debit. The reversal is idempotent on the bank side, keyed by the transaction id.
  • Denomination unavailable: the user asks for 6,500 INR but the cassette only has 500s; the DenominationStrategy either rounds down to the nearest dispensable amount and re-prompts, or refuses and routes to CashUnavailable. Both are valid; pick one and be consistent.
  • Daily withdrawal limit edge: the limit check must run on the bank side, not the ATM, because the user can hop between ATMs. The chain-of-responsibility step calls bank.checkDailyLimit(account, amount) which returns the residual headroom.
  • Concurrent ATM access on the same account: two cards (joint account) at two ATMs in the same minute. The bank switch serialises debits through an account-level lock or an optimistic-concurrency token; the ATM treats a ConcurrencyConflictException as a transient error and prompts the user to retry.

Glossary & fundamentals#

Concept What it is Fundamentals
State machine ATM states mvcc-isolation-levels
Idempotency reversal-safe withdrawals idempotency-retries
Distributed transactions bank switch saga distributed-transactions
Resilience patterns dispenser failure recovery resilience-patterns

Quick reference#

Functional#

  • Card insert + PIN.
  • Withdraw, deposit, balance, transfer, mini-statement.
  • Receipt print.

Non-functional#

  • 99.9%+ uptime.
  • Reversal handling: if cash not dispensed, reverse the debit.

Patterns#

  • State, Command (transactions), Strategy (denomination split).

Trade-offs#

  • Online vs offline mode: some ATMs allow limited offline with delayed posting.
  • Cash inventory management with denomination optimization is its own LLD.

Refs#

  • Common LLD references; banking integration whitepapers.

FAQ#

How do you design an ATM in LLD?#

Model an ATM that owns CardReader, Keypad, Display, CashDispenser, and a BankSwitch. Use the State pattern (Idle, CardInserted, Authenticated, ChoosingTxn) so each input is handled by the current state object.

Why use the State pattern in ATM design?#

Each ATM state has different valid inputs. Encapsulating behavior in state classes avoids large switch statements and makes adding states (OutOfCash, NetworkDown) safe.

How is authentication modeled?#

Card holds the card number; the AuthenticatedState validates PIN with BankSwitch within a retry counter. On success it transitions to ChoosingTxn; on lockout it ejects or retains the card.

How is the withdraw flow designed?#

Withdraw uses a Transaction object with amount, account, and timestamp. The ATM debits via BankSwitch atomically, then dispenses cash and prints a receipt. Any step failure rolls back.

How do you handle hardware exceptions like no cash?#

CashDispenser raises a domain exception (OutOfCashException). The state transitions to OutOfCashState which only allows balance inquiry and gracefully tells the user.

Video walkthrough

Design an ATM: Low Level Design with State Design Pattern : via Concept && Coding