Back to System Design
System Design
hard
mid

How would you design and implement an ATM machine in low level design?

OOP LLD with clear responsibilities: `ATM` (state machine: idle → authenticated → operating), `Card` (number, expiry, validate), `BankAccount` (balance, withdraw, deposit, transactions), `Transaction` (type, amount, status, ts), `CashDispenser` (inventory by denomination, dispense algorithm), plus a bank service for auth/balance. State machine on the ATM and transactional consistency are the depth pieces.

6 min read·~45 min to think through

An ATM LLD question wants OOP decomposition, a state machine for the ATM, and awareness of transactional consistency. Keep classes focused — single responsibility — and discuss the depth pieces (the state machine and dispenser algorithm).

1. Domain decomposition

ts
ATM
 ├── state: ATMState (Idle, CardInserted, Authenticated, SelectingOp, Dispensing, ...)
 ├── currentCard, currentAccount
 ├── cashDispenser: CashDispenser
 └── bank: BankService

Card
 ├── number, expiry, holderName
 └── isValid(): boolean

BankAccount
 ├── id, holderId, balance
 ├── dailyWithdrawn (with reset at midnight)
 ├── withdraw(amount), deposit(amount), getBalance()
 └── transactions: Transaction[]

Transaction
 ├── id, accountId, type, amount, status, ts
 └── status: PENDING | SUCCESS | FAILED

CashDispenser
 ├── inventory: { 100: count, 50: count, 20: count, 10: count }
 └── dispense(amount): { 100: n, 50: n, ... } | error

BankService
 ├── authenticate(cardNumber, pin)
 ├── getAccountForCard(card)
 ├── recordTransaction(transaction)
 └── (in real life, the remote backend; mock here)

2. The state machine

ts
Idle
CardInserted (on card)
Authenticated (on correct PIN)        |  Idle (on cancel / 3 wrong PINs → card eat)
SelectingOp (after auth)
      → CheckingBalance | Depositing | Withdrawing | Transferring
Dispensing (if withdrawing)
          → CompletingTransaction
Ejecting (card out)
              → Idle
js
class ATM {
  constructor(bank, dispenser) {
    this.bank = bank;
    this.dispenser = dispenser;
    this.state = "Idle";
    this.card = null;
    this.account = null;
  }

  insertCard(card) {
    if (this.state !== "Idle") throw new Error("Cannot insert card now");
    if (!card.isValid()) { this.state = "Idle"; return { error: "Invalid card" }; }
    this.card = card;
    this.state = "CardInserted";
  }

  enterPin(pin) {
    if (this.state !== "CardInserted") throw new Error("PIN not expected");
    const auth = this.bank.authenticate(this.card.number, pin);
    if (!auth.ok) { /* track attempts, eat card after 3 */ return; }
    this.account = this.bank.getAccountForCard(this.card);
    this.state = "Authenticated";
  }

  withdraw(amount) {
    if (this.state !== "Authenticated") throw new Error("Not authenticated");
    if (amount > this.account.balance) return { error: "Insufficient funds" };
    if (this.account.dailyLimitExceeded(amount)) return { error: "Daily limit" };

    const dispense = this.dispenser.preview(amount);
    if (dispense.error) return dispense;

    // transactional: deduct + dispense atomically
    const tx = this.account.withdraw(amount);             // status PENDING
    try {
      this.dispenser.commit(dispense.plan);
      tx.markSuccess();
      this.bank.recordTransaction(tx);
      return { dispensed: dispense.plan };
    } catch (e) {
      this.account.refund(amount);                        // compensate
      tx.markFailed();
      this.bank.recordTransaction(tx);
      return { error: "Dispense failed" };
    }
  }

  ejectCard() {
    this.card = null; this.account = null; this.state = "Idle";
  }
}

3. Cash dispenser algorithm

Dispense the requested amount using available denominations. Greedy works for most denominations:

