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.
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.
Clarifying questions (ask before coding)
Spend 3–5 minutes — banking interviews penalize silent assumptions.
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.
| Scope | In scope (v1) | Extension (mention) |
|---|---|---|
| Cash | Dispenser with note counts | Recycler, deposit acceptor |
| Auth | 4-digit PIN on card | OTP, chip crypto |
| Backend | In-memory Account map | Core 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.
- Domain:
Card,Account,Transaction,CashDispenser - Facade:
ATM—insertCard,enterPin,withdraw,deposit,ejectCard - State:
IdleState,HasCardState,AuthenticatedState(optional explicit classes)
Idle → Card → PIN → Transaction → 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.
Relationships to label aloud
- Association:
Card→Account(one primary account in v1). - Composition (◆):
ATMownsCashDispenserand transaction history for this machine. - Dependency:
ATMcreatesTransactionrecords after each successful op.
Design patterns used (and why)
| Pattern | Where | Why (interview soundbite) |
|---|---|---|
| 🎬State | ATM session: IDLE → CARD_INSERTED → AUTHENTICATED | Illegal ops (withdraw without PIN) rejected by current state. |
| 🪞Facade | ATM public methods | Hides dispenser math, ledger, and PIN counter from hardware stubs. |
| 🎯Strategy (optional) | Note breakdown algorithm | Greedy vs min-notes — swap without changing withdraw flow. |
| 📋Command (light) | Transaction with type WITHDRAW/DEPOSIT | Audit trail and undo/reversal in extensions. |
Full solution — enums & domain model
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; }
}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
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; }
}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
Cardwithout mentioning hash/salt in prod. - God
ATMclass with SQL, UI, and fraud in one file. - Ignoring blocked card and max PIN attempts.