Modeling withdrawal/deposit/balance-inquiry as Command objects, and the concurrency question that actually matters: two ATMs hitting the same account at once.
Published September 23, 2026
class Account {
private final String accountId;
private double balance;
private long version; // for optimistic locking — see below
}
class Card {
private final String cardNumber;
private final String accountId;
private final String pinHash;
}
class CashDispenser {
private final Map<Integer, Integer> denominationCounts; // e.g. {2000: 10, 500: 40, 100: 100}
boolean canDispense(int amount) { /* greedy or DP check against available denominations */ return true; }
void dispense(int amount) { /* decrement denomination counts */ }
}
class ATM {
private final CashDispenser dispenser;
private final AccountRepository accountRepository;
}
interface ATMTransaction { TransactionResult execute(Account account); }
class WithdrawalTransaction implements ATMTransaction {
private final double amount;
private final CashDispenser dispenser;
public TransactionResult execute(Account account) {
if (account.getBalance() < amount) return TransactionResult.failure("Insufficient funds");
if (!dispenser.canDispense((int) amount)) return TransactionResult.failure("Dispenser cannot fulfill this amount");
account.debit(amount);
dispenser.dispense((int) amount);
return TransactionResult.success();
}
}
class DepositTransaction implements ATMTransaction {
private final double amount;
public TransactionResult execute(Account account) {
account.credit(amount);
return TransactionResult.success();
}
}
class BalanceInquiryTransaction implements ATMTransaction {
public TransactionResult execute(Account account) { return TransactionResult.withBalance(account.getBalance()); }
}
This is Command Pattern (see Command Pattern), not just Strategy: each transaction type is a request object that can be constructed, validated, and executed as a discrete unit — and critically, this shape is what naturally supports building a transaction history/audit log (every executed ATMTransaction is itself a natural log entry) and potential reversal logic, which a pure Strategy ("pick an algorithm") framing doesn't emphasize as directly.
This is the question every interviewer eventually asks for this prompt, and it's the one worth spending real design time on. The consistency boundary is the account's balance check-and-debit, and it needs to be atomic across any ATM touching that account, not just within one ATM process.
class AccountRepository {
// Optimistic locking — see Locking Strategies for the general pattern
boolean debitWithVersionCheck(String accountId, double amount, long expectedVersion) {
// UPDATE accounts SET balance = balance - ?, version = version + 1
// WHERE account_id = ? AND version = ? AND balance >= ?
// returns false (0 rows affected) if the version changed since it was read, OR balance is insufficient
return database.executeUpdate(
"UPDATE accounts SET balance = balance - ?, version = version + 1 WHERE account_id = ? AND version = ? AND balance >= ?",
amount, accountId, expectedVersion, amount
) > 0;
}
}
class WithdrawalTransaction implements ATMTransaction {
public TransactionResult execute(Account account) {
boolean success = accountRepository.debitWithVersionCheck(account.getId(), amount, account.getVersion());
if (!success) return TransactionResult.failure("Concurrent modification — please retry"); // the OTHER ATM won the race
dispenser.dispense((int) amount);
return TransactionResult.success();
}
}
This is the same optimistic-locking pattern from Locking Strategies, applied to the exact scenario it's built for: two ATMs both reading the same account balance, both attempting to debit — the database-level conditional UPDATE (checking version and balance >= amount atomically, in the database, not in application code) guarantees only one of the two concurrent withdrawal attempts succeeds if the combined amount would overdraw the account, regardless of which ATM's application code runs first. Application-level locking (a Java synchronized block, a ReentrantLock) cannot solve this, because the two ATMs are almost certainly separate processes (potentially on separate machines) — the consistency boundary has to live in the shared database, not in any single application's memory.
Q: Why optimistic locking here rather than pessimistic (SELECT ... FOR UPDATE)? A: ATM withdrawals from the same account happening genuinely simultaneously are rare (most accounts aren't being hit by two ATMs at once) — optimistic locking fits low-contention scenarios well (see Locking Strategies), avoiding the cost of holding a database lock across the round-trip to the cash dispenser hardware, which pessimistic locking would require for correctness.
Q: What should the ATM do when debitWithVersionCheck() fails due to a lost race? A: Re-read the current account state and either retry the whole transaction (if the now-current balance still supports it) or surface a clear failure to the user — silently retrying without re-checking balance could still overdraw if the losing ATM's original amount is no longer affordable.
Q: Why check canDispense() on the CashDispenser separately from the balance check? A: Two genuinely independent failure modes: insufficient account balance is a business-logic failure, while a dispenser physically unable to make exact change for a requested amount (e.g. requesting $30 when the machine only stocks $20 and $50 notes) is a hardware/inventory constraint — conflating them would produce a confusing error message that doesn't tell the user which problem actually occurred.
Q: How would you extend TransactionResult logging into a full audit trail? A: Each executed ATMTransaction (with its type, amount, timestamp, resulting account version) becomes a row in a transactions table — since Command objects already encapsulate everything needed to describe 'what happened,' persisting the command itself (or its result) after execution is a natural, low-effort audit log, not a bolted-on afterthought.