ATM Machine
Difficulty: Medium | Track: LLD
Problem statement
Design an ATM machine that handles card insertion, PIN verification, and cash transactions. The system must enforce a strict state machine — operations are only valid in the correct ATM state. It should interact with a bank service for account validation and debiting, and use a denomination algorithm to dispense the minimum number of notes.
Requirements
Functional:
- Accept a card and validate the PIN (block the card after 3 failed attempts)
- Support balance enquiry, cash withdrawal, and mini-statement operations
- Dispense cash using the minimum number of denomination notes
- Print or display a transaction receipt after each operation
- Handle insufficient funds gracefully with a clear error response
- Handle denomination unavailability when the exact amount cannot be dispensed
Out of scope:
- Card issuance and physical card management
- Inter-bank network communication and settlement
- Physical hardware control (motors, sensors, card reader firmware)
Key classes
// State machine
enum ATMState {
IDLE, CARD_INSERTED, PIN_VERIFIED, TRANSACTION_SELECTED;
public void transition(ATMState next) { /* validate legal transitions */ }
}
// Domain models
class Card {
private String cardNumber;
private String bankCode;
private boolean isBlocked;
}
class Account {
private String accountId;
private double balance;
private List<Transaction> recentTransactions;
}
class Transaction {
private TransactionType type; // WITHDRAWAL, BALANCE_ENQUIRY, MINI_STATEMENT
private double amount;
private Instant timestamp;
}
// Bank integration
interface BankService {
Account validateCard(Card card);
boolean validatePIN(Card card, String pin);
double getBalance(String accountId);
boolean debit(String accountId, double amount);
List<Transaction> getMiniStatement(String accountId);
}
// Cash dispensing
enum Denomination {
TWO_THOUSAND(2000), FIVE_HUNDRED(500), TWO_HUNDRED(200), HUNDRED(100);
private final int value;
Denomination(int value) { this.value = value; }
public int getValue() { return value; }
}
class CashDispenser {
private Map<Denomination, Integer> inventory;
public Map<Denomination, Integer> dispenseCash(int amount) { /* greedy algorithm */ }
public boolean canDispense(int amount) { /* check feasibility */ }
public void restock(Map<Denomination, Integer> notes) { }
}
// Receipt output
interface ReceiptPrinter {
void printReceipt(Transaction transaction, Account account);
}
// Core ATM orchestrator
class ATM {
private ATMState currentState;
private Card currentCard;
private Account currentSession;
private int pinAttempts;
private final BankService bankService;
private final CashDispenser cashDispenser;
private final ReceiptPrinter receiptPrinter;
public void insertCard(Card card) { }
public boolean enterPIN(String pin) { }
public double checkBalance() { }
public boolean withdraw(int amount) { }
public List<Transaction> getMiniStatement() { }
public void ejectCard() { }
}
Patterns used
- State: The ATM moves through IDLE → CARD_INSERTED → PIN_VERIFIED → TRANSACTION_SELECTED with illegal transitions rejected, keeping state-specific logic encapsulated.
- Strategy: The cash dispensing algorithm (greedy by denomination) is isolated in
CashDispenser, making it swappable without touching ATM orchestration logic. - Chain of Responsibility: A PIN validation → balance check → dispense chain lets each step either handle or reject the request and pass it along, cleanly separating preconditions.
- Command: Each transaction (withdrawal, enquiry, statement) is modelled as a Command object, enabling uniform logging, receipt generation, and potential undo support.
Key design decisions
- PIN attempt counter lives in ATM session, not BankService: The ATM owns the lockout logic locally so the bank service interface stays stateless and easily mockable; the card-block signal is sent to the bank only on the third failure.
- CashDispenser validates feasibility before debiting:
canDispense(amount)is checked before callingbankService.debit(), preventing a debit with no corresponding cash payout. - Denomination enum ordered largest-first: The greedy algorithm iterates
Denomination.values()in declaration order, so ordering the enum from largest to smallest avoids sorting overhead on every withdrawal. - ReceiptPrinter as interface: Allows swapping between a thermal printer, on-screen display, and SMS receipt without changing ATM or transaction logic.
- Session object wraps Card + Account: Grouping the active card and account into a session makes it easy to clear all ephemeral state atomically on card ejection or timeout.
What the interviewer is looking for
- State machine correctness: Does the design prevent operations in wrong states (e.g., withdrawing before PIN is verified)?
- Denomination algorithm: Can you implement and explain the greedy denomination selection, and identify edge cases where exact change is impossible?
- Error handling breadth: Are insufficient funds, blocked cards, denomination gaps, and bank service failures all handled distinctly?
- Interface segregation: Is
BankServicekept as a thin integration boundary so the ATM core can be unit-tested without a real bank? - Thread safety awareness: Can you identify shared mutable state (inventory in
CashDispenser) and discuss synchronization if multiple sessions were possible?