Problem 3: Design an ATM
The setup
"Design an ATM (Automated Teller Machine). Users can insert a card, enter PIN, withdraw cash, deposit cash, check balance, transfer between accounts. Multi-currency optional. The ATM communicates with a bank server for authorization."
This problem combines state machines (ATM has clear UI states), the Command pattern (transactions), and external system integration (bank server). The interviewer is testing whether you can model a multi-step transactional flow with proper failure handling.
Clarifying questions
| Question | Story answer |
|---|---|
| Single-currency or multi? | Single (INR) for v1. |
| What's the cash inventory model? | ATM has fixed slots of ₹100, ₹500, ₹2000 notes. Dispense based on availability. |
| Deposit verification? | Assume the ATM can verify deposited cash on insertion (real ATMs do this). |
| Transaction limits? | ₹50,000 per day per card. |
| What happens on network failure mid-transaction? | Rollback the local state; user retries. |
| Audit trail? | Yes — every transaction logged with timestamp, card, amount, result. |
Core entities
| Entity | Responsibility |
|---|---|
| ATM | The main facade; orchestrates state transitions and external calls |
| ATMState | Interface for each UI state (IdleState, AuthenticatingState, etc.) |
| Card | Represents the user's card; has card number, account info |
| CashDispenser | Manages the ATM's cash inventory; dispenses requested amounts |
| BankService | External interface to the bank; verify PIN, debit account, credit account |
| Transaction | Abstract base for WithdrawTransaction, DepositTransaction, etc. (Command pattern) |
| TransactionLog | Audit log of all transactions |
Design Decision 1: ATM as a State Machine
stateDiagram-v2
[*] --> Idle
Idle --> CardInserted: card swiped
CardInserted --> AuthenticatingPIN: PIN entered
AuthenticatingPIN --> Authenticated: PIN ok
AuthenticatingPIN --> CardInserted: PIN wrong (retry)
AuthenticatingPIN --> CardEjected: 3 failures
Authenticated --> SelectingTransaction
SelectingTransaction --> Withdrawing: withdraw
SelectingTransaction --> Depositing: deposit
SelectingTransaction --> CheckingBalance: balance
Withdrawing --> DispensingCash: amount valid
DispensingCash --> SelectingTransaction: another transaction
DispensingCash --> CardEjected: exit
Depositing --> SelectingTransaction
CheckingBalance --> SelectingTransaction
CardEjected --> Idle
The ATM has clearly defined UI states:
States: IDLE — waiting for card CARD_INSERTED — card read, prompt for PIN PIN_ENTERED — verifying with bank AUTHENTICATED — main menu (withdraw/deposit/balance/etc.) TRANSACTION_IN_PROGRESS — executing chosen transaction PRINTING_RECEIPT — final receipt EJECTING_CARD — returning card Transitions: IDLE + card_inserted → CARD_INSERTED CARD_INSERTED + pin_entered → PIN_ENTERED PIN_ENTERED + bank_ok → AUTHENTICATED PIN_ENTERED + bank_fail → EJECTING_CARD AUTHENTICATED + select_transaction → TRANSACTION_IN_PROGRESS TRANSACTION_IN_PROGRESS + complete → PRINTING_RECEIPT ...
Implement via State pattern:
interface ATMState {
void insertCard(ATM atm, Card card);
void enterPin(ATM atm, String pin);
void selectTransaction(ATM atm, TransactionType type);
void executeTransaction(ATM atm, Transaction tx);
void cancel(ATM atm);
}
class IdleState implements ATMState {
public void insertCard(ATM atm, Card card) {
atm.setCurrentCard(card);
atm.transitionTo(new CardInsertedState());
}
public void enterPin(ATM atm, String pin) {
throw new IllegalStateException("No card inserted");
}
// Other methods: throw or noop
}
class CardInsertedState implements ATMState {
public void enterPin(ATM atm, String pin) {
atm.transitionTo(new PinVerificationState());
boolean ok = atm.getBankService().verifyPin(atm.getCurrentCard(), pin);
if (ok) atm.transitionTo(new AuthenticatedState());
else atm.transitionTo(new EjectingCardState());
}
// Other methods invalid in this state
}
class AuthenticatedState implements ATMState {
public void selectTransaction(ATM atm, TransactionType type) {
Transaction tx = TransactionFactory.create(type, atm);
atm.transitionTo(new TransactionInProgressState(tx));
}
}
State pattern keeps each state's allowed operations local. An attempt to do something invalid (enter PIN when no card inserted) throws an exception or is a no-op — caller doesn't need to check the state externally.
Design Decision 2: Transactions as Command pattern
Each transaction type (withdraw, deposit, transfer, balance check) has different parameters and logic but a common shape: validate, execute, log, produce result.
abstract class Transaction {
protected Card card;
protected TransactionType type;
protected Instant startedAt;
protected TransactionResult result;
public abstract void execute(ATM atm);
public abstract void rollback(ATM atm); // for failure recovery
public TransactionResult getResult() { return result; }
}
class WithdrawTransaction extends Transaction {
private BigDecimal amount;
public void execute(ATM atm) {
// Step 1: check bank balance / authorization
boolean authorized = atm.getBankService().authorize(card, amount);
if (!authorized) {
result = TransactionResult.failed("Insufficient funds");
return;
}
// Step 2: debit account (atomically with subsequent dispense)
DebitResult debit = atm.getBankService().debit(card, amount);
if (!debit.isSuccess()) {
result = TransactionResult.failed("Debit failed");
return;
}
// Step 3: dispense cash
try {
atm.getCashDispenser().dispense(amount);
result = TransactionResult.success(amount, debit.transactionId);
} catch (DispenserException e) {
// Critical: account debited but cash not dispensed. Must reverse.
atm.getBankService().reverseDebit(card, amount, debit.transactionId);
result = TransactionResult.failed("Dispenser malfunction");
}
}
public void rollback(ATM atm) {
if (result != null && result.isSuccess()) {
atm.getBankService().reverseDebit(card, amount, result.transactionId);
}
}
}
class DepositTransaction extends Transaction { ... }
class BalanceInquiryTransaction extends Transaction { ... }
class TransferTransaction extends Transaction { ... }
Why Command pattern: encapsulates the "what" and "how" of each transaction. Caller (the AuthenticatedState) doesn't need to know withdrawal differs from deposit; it just calls execute(). New transaction types are new classes — Open-Closed.
Design Decision 3: Cash dispenser
The dispenser has notes of multiple denominations. To dispense ₹1,300 from {2000, 500, 100}: must figure out a valid combination. Greedy works for these denominations (each is a multiple of smaller ones).
class CashDispenser {
private Map inventory; // notes available
public synchronized void dispense(BigDecimal amount) throws DispenserException {
Map needed = computeDispensingPlan(amount);
if (needed == null) throw new DispenserException("Cannot dispense " + amount);
// Atomically decrement inventory
for (var entry : needed.entrySet()) {
inventory.merge(entry.getKey(), -entry.getValue(), Integer::sum);
}
physicallyDispense(needed);
}
private Map computeDispensingPlan(BigDecimal amount) {
// Greedy: largest denomination first
Map plan = new HashMap<>();
BigDecimal remaining = amount;
for (Denomination d : Denomination.descendingOrder()) {
int available = inventory.getOrDefault(d, 0);
int needed = remaining.divide(d.value, RoundingMode.DOWN).intValue();
int taken = Math.min(needed, available);
plan.put(d, taken);
remaining = remaining.subtract(d.value.multiply(BigDecimal.valueOf(taken)));
}
if (remaining.signum() > 0) return null; // can't fulfill
return plan;
}
}
Cross-examination round 1: network failure
- If bank debit succeeded and dispense failed: reverse the debit (the catch block in WithdrawTransaction above). Risk: the reversal call itself might fail.
- Persistent transaction log: record every state transition. On startup, ATM detects "in-flight transaction without final state" and triggers reconciliation with bank.
- Idempotency keys: every operation has a unique ID. ATM can query bank "what's the status of transaction T?" The bank should respond authoritatively. Reconcile based on truth.
Cross-examination round 2: concurrent access
Cross-examination round 3: PIN security
Takeaway
The ATM is the canonical example of: State pattern (UI flow) + Command pattern (transactions) + external-system integration (bank). Each piece is solved by a well-known pattern. The interview test is whether you can recognise each, apply correctly, and reason about failure modes (the bank/network can fail; cash dispense can fail; accidents happen).
The deeper insight: money correctness requires idempotency and reconciliation. Whenever you cross a boundary into an external system (bank), assume the worst can happen mid-operation. Idempotency keys, persistent transaction logs, and reconciliation processes are the safety net.