js
class CashDispenser {
  constructor(inventory) { this.inventory = inventory; }

  preview(amount) {
    const denoms = [100, 50, 20, 10];
    const inv = { ...this.inventory };
    const plan = {};
    for (const d of denoms) {
      const n = Math.min(Math.floor(amount / d), inv[d]);
      if (n > 0) { plan[d] = n; amount -= n * d; inv[d] -= n; }
    }
    if (amount > 0) return { error: "Cannot dispense — denominations" };
    return { plan };
  }

  commit(plan) {
    for (const [d, n] of Object.entries(plan)) {
      if (this.inventory[d] < n) throw new Error("Inventory changed");
      this.inventory[d] -= n;
    }
  }
}

Greedy works for standard "canonical" coin/bill systems where each denom is a multiple of the smaller — for non-canonical systems (e.g., {1, 6, 10}), use DP for the minimum-count change-making problem.

4. Transactional consistency

The critical piece: deducting from the account and dispensing cash must be atomic. If we deduct then the hardware fails to dispense, the user is short cash. If we dispense then fail to deduct, the bank is out of money.

Real ATMs use two-phase or compensating transactions:

  • Reserve the amount in the account (pending hold).
  • Trigger physical dispense; wait for ack.
  • On ack → finalize the deduction.
  • On no-ack → release the hold (compensate).

The transaction record is the audit trail; never delete it.

5. Receipts, daily limits, fees

  • Receipt printed (or skipped) at the end — generated from the Transaction record.
  • Daily withdrawal limit tracked per account/card; reset on schedule.
  • Foreign-network fees added as separate transaction or part of the withdrawal.

6. Concurrency

If multiple operations could happen on the same account (joint accounts), the bank service handles concurrency — the ATM is single-user but the backend must lock or use optimistic concurrency on the account record.

7. The OO principles in play

  • Single responsibilityCashDispenser only dispenses; BankAccount only manages balance + transactions; ATM orchestrates.
  • Open/closed — adding a "Transfer" operation doesn't change ATM's state machine fundamentally — add a new branch off Authenticated.
  • Dependency inversionATM depends on a BankService interface, not a specific implementation (mock vs real backend).

Interview framing

"Decompose: ATM (state machine), Card, BankAccount, Transaction, CashDispenser, BankService. The depth pieces are (1) the ATM state machine — Idle → CardInserted → Authenticated → SelectingOp → operation-specific → Ejecting → Idle, with PIN-failure card-eat after 3 attempts; (2) the cash-dispenser algorithm — greedy works for standard denominations; non-canonical needs DP; and (3) transactional consistency — deducting balance and physical dispense must be atomic, in real ATMs via two-phase or compensating transactions with a pending hold. Daily limits, fees, and the audit-trail transaction record round it out. The OO principles: SRP per class, open/closed for new operations, dependency-injected BankService for testability."

Follow-up questions

  • Walk through the state machine for a withdrawal.
  • Why does the deduct+dispense need to be atomic and how do you achieve it?
  • When does greedy dispense fail and what do you use instead?
  • How do you handle a hardware failure mid-dispense?

Common mistakes

  • Single God-object ATM with no decomposition.
  • No state machine — methods callable in any order.
  • Greedy on non-canonical denominations.
  • Deduct before dispense without rollback path.
  • No audit log / Transaction record.

Performance considerations

  • Not a perf problem — correctness and consistency dominate.

Edge cases

  • 3 wrong PINs → eat card.
  • Withdraw equal to exact balance.
  • Hardware jam mid-dispense.
  • Card removed mid-transaction.
  • Daily limit boundary cases.

Real-world examples

  • Bank ATM software (Diebold, NCR).
  • Vending machines (smaller version of the same problem).

Senior engineer discussion

Seniors emphasize the state machine, the compensating-transaction pattern for atomicity, and SRP per class. They flag DP for non-canonical denominations and an audit-trail Transaction as immutable.

Related questions