Design and implement an ATM Machine (LLD: ATM, BankAccount, Card, Transaction, CashDispenser)
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.
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
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
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)
→ Idleclass 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:
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 responsibility —
CashDispenseronly dispenses;BankAccountonly manages balance + transactions;ATMorchestrates. - Open/closed — adding a "Transfer" operation doesn't change
ATM's state machine fundamentally — add a new branch offAuthenticated. - Dependency inversion —
ATMdepends on aBankServiceinterface, 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).