← Home
🏗Crack System Design with DSA AnimatorSystem DesignNEW
🏗 System Design›LLD Interview Prep›LLD
🏧

Design an ATM Machine

State-driven cash machine LLD — card/PIN, withdraw/deposit, ledger, and dispenser — asked at Goldman, JP Morgan, Oracle, and most fintech/service loops.

📊 Medium⏱ 40–45 min🏢 Goldman Sachs · JP Morgan · Oracle · Amazon · Paytm · most banking LLD rounds
🎯 Interview goal

Model Card, Account, Transaction, CashDispenser, and ATM facade. Use State for session flow; validate PIN, debit/credit account, dispense notes, log Transaction records.

📋

The interview question

“Design an ATM system.” User inserts card, enters PIN, chooses withdraw or deposit, receives cash or updates balance.

Focus on correct money movement, session states, and failure paths — not bank core mainframe integration.

💡
Clarify single ATM vs network early. Code one ATM + in-memory bank; describe REST to core banking as extension.
❓

Clarifying questions (ask before coding)

Spend 3–5 minutes — banking interviews penalize silent assumptions.

1Withdraw only, or deposit + balance inquiry too?
Why askDeposit needs acceptor or manual credit path; scope v1 APIs.
2Fixed denominations in dispenser (e.g. ₹500, ₹200)?
Why askGreedy note breakdown vs simple “any amount if balance OK”.
3Max wrong PIN attempts before block?
Why askCard lock after 3 tries — security rule to model.
4Single account per card or multi-account?
Why askv1: one Account per Card; extension: account picker menu.
✅

Requirements & assumptions (state aloud)

  • Functional: insert card → validate PIN → withdraw / deposit / balance → eject card.
  • Withdraw: check balance + dispenser cash; debit account; dispense notes; log transaction.
  • Deposit: credit account; log transaction (cash accepted — no physical acceptor in v1).
  • Security: lock card after 3 failed PINs.
  • v1: one ATM, in-memory accounts; synchronized methods for concurrency.
  • Out of scope: check imaging, forex, receipt printer — extensions.
ScopeIn scope (v1)Extension (mention)
CashDispenser with note countsRecycler, deposit acceptor
Auth4-digit PIN on cardOTP, chip crypto
BackendIn-memory Account mapCore banking API + idempotency
🧩

High-level design & class relationships

ATM is the facade: drives State machine (idle → authenticated → transaction).

Account holds balance; Transaction is immutable audit row; CashDispenser tracks physical notes.

  1. Domain: Card, Account, Transaction, CashDispenser
  2. Facade: ATM — insertCard, enterPin, withdraw, deposit, ejectCard
  3. State: IdleState, HasCardState, AuthenticatedState (optional explicit classes)
💳 Insert🔐 PIN📋 Menu💵 Dispense

Idle → Card → PIN → Transaction → Eject

ATM session flow
Card insert → PIN → menu → cash / ledger → eject
📐

UML class diagram (optional — if interviewer asks)

Show ATM at center with associations to Card, CashDispenser, and Transaction log.

Account linked from Card; ATM delegates money ops to account after PIN OK.

UML class diagram for ATM
Figure — ATM, Card, Account, Transaction, CashDispenser

Relationships to label aloud

  • Association: Card → Account (one primary account in v1).
  • Composition (◆): ATM owns CashDispenser and transaction history for this machine.
  • Dependency: ATM creates Transaction records after each successful op.
🎨

Design patterns used (and why)

⚠️
Mention Chain of Responsibility for fraud checks only if interviewer pushes compliance pipeline.
PatternWhereWhy (interview soundbite)
🎬StateATM session: IDLE → CARD_INSERTED → AUTHENTICATEDIllegal ops (withdraw without PIN) rejected by current state.
🪞FacadeATM public methodsHides dispenser math, ledger, and PIN counter from hardware stubs.
🎯Strategy (optional)Note breakdown algorithmGreedy vs min-notes — swap without changing withdraw flow.
📋Command (light)Transaction with type WITHDRAW/DEPOSITAudit trail and undo/reversal in extensions.
💻

Full solution — enums & domain model

1. Enums, Card & Accountjava
public enum TransactionType { WITHDRAW, DEPOSIT, BALANCE_INQUIRY }

public enum AtmState { IDLE, CARD_INSERTED, AUTHENTICATED }

public class Card {
    private final String cardNumber;
    private final String pin;
    private final Account account;
    private boolean blocked;

    // Links card to backing account and stored PIN
    public Card(String cardNumber, String pin, Account account) {
        this.cardNumber = Objects.requireNonNull(cardNumber);
        this.pin = Objects.requireNonNull(pin);
        this.account = Objects.requireNonNull(account);
    }

