- Problem Statement
- What to Say First
- Class Diagram
- Design Patterns — All Four
- State Transitions
- Cash Dispensing Algorithm
- Concurrency Design
- Complexity Analysis
- Interview Time Plan
- Common Follow-up Questions
- Improvements to Mention at the End
Design an ATM Machine that supports:
- Card insertion and PIN authentication (debit cards only)
- Cash withdrawal with denomination tracking
- Balance inquiry
- PIN change
- 3-attempt PIN lockout (card retained on failure)
- Concurrency: multiple ATMs serving the same account simultaneously
"I see four design decisions before writing any code.
Decision 1 — State management → State Pattern. An ATM has strict state transitions. You can't withdraw before authenticating. You can't authenticate before inserting a card. If I use switch/if-else in every ATM method, adding a new state (say CardBlocked) means touching every conditional. State Pattern gives each state its own class — ATM just delegates. One new state = one new class, zero changes to ATM.
Decision 2 — State creation → Factory Pattern. ATM never calls
new IdleState()directly. The Factory centralizes state creation and caches one instance per state per ATM, since states hold an ATM reference (not sharable across ATMs, but reusable within one ATM).Decision 3 — Cash dispensing → Strategy Pattern. Different ATMs may have different dispensing policies. The algorithm is injected — ATM never knows which strategy it's using. Swap to DP-based dispensing for non-canonical denomination sets without touching ATM.
Decision 4 — Concurrency → ReentrantLock on Account. Two ATMs can hit the same account simultaneously. Balance check and debit must be atomic together. I use tryLock with timeout to avoid deadlock."
ATM «Singleton»
│ currentState: ATMState ← delegated to for every user action
│ currentCard: Card
│ currentAccount: Account
│ dispenser: CashDispenser
│ bank: Bank
│ stateFactory: ATMStateFactory
│
│ insertCard() / authenticate() / withdraw()
│ checkBalance() / changePIN() / ejectCard()
│ transitionTo(ATMStateType) ← called by states to trigger transitions
│
├──► ATMStateFactory
│ cache: Map<ATMStateType, ATMState> ← per-ATM cache
│ getState(type): ATMState
│
├──► ATMState «interface»
│ insertCard / authenticate / withdraw
│ checkBalance / changePIN / ejectCard
│ │ implements
│ ┌─────┼──────────────────┐
│ IdleState CardInserted AuthenticatedState
│ State
│ (tracks failed attempts)
│
├──► CashDispenser
│ notes: TreeMap<Integer, Integer> ← denomination → count
│ strategy: DispenseStrategy
│ canDispense(amount): boolean
│ dispense(amount): Map<Integer,Integer>
│
├──► DispenseStrategy «interface»
│ dispense(amount, available): Map<Integer,Integer>
│ │ implements
│ GreedyDispenseStrategy (optimal for canonical denomination sets)
│
├──► Card
│ cardNumber, cardHolderName, accountId
│ hashedPIN ← never raw PIN
│ validatePIN(raw) / updatePIN(new) / block()
│
├──► Account
│ accountId, balance (volatile)
│ lock: ReentrantLock ← concurrency guard
│ debit() / credit() / getBalance()
│
└──► ATMTransaction (immutable)
type, amount, accountId, status, failureReason, timestamp
The problem it solves: Without State Pattern, every ATM method has the same guard:
// WITHOUT State Pattern — fragile:
public void withdraw(double amount) {
if (state == IDLE) throw new Exception("Insert card first");
if (state == CARD_INSERTED) throw new Exception("Authenticate first");
// actual logic...
}
// Add CardBlocked state → fix every single method. Error-prone.With State Pattern:
// ATM has zero conditionals — pure delegation:
public void withdraw(double amount) { currentState.withdraw(amount); }
// State decides what happens. ATM never changes.Invalid operations: Each state implements invalidOp(msg) helper — throws IllegalStateException with a user-friendly message. No duplicated throw logic.
Key distinction from Splitwise Factory: States here hold an ATM reference — they are NOT sharable across ATMs. But within one ATM, the same IdleState object can be reused every time we return to IDLE.
Per-ATM caching:
ATMStateFactory(atm) constructor pre-builds all 3 states for this ATM.
getState(IDLE) always returns the same cached IdleState for this ATM.
Different ATMs have different factories → different cached objects.
Adding a new state (e.g., CardBlockedState):
- Add
CARD_BLOCKEDtoATMStateTypeenum - Create
CardBlockedState implements ATMState - Add
cache.put(ATMStateType.CARD_BLOCKED, new CardBlockedState(atm))in Factory constructor
Zero changes to ATM, zero changes to other states.
Separates the cash dispensing algorithm from ATM mechanics. ATM and CashDispenser never know which algorithm is running.
// Swap dispensing algorithm without touching ATM:
CashDispenser dispenser = new CashDispenser(new GreedyDispenseStrategy()); // current
CashDispenser dispenser = new CashDispenser(new DPDispenseStrategy()); // futureOne physical machine = one instance. Uses Double-Checked Locking with volatile.
private static volatile ATM instance;
public static ATM getInstance(String atmId, Bank bank, CashDispenser dispenser) {
if (instance == null) {
synchronized (ATM.class) {
if (instance == null) instance = new ATM(atmId, bank, dispenser);
}
}
return instance;
} insertCard()
┌──────────────────────────────────────────────────┐
▼ │ ejectCard()
IDLE ──insertCard()──► CARD_INSERTED ──authenticate()──► AUTHENTICATED
▲ │ │
│ │ ejectCard() │ ejectCard()
│ └───────────────────────────────┘
│
└────────── 3 wrong PINs → card.block() → back to IDLE
Key transition rules:
| From | Action | To | Condition |
|---|---|---|---|
| IDLE | insertCard() | CARD_INSERTED | Always |
| CARD_INSERTED | authenticate() | AUTHENTICATED | PIN correct |
| CARD_INSERTED | authenticate() | IDLE | 3rd wrong PIN (card blocked) |
| CARD_INSERTED | ejectCard() | IDLE | Always |
| AUTHENTICATED | ejectCard() | IDLE | Always |
| AUTHENTICATED | withdraw/balance/changePIN | AUTHENTICATED | Stays in state |
Greedy (largest denomination first) is NOT always optimal for arbitrary coin systems.
Counterexample for arbitrary denominations:
Denominations: [1, 3, 4], Amount: 6
Greedy: 4 + 1 + 1 = 3 notes ✗
Optimal: 3 + 3 = 2 notes ✓
But for ATM denominations (₹10, ₹20, ₹50, ₹100, ₹200, ₹500, ₹2000), greedy IS provably optimal.
The property that makes them canonical: each denomination is large enough relative to smaller ones that using a larger note can never be bettered by a combination of smaller notes. This is the "canonical coin system" property (Pearson, 1994).
Greedy on ATM example:
Withdraw ₹4700. Available: 2000×10, 500×20, 200×15, 100×30
Step 1: 4700 / 2000 = 2 notes → remaining = 700
Step 2: 700 / 500 = 1 note → remaining = 200
Step 3: 200 / 200 = 1 note → remaining = 0
Result: 2×₹2000 + 1×₹500 + 1×₹200 = 4 notes ✓ OPTIMAL
When would you need DP?
If the ATM used non-canonical denominations. DP: dp[i] = min notes for amount i, O(amount × D). The Strategy Pattern enables this swap without touching the ATM.
Greedy complexity:
- Time: O(D) where D = denomination types (typically 6–7 for real ATMs)
- Space: O(D) for result map
WITHOUT lock:
ATM-A: reads balance = ₹5000, checks ≥ ₹5000 → OK
ATM-B: reads balance = ₹5000, checks ≥ ₹5000 → OK ← interleaved!
ATM-A: deducts ₹5000 → balance = ₹0
ATM-B: deducts ₹5000 → balance = -₹5000 ← MONEY CREATED
WITH ReentrantLock:
ATM-A acquires lock → read balance → deduct → release lock
ATM-B waits → acquires lock → reads ₹0 → rejects → release
✓ Correct
| Feature | synchronized |
ReentrantLock |
|---|---|---|
| tryLock with timeout | ❌ | ✅ Prevents deadlock |
| Interruptible wait | ❌ | ✅ Thread can be cancelled |
| Re-entrant | ✅ | ✅ Same thread can re-acquire |
| Explicit intent | ❌ | ✅ lock()/unlock() in try/finally |
lock() blocks indefinitely. If a future feature requires locking two accounts (fund transfer), two ATMs locking in opposite order could deadlock forever. tryLock(timeout) returns false instead — we can back off and retry. Best practice in any financial system.
balance is declared volatile — guarantees that single reads (for display in checkBalance) see the latest value across threads. For transactional reads (check-then-debit), we still require the lock — volatile alone doesn't make compound operations atomic.
| Operation | Time | Notes |
|---|---|---|
insertCard |
O(1) | Map lookup in Bank |
authenticate |
O(1) | Hash comparison |
withdraw |
O(D) | D = denomination types (≤7) |
checkBalance |
O(1) | volatile read |
changePIN |
O(1) | Hash + update |
ejectCard |
O(1) | State transition |
canDispense |
O(D) | Dry-run greedy |
dispense |
O(D) | Greedy + deduct stock |
transitionTo |
O(1) | Map lookup in Factory cache |
loadCash |
O(log D) | TreeMap insert |
| Time | What to do |
|---|---|
| 0–5 min | Clarify. Open with all 4 design decisions. Sketch state diagram. |
| 5–10 min | Code ATMStateType enum + ATMState interface |
| 10–15 min | Code ATMStateFactory — explain per-ATM caching |
| 15–25 min | Code all 3 concrete states — explain invalid op helper |
| 25–30 min | Code Account with ReentrantLock — explain race condition |
| 30–35 min | Code DispenseStrategy + GreedyDispenseStrategy — explain why greedy is optimal |
| 35–40 min | Code ATM Singleton + CashDispenser + Bank |
| 40–45 min | Run simulation. Walk through output. Mention improvements. |
With switch: every ATM method has the same state guard boilerplate. Add a new state → fix every method. With State Pattern: one new class + one Factory registration. ATM never changes. Each state is independently testable.
States hold ATM reference — can't share across ATMs. But within ONE ATM, the same IdleState can be reused every session. Factory provides per-ATM caching: one instance per state per ATM, created upfront. Centralized creation — adding a state = one class + one Factory line.
Lock ordering to prevent deadlock: always acquire the lock for the lower accountId first. Both threads follow the same ordering → circular wait impossible → no deadlock.
Two-phase approach: write a PENDING transaction BEFORE debiting. Mark COMPLETED after dispensing. On restart, check for any PENDING transactions and reconcile — refund if cash was not actually dispensed.
- Add
CARD_BLOCKEDtoATMStateType - Create
CardBlockedState implements ATMState— most operations throw "Card is blocked. Visit your bank." - Add one line in
ATMStateFactoryconstructor
Zero other changes.
For single display reads (checkBalance) — guarantees latest value is seen across threads without lock overhead. For transactional reads (check before debit) — volatile alone is insufficient; we still need the lock because check-and-debit is a compound operation.
| Feature | Approach | Pattern/Note |
|---|---|---|
| CardBlocked state | New CardBlockedState + Factory entry |
State + Factory |
| Daily withdrawal limit per card | Track in Card, check in AuthenticatedState | — |
| Receipt printing | ReceiptPrinter with print strategy |
Strategy |
| Network bank calls | Replace Bank with BankService (HTTP/ISO8583) |
Adapter |
| Non-canonical denominations | DPDispenseStrategy as alternative |
Strategy |
| Concurrent ATMs on same account | Distributed lock (Redis SETNX) | — |
| Crash recovery | PENDING transaction + reconciliation on restart | — |
| Card types (credit, prepaid) | Subclass or interface for Card |
Polymorphism |
| Audit logging to DB | Observer on recordTransaction |
Observer |
| Multiple currencies | Add currency field + FX converter | Strategy |
| Principle | Where |
|---|---|
| Single Responsibility | State handles logic. Factory creates. Dispenser manages cash. Account manages balance. |
| Open/Closed | New state = new class + one Factory line. New dispense algorithm = new Strategy. Nothing else changes. |
| Liskov Substitution | All states are interchangeable behind ATMState interface. All strategies behind DispenseStrategy. |
| Interface Segregation | ATMState has exactly the operations a user performs — nothing else forced on implementors. |
| Dependency Inversion | ATM depends on ATMState interface, not IdleState directly. CashDispenser depends on DispenseStrategy interface. |
Focus in interview: State transitions diagram + why State Pattern beats switch + concurrency race condition + why greedy is optimal for ATM denominations.