Problem 4.3 — ATM
In one line
State + transaction handling + cash management + authentication. Districts it from vending machine with authentication, session and ledger concerns.
Key ideas
- 01
In plain words: an ATM is a vending machine that dispenses cash instead of chips, but with two extra headaches — it must check who you are, and it must never hand out money it doesn't have or let your balance go negative.
- 02
States: Idle, Authenticated, Withdraw, Dispensing, OutOfService.
- 03
Authentication: card + PIN; 3 attempts lock — keep a session, not a global 'logged in' flag.
- 04
Cash management: denomination counts (100s/500s/2000s); withdrawal must respect available denominations.
- 05
Ledger: every withdrawal debits the account record and decrements cash — both must be atomic (business + machine consistency).
- 06
Transaction history per card; failure modes (insufficient funds, machine out of cash, network timeout) each handled explicitly.
- 07
Concurrency: two ATMs on one account — the account balance check/update must be atomic (DB-level, not a boolean).
- 08
Interview differentiator: prioritize core flow (auth → withdraw → dispense), then replay/rollback on network failure.
Java / Spring map
- →
Map<Denomination, Integer> cash; a WithdrawService that is @Transactional on the account row.
Code & diagrams
Greedy denomination dispense + the two-invariant guard (no overdraft, no fake cash) in one method.
public enum Denomination { TWO_THOUSAND(2000), FIVE_HUNDRED(500), HUNDRED(100);
final int value;
Denomination(int v) { this.value = v; }
}
public class CashTray {
private final Map<Denomination, Integer> counts = new EnumMap<>(Denomination.class);
public CashTray(Map<Denomination, Integer> initial) { counts.putAll(initial); }
// Greedy from largest to smallest. Returns null if the exact amount can't be made.
public synchronized Map<Denomination, Integer> withdraw(int amount) {
if (amount % 100 != 0) throw new IllegalArgumentException("multiples of 100 only");
Map<Denomination, Integer> plan = new EnumMap<>(Denomination.class);
int remaining = amount;
for (Denomination d : Denomination.values()) {
int available = counts.getOrDefault(d, 0);
int use = Math.min(available, remaining / d.value);
if (use > 0) { plan.put(d, use); remaining -= use * d.value; }
}
if (remaining != 0) return null; // machine physically cannot make this amount
plan.forEach((d, n) -> counts.merge(d, -n, Integer::sum)); // commit only if the whole plan works
return plan;
}
}
public class WithdrawService {
private final AccountRepository accounts; // @Transactional boundary lives here
private final CashTray tray;
public WithdrawResult withdraw(String accountId, int amount) {
// INVARIANT 1: no overdraft — checked and debited atomically at the DB row
boolean debited = accounts.debitIfSufficientFunds(accountId, amount); // UPDATE ... WHERE balance >= amount
if (!debited) return WithdrawResult.insufficientFunds();
// INVARIANT 2: no fake cash — the tray can refuse even after the ledger says yes
Map<Denomination, Integer> notes = tray.withdraw(amount);
if (notes == null) {
accounts.credit(accountId, amount); // compensate: give the money back
return WithdrawResult.cannotDispenseExactAmount();
}
return WithdrawResult.success(notes);
}
}Explain without notes
What is exactly 'atomic' about an ATM withdrawal — which two things must succeed together?
Practice
Implement withdraw with greedy denomination selection + 'exact amount unavailable' error.
Trade-offs
- ↔
Safety over speed: cash is a hard limit; never trust an in-memory balance across processes.
Completion checklist
I can state the two invariants an ATM must never break (no overdraft, no fake cash), and show the compensating action when the second check fails after the first succeeds.