    // Returns masked card id for receipts
    public String getCardNumber() { return cardNumber; }

    // Validates entered PIN; false if blocked or mismatch
    public boolean validatePin(String entered) {
        if (blocked) return false;
        return pin.equals(entered);
    }

    // Locks card after too many failed PIN attempts
    public void block() { this.blocked = true; }

    // True when card is blocked from further use
    public boolean isBlocked() { return blocked; }

    // Primary account for withdraw/deposit in v1
    public Account getAccount() { return account; }
}

public class Account {
    private final String accountId;
    private BigDecimal balance;

    // Opens account with initial balance
    public Account(String accountId, BigDecimal initialBalance) {
        this.accountId = accountId;
        this.balance = initialBalance;
    }

    // Returns current available balance
    public synchronized BigDecimal getBalance() { return balance; }

    // Debits if sufficient funds; false on insufficient balance
    public synchronized boolean debit(BigDecimal amount) {
        if (amount.compareTo(BigDecimal.ZERO) <= 0) return false;
        if (balance.compareTo(amount) < 0) return false;
        balance = balance.subtract(amount);
        return true;
    }

    // Credits positive deposit amount
    public synchronized boolean credit(BigDecimal amount) {
        if (amount.compareTo(BigDecimal.ZERO) <= 0) return false;
        balance = balance.add(amount);
        return true;
    }

    // Account id for transaction log
    public String getAccountId() { return accountId; }
}
2. Transaction & CashDispenserjava
public class Transaction {
    private final String id;
    private final String accountId;
    private final TransactionType type;
    private final BigDecimal amount;
    private final Instant timestamp;

    // Immutable audit record for one ATM operation
    public Transaction(String accountId, TransactionType type, BigDecimal amount) {
        this.id = UUID.randomUUID().toString();
        this.accountId = accountId;
        this.type = type;
        this.amount = amount;
        this.timestamp = Instant.now();
    }

    // Unique transaction id for reconciliation
    public String getId() { return id; }

    // WITHDRAW, DEPOSIT, or BALANCE_INQUIRY
    public TransactionType getType() { return type; }

    // Amount moved (ZERO for balance inquiry)
    public BigDecimal getAmount() { return amount; }

    // When the transaction occurred
    public Instant getTimestamp() { return timestamp; }
}

public class CashDispenser {
    private final Map<Integer, Integer> noteCounts = new TreeMap<>(Comparator.reverseOrder());

    // Loads initial note inventory by denomination
    public CashDispenser(Map<Integer, Integer> initialNotes) {
        noteCounts.putAll(initialNotes);
    }

    // Total cash currently in the machine
    public synchronized int totalCash() {
        return noteCounts.entrySet().stream()
            .mapToInt(e -> e.getKey() * e.getValue())
            .sum();
    }

    // True if machine can dispense exact amount with available notes
    public synchronized boolean canDispense(int amount) {
        return computeBreakdown(amount) != null;
    }

    // Removes notes and returns breakdown map; null if impossible
    public synchronized Map<Integer, Integer> dispense(int amount) {
        Map<Integer, Integer> breakdown = computeBreakdown(amount);
        if (breakdown == null) return null;
        breakdown.forEach((denom, count) ->
            noteCounts.merge(denom, -count, Integer::sum));
        return breakdown;
    }

    // Greedy breakdown using highest denominations first
    private Map<Integer, Integer> computeBreakdown(int amount) {
        int remaining = amount;
        Map<Integer, Integer> result = new LinkedHashMap<>();
        for (var entry : noteCounts.entrySet()) {
            int denom = entry.getKey();
            int available = entry.getValue();
            int need = Math.min(available, remaining / denom);
            if (need > 0) {
                result.put(denom, need);
                remaining -= need * denom;
            }
        }
        return remaining == 0 ? result : null;
    }
}
⚙️

Full solution — ATM withdraw & deposit

3. ATM facadejava
public class ATM {
    private static final int MAX_PIN_ATTEMPTS = 3;

    private final String atmId;
    private final CashDispenser dispenser;
    private final List<Transaction> ledger = new ArrayList<>();

    private AtmState state = AtmState.IDLE;
    private Card insertedCard;
    private int pinAttempts;

    // Creates ATM with id and initial cash inventory
    public ATM(String atmId, CashDispenser dispenser) {
        this.atmId = atmId;
        this.dispenser = dispenser;
    }

    // Accepts card; rejects if another session active
    public synchronized boolean insertCard(Card card) {
        if (state != AtmState.IDLE) return false;
        if (card.isBlocked()) return false;
        this.insertedCard = card;
        this.pinAttempts = 0;
        this.state = AtmState.CARD_INSERTED;
        return true;
    }

    // Validates PIN; blocks card after max failures
    public synchronized boolean enterPin(String pin) {
        if (state != AtmState.CARD_INSERTED || insertedCard == null) return false;

        if (insertedCard.validatePin(pin)) {
            state = AtmState.AUTHENTICATED;
            return true;
        }

        pinAttempts++;
        if (pinAttempts >= MAX_PIN_ATTEMPTS) {
            insertedCard.block();
            ejectCard();
        }
        return false;
    }

    // Withdraws cash if balance and dispenser allow; logs transaction
    public synchronized Optional<Map<Integer, Integer>> withdraw(BigDecimal amount) {
        if (state != AtmState.AUTHENTICATED || insertedCard == null) {
            return Optional.empty();
        }

        Account account = insertedCard.getAccount();
        int cents = amount.intValue();

        if (!dispenser.canDispense(cents)) return Optional.empty();
        if (!account.debit(amount)) return Optional.empty();

        Map<Integer, Integer> notes = dispenser.dispense(cents);
        if (notes == null) {
            account.credit(amount);
            return Optional.empty();
        }

        ledger.add(new Transaction(account.getAccountId(), TransactionType.WITHDRAW, amount));
        return Optional.of(notes);
    }

    // Credits account and logs deposit transaction
    public synchronized boolean deposit(BigDecimal amount) {
        if (state != AtmState.AUTHENTICATED || insertedCard == null) return false;

        Account account = insertedCard.getAccount();
        if (!account.credit(amount)) return false;

        ledger.add(new Transaction(account.getAccountId(), TransactionType.DEPOSIT, amount));
        return true;
    }

    // Returns balance when authenticated
    public synchronized Optional<BigDecimal> getBalance() {
        if (state != AtmState.AUTHENTICATED || insertedCard == null) {
            return Optional.empty();
        }
        Account account = insertedCard.getAccount();
        ledger.add(new Transaction(account.getAccountId(), TransactionType.BALANCE_INQUIRY, BigDecimal.ZERO));
        return Optional.of(account.getBalance());
    }

    // Ends session and returns card
    public synchronized void ejectCard() {
        insertedCard = null;
        pinAttempts = 0;
        state = AtmState.IDLE;
    }

    // Read-only copy of transactions processed on this ATM
    public List<Transaction> getLedger() {
        return Collections.unmodifiableList(ledger);
    }

    // Current session state for UI / tests
    public AtmState getState() { return state; }
}
4. Demo mainjava
public class AtmDemo {
    // Simulates insert → PIN → withdraw → deposit → eject
    public static void main(String[] args) {
        Account account = new Account("ACC-001", new BigDecimal("10000"));
        Card card = new Card("4111-1111-1111", "1234", account);

        Map<Integer, Integer> notes = Map.of(500, 10, 200, 10, 100, 10);
        ATM atm = new ATM("ATM-BLR-01", new CashDispenser(notes));

        atm.insertCard(card);
        atm.enterPin("1234");

        atm.withdraw(new BigDecimal("1500"))
           .ifPresent(b -> System.out.println("Dispensed: " + b));

        atm.deposit(new BigDecimal("2000"));
        atm.getBalance().ifPresent(b -> System.out.println("Balance: " + b));

        atm.ejectCard();
    }
}
🎤

While you code — questions interviewers ask

Expect mid-coding probes — answer in one sentence, keep typing.

🚀

Extensions & follow-ups (senior loop)

  • Multi-account card: pick account after PIN.
  • Deposit acceptor: validate note count before credit.
  • Admin refill: operator mode to load CashDispenser.
  • Fraud chain: velocity limits, geo check — Chain of Responsibility.
  • Distributed: ATM calls BankService REST with timeout + saga reversal.
⚠️

Common mistakes (fail signals)

  • Allowing withdraw without PIN or after eject.
  • Debit account before checking dispenser inventory.
  • No rollback when dispense fails after debit.
  • Storing PIN in plain text on Card without mentioning hash/salt in prod.
  • God ATM class with SQL, UI, and fraud in one file.
  • Ignoring blocked card and max PIN attempts.
🧾

60-second cheat sheet

Ask firstOps scope, denominations, PIN lock, multi-account
Core classesCard, Account, Transaction, CashDispenser, ATM
Flowinsert → PIN → withdraw/deposit → eject
PatternsState (session), Facade (ATM), Command (Transaction)
Withdrawcheck dispenser → debit → dispense → log
ExtendCore bank API, idempotency, fraud chain
🔗

More LLD problems