Cruxly › Low-level design › LLD Exercises
ATM Machine
a transaction is a sequence of hardware side effects, and one of them can fail after the money is already committed
Open the interactive lessonPrompt: design the software for an ATM: authenticate a card and PIN, then support balance inquiry, withdrawal and deposit. Withdrawal must dispense the correct physical notes, debit the account, and print a receipt — and the machine must behave correctly if it runs out of cash or loses power mid-transaction.
45 minutes. The interesting part is not authentication. It's that a withdrawal is several hardware operations in sequence, and the software has to be honest about which one is irreversible.
Applying the five moves: nouns are Account, Card, Transaction, CashDispenser — and Transaction as a sequence-with-state (not a single method call) is the modelling decision the whole exercise depends on. The invariant is "the account is debited if and only if cash was actually dispensed" — say this before designing the sequence, because it's the sentence that tells you dispensing has to happen before the debit commits, or a power failure between the two creates a debited account with no cash given. There's no interface seam worth adding for a single ATM in isolation (multiple bank networks would be one, but that's outside this prompt) — naming that absence is the stronger answer. The concurrency sentence: one physical machine serves one card at a time, so the real ordering problem isn't concurrent threads, it's ordering irreversible hardware side effects correctly within a single sequence — say that distinction explicitly, since it's easy to reach for a lock when the actual issue is sequencing.
Scope, and the sequence that is the actual exercise
In scope: authentication (card + PIN), balance inquiry, withdrawal (with note dispensing), deposit. Out of scope (say it): the bank's core ledger system itself (the ATM is a client to it, name that boundary explicitly), fraud detection, card issuance.
A withdrawal is not one atomic action — it's an ordered sequence of independently-failable steps, and the order matters because one step is physically irreversible.
Ordering A: verify -> DEBIT -> dispense -> receipt
Ordering B: verify -> DISPENSE -> debit -> receipt
The state machine and the classes
public enum AtmState { IDLE, CARD_INSERTED, AUTHENTICATED, TRANSACTION_IN_PROGRESS, DISPENSING, OUT_OF_SERVICE }
public interface AccountService {
Money balanceOf(String accountId);
Hold placeHold(String accountId, Money amount) throws InsufficientFundsException;
void commitHold(Hold hold) throws AccountServiceException; // converts hold into a real debit
void releaseHold(Hold hold); // cancels a hold that never became a debit
}
public interface CashDispenser {
/** Throws if the physical tray cannot make this amount with available note denominations. */
DispenseResult dispense(Money amount) throws InsufficientCashException, MechanicalFaultException;
}
AccountService is behind an interface because it is, structurally, an external system — the ATM does not own the ledger, the bank's core banking system does, and the interface is the seam between "hardware and UI I control" and "money system I only call." This mirrors the payment exercise's provider abstraction elsewhere in this track: name the boundary, then design as if the other side of it can fail independently and slowly.
public class WithdrawalTransaction {
private final AccountService accounts;
private final CashDispenser dispenser;
private final ReceiptPrinter printer; // best-effort; failure here doesn't roll back
public WithdrawalResult withdraw(String accountId, Money amount) {
if (accounts.balanceOf(accountId).isLessThan(amount)) {
return WithdrawalResult.declined("Insufficient funds");
}
Hold hold;
try {
hold = accounts.placeHold(accountId, amount); // reversible: a hold, not yet a debit
} catch (InsufficientFundsException e) {
return WithdrawalResult.declined("Insufficient funds");
}
DispenseResult dispensed;
try {
dispensed = dispenser.dispense(amount); // THE IRREVERSIBLE STEP
} catch (InsufficientCashException | MechanicalFaultException e) {
accounts.releaseHold(hold); // cash never left the machine — safe to release
return WithdrawalResult.failed("Unable to dispense cash");
}
try {
accounts.commitHold(hold); // convert hold into a real debit
} catch (AccountServiceException e) {
// Cash is ALREADY in the user's hand. This is the one case that cannot
// be cleanly rolled back — it must be logged for manual reconciliation,
// not silently swallowed and not retried blindly against a possibly-
// still-broken account service.
auditLog.recordUncommittedDispense(accountId, amount, dispensed, hold);
return WithdrawalResult.succeededWithReconciliationPending(dispensed);
}
printer.print(receiptFor(accountId, amount, dispensed)); // best-effort, never blocks the result
return WithdrawalResult.succeeded(dispensed);
}
}
Every failure before dispensing is fully recoverable — declined or a released hold, the user's account is untouched. The one failure after dispensing (commitHold throwing) is the single case the design cannot make disappear, and the correct answer is not a clever retry — it's an audit log entry and an honest result type that says reconciliation is pending, rather than pretending the transaction cleanly failed or cleanly succeeded.
Note dispensing, and power loss mid-dispense
Computing which physical notes to dispense for a given amount is a coin-change problem again — the same algorithm as the vending machine's change-making, applied to withdrawal amounts instead of change owed. A machine stocked with $20s and $50s asked to dispense $30 has no valid combination at all — this must be caught and reported as a denomination failure, distinct from insufficient total cash, because the fix a user needs to hear is different: "try a multiple of $20," not "this machine is empty."
public DenominationPlan planDispense(Money amount, Map<Denomination, Integer> trayStock) {
// Greedy over available denominations, largest first, checking stock at each step —
// the same greedy-with-a-known-exception-case as the vending machine's change-maker.
Map<Denomination, Integer> plan = new LinkedHashMap<>();
long remaining = amount.cents();
for (Denomination d : Denomination.descendingValues()) {
int available = trayStock.getOrDefault(d, 0);
int use = (int) Math.min(available, remaining / d.valueCents());
if (use > 0) { plan.put(d, use); remaining -= use * d.valueCents(); }
}
if (remaining != 0) throw new InsufficientCashException("No valid note combination for this amount");
return new DenominationPlan(plan);
}
Power loss mid-dispense is the hardware failure mode this exercise is really testing whether you'll raise unprompted. If the machine loses power after some notes have physically dropped but before the software recorded how many, the honest answer has two parts: the dispenser hardware itself typically has a sensor-verified count (the software asks the tray "how many notes did you actually eject," not just "how many did I ask for"), and the transaction log must be written before commanding the dispenser, not after — so that on restart, an ATM can detect "I commanded a dispense and never recorded completion" and flag itself OUT_OF_SERVICE pending manual reconciliation rather than silently resuming normal operation over an unknown cash-drawer state.
This is the same write-ahead-before-the-irreversible-action discipline as the workflow engine's durable event log elsewhere in this track — the log entry for "about to dispense $200" must be durable before the physical dispense command is sent, so a crash between the two is detectable on restart instead of silently unrecorded.
Follow-ups they will ask
"What if two ATMs somehow process the same card's withdrawal at the same time (cloned card, network partition)?" This pushes the concurrency question onto AccountService, not the ATM — placeHold must be atomic against the account balance at the bank's system of record, the same check-then-act discipline as every other exercise in this track, just relocated to a system this design explicitly treats as external. The ATM's own correctness doesn't change; naming that the hold's atomicity is the bank's core system's responsibility, and that the ATM design is correct given that guarantee, is the right scope boundary to draw.
"Add multi-currency or foreign card support." AccountService and Money already carry currency as part of their contract if designed with the ledger exercise's discipline (currency attached to the money type, not assumed) — this follow-up costs a currency-conversion step inserted before the balance check, not a redesign of the withdrawal sequence itself.
"What should happen if the receipt printer is out of paper?" This is the one step in the sequence explicitly marked best-effort — a failed receipt must never roll back a successful withdrawal or block the result being returned to the user. The honest design decision is offering an on-screen summary or an emailed receipt as the fallback, and naming printer failure as categorically different in severity from every step before it (which are financial) is what shows the earlier ordering decision was deliberate, not incidental.
How to say it in the interview
"The withdrawal isn't one operation, it's a sequence — check funds, dispense cash, debit the account, print a receipt — and cash dispensing is the one step that can't be undone once it happens, so I want to order the sequence so the irreversible step happens last among the things that could plausibly fail, not first."
Checklist
- Modelled withdrawal as an ordered sequence of independently-failable steps, not one atomic action
- Placed the irreversible cash-dispense step after a reversible hold, not before or instead of one
- Treated AccountService as an external system behind an interface, not part of the ATM's own state
- Named the post-dispense commit failure as the one case that cannot be cleanly rolled back
- Distinguished a denomination-combination failure from a total-cash-insufficient failure
- Logged the intent to dispense before commanding the dispenser, not after
- Used a sensor-verified dispensed count rather than trusting the software's requested amount
- Marked receipt printing as best-effort, explicitly not able to roll back a successful withdrawal