Lesson 24 of 31
Design an ATM in Java
An ATM combines three ideas that interviewers like: a session whose valid operations depend on how far the customer has got (card in, PIN checked), a remote dependency (the bank) that must be replaceable for testing, and a cash dispenser that turns an amount into a set of notes. Each maps to a distinct Java tool: a sealed interface of records for the session, an interface with a fake for the bank, and a chain of responsibility for the notes. This article designs all three and ends with a complete program.
Requirements
Functional requirements
- The customer inserts a card, enters a four digit PIN, and can then check the balance, withdraw cash, and finish by ejecting the card.
- After three wrong PIN entries the machine keeps the card.
- A withdrawal must be a multiple of the smallest note, at most a per-transaction limit, covered by the account balance, and payable with the notes currently in the machine.
- Notes come in 100, 50, 20 and 10 dollar denominations, and the dispenser hands out the largest notes first.
- The bank is an external system. The ATM talks to it through an interface.
Rules and assumptions
- Money is integer cents. Notes are a closed set, so they are an enum.
- A malformed PIN (not four digits) is rejected without using up an attempt.
- If the machine cannot pay, the account must not be debited. If the bank refuses, the machine must not lose notes.
- Out of scope: deposits, receipts, hardware faults, network failures. Deposits and network errors are covered in the extensions.
Entities and relationships
| Noun | Kind of thing | Java form |
|---|---|---|
| Note | one of a fixed set with a value | enum Note |
| Money, CardId, AccountId | values | record types |
| Session | idle, awaiting PIN, authenticated | sealed interface Session with a record per state |
| Bank | external behavior | interface Bank, plus InMemoryBank for tests |
| Cash dispenser | ordered handlers, one per denomination | chain of NoteHandler |
| ATM | orchestrator | Atm owning the session and dispenser, and receiving the bank in its constructor |
The ATM receives its bank as a constructor argument typed as the Bank interface. The production build would pass a network client and the tests pass the fake. See testability and dependency injection.
classDiagram
class Atm {
-bank: Bank
-dispenser: CashDispenser
-session: Session
+insertCard(card)
+enterPin(pin)
+balance() Money
+withdraw(amount) List~NoteCount~
+eject() CardId
}
class Session {
<<sealed interface>>
Idle
AwaitingPin(card, attemptsLeft)
Active(card, account)
}
class Bank {
<<interface>>
+verifyPin(card, pin) AccountId
+balance(account) Money
+debit(account, amount) Money
}
class InMemoryBank
class CashDispenser {
-head: NoteHandler
+plan(amount) List~NoteCount~
+commit(plan)
}
class NoteHandler {
-note: Note
-stock: int
-next: NoteHandler
}
Atm --> Session
Atm o-- Bank
Atm *-- CashDispenser
CashDispenser *-- NoteHandler
NoteHandler *-- NoteHandler : next
Bank <|.. InMemoryBankAPI
final class Atm {
Atm(Bank bank, CashDispenser dispenser, Money limit);
void insertCard(CardId card);
void enterPin(String pin);
Money balance();
List<NoteCount> withdraw(Money amount);
CardId eject();
}
interface Bank {
AccountId verifyPin(CardId card, String pin); // throws BankException
Money balance(AccountId account); // throws BankException
Money debit(AccountId account, Money amount); // throws BankException
}A withdrawal returns the notes to dispense, as NoteCount records (a note and a count). The ATM never prints "dispensing"; the caller (a hardware driver, a test) decides what to do with the result. The bank interface is the smallest set the ATM needs. It knows nothing about sessions or notes.
The failure cases are domain exceptions. Every failure here is an expected business outcome (wrong PIN, not enough cash), and each one must stop the operation before anything changes, which an exception does without extra if checks in the caller chain. The exception carries a sealed AtmError value, so a caller can still switch over the reason exhaustively. The exceptions are unchecked, so they do not appear in every signature.
Design decisions
The session is a sealed interface of records. Idle holds nothing, AwaitingPin holds the card and the attempts left, and Active holds the card and the authenticated account. The Active record is the only place an AccountId exists, so the ATM cannot read a balance without having passed the PIN check inside its own code. Every operation begins by testing the session with pattern matching, and any state that is not valid for that operation throws AtmError.Plain.WRONG_STATE.
Attempts belong to the session, not the bank. The bank answers "is this PIN correct for this card". Counting attempts and keeping the card is the ATM's job, so the count lives in AwaitingPin. Decrementing means creating a new record, so the count cannot drift out of step with the state.
The bank is an interface with an in-memory fake. The fake holds cards and balances in maps. Tests can arrange any account state and run in microseconds, and the real implementation only has to satisfy the same three methods.
Dispensing is a chain of responsibility. Each handler owns one denomination and its stock. It takes as many notes of its kind as it can, subtracts their value from the remaining amount, and passes the rest to the next handler. The last handler's next is null, which is the usual end marker for a linked structure. Adding a new denomination means inserting one more handler, and changing the order (smallest first, or keep a reserve of small notes) means rebuilding the chain.
Plan first, then commit. A handler cannot know whether the whole chain will succeed until the end. So the chain works in two passes: plan reads the stock and builds a list without changing anything, and commit subtracts the notes once the bank has approved the debit. There is no rollback code because nothing was changed before success.
The order of steps in a withdrawal is fixed. Validate the amount, check the limit, plan the notes, debit the bank, then commit the notes. The only step that changes state outside the ATM is the debit, and it comes after everything the ATM can refuse on its own.
Greedy has a known limit. With limited stock a greedy chain can fail when another combination exists (60 dollars from one 50 and three 20s, with no tens, takes the 50 and is stuck). The vending machine article shows an exact search for the same problem. Here the chain is the pattern being taught, and the limit is worth stating aloud.
The cash dispenser chain
This program builds the chain, plans several amounts and shows the plan-then-commit split. plan returns an Optional, because "no plan exists" is a normal answer here.
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
enum Note {
TEN(1_000), TWENTY(2_000), FIFTY(5_000), HUNDRED(10_000);
private final long cents;
Note(long cents) {
this.cents = cents;
}
long cents() {
return cents;
}
}
record NoteCount(Note note, int count) {
@Override
public String toString() {
return count + "x" + note;
}
}
final class NoteHandler {
private final Note note;
private int stock;
private final NoteHandler next; // null for the last handler
NoteHandler(Note note, int stock, NoteHandler next) {
this.note = note;
this.stock = stock;
this.next = next;
}
/** Takes what it can, passes the rest on. Returns the cents nobody could pay. */
long plan(long remaining, List<NoteCount> out) {
long wanted = remaining / note.cents();
int take = (int) Math.min(wanted, stock);
if (take > 0) {
out.add(new NoteCount(note, take));
}
long left = remaining - take * note.cents();
return next == null ? left : next.plan(left, out);
}
void commit(List<NoteCount> plan) {
for (NoteCount entry : plan) {
if (entry.note() == note) {
stock -= entry.count();
}
}
if (next != null) {
next.commit(plan);
}
}
}
final class CashDispenser {
private final NoteHandler head;
/** The largest note ends up at the head of the chain. */
CashDispenser(Map<Note, Integer> stock) {
NoteHandler chain = null;
for (Note note : Note.values()) { // ascending value, so the last one built is the head
chain = new NoteHandler(note, stock.getOrDefault(note, 0), chain);
}
head = chain;
}
Optional<List<NoteCount>> plan(long cents) {
var out = new ArrayList<NoteCount>();
long left = head.plan(cents, out);
return left == 0 ? Optional.of(out) : Optional.empty();
}
void commit(List<NoteCount> plan) {
head.commit(plan);
}
}
public class Main {
public static void main(String[] args) {
var dispenser = new CashDispenser(Map.of(Note.TWENTY, 3, Note.HUNDRED, 1, Note.TEN, 2, Note.FIFTY, 1));
for (int dollars : new int[] {70, 130, 250, 15, 60}) {
System.out.println("$" + dollars + ": " + dispenser.plan(dollars * 100L));
}
var plan = dispenser.plan(13_000).orElseThrow();
dispenser.commit(plan);
System.out.println("after paying $130: " + dispenser.plan(13_000));
System.out.println("still can pay $50: " + dispenser.plan(5_000));
}
}$70: Optional[[1xFIFTY, 1xTWENTY]]
$130: Optional[[1xHUNDRED, 1xTWENTY, 1xTEN]]
$250: Optional.empty
$15: Optional.empty
$60: Optional[[1xFIFTY, 1xTEN]]
after paying $130: Optional.empty
still can pay $50: Optional[[1xFIFTY]]The 60 dollar request succeeds as a 50 and a 10. The greedy limit appears with a different stock: with a single 50 and no tens, the same request takes the 50, is left with 10 and fails, even though three twenties could pay it. That is the trade-off of the pattern, because each handler decides locally. The 130 dollar plan is HUNDRED, TWENTY, TEN, and after it is committed the stock no longer covers 130 again, while the untouched 50 dollar request still works.
A type-per-state alternative for the session
The sealed session checks its state at run time and answers with WRONG_STATE. A type-per-state session makes each state a different class, so calling withdraw before the PIN check is a compile error: the method does not exist on the card-inserted type. This program models the card and PIN part. Each wrong entry returns a new CardInserted record inside a sealed PinOutcome.
import java.util.List;
import java.util.Optional;
record CardId(int value) {}
record AccountId(int value) {}
@FunctionalInterface
interface PinCheck {
Optional<AccountId> verify(CardId card, String pin);
}
record Authenticated(AccountId account) {}
record CardInserted(CardId card, int attemptsLeft) {
CardInserted(CardId card) {
this(card, 3);
}
PinOutcome enterPin(String pin, PinCheck check) {
var account = check.verify(card, pin);
if (account.isPresent()) {
return new PinOutcome.Accepted(new Authenticated(account.get()));
}
if (attemptsLeft > 1) {
return new PinOutcome.TryAgain(new CardInserted(card, attemptsLeft - 1));
}
return new PinOutcome.CardRetained(card);
}
}
sealed interface PinOutcome {
record Accepted(Authenticated session) implements PinOutcome {}
record TryAgain(CardInserted session) implements PinOutcome {}
record CardRetained(CardId card) implements PinOutcome {}
}
public class Main {
public static void main(String[] args) {
PinCheck check = (card, pin) ->
card.equals(new CardId(7)) && pin.equals("1234") ? Optional.of(new AccountId(70)) : Optional.empty();
var session = new CardInserted(new CardId(7));
for (String pin : List.of("0000", "1111", "1234")) {
switch (session.enterPin(pin, check)) {
case PinOutcome.Accepted(var active) -> {
System.out.println("accepted, account " + active.account());
return;
}
case PinOutcome.TryAgain(var next) -> {
System.out.println("wrong pin, " + next.attemptsLeft() + " attempts left");
session = next;
}
case PinOutcome.CardRetained(var card) -> {
System.out.println("card " + card + " retained");
return;
}
}
}
}
}wrong pin, 2 attempts left
wrong pin, 1 attempts left
accepted, account AccountId[value=70]Every wrong entry returns a new CardInserted with one attempt fewer, and the last wrong entry returns CardRetained, after which no session value exists at all. The compiler rejects an operation that the state does not offer:
record CardInserted(int attemptsLeft) {}
public class Main {
public static void main(String[] args) {
var session = new CardInserted(3);
System.out.println(session.balance());
}
}error: cannot find symbolJava has no equivalent of a consumed value, so this design is weaker than the compile-time guarantee suggests. The old CardInserted object stays usable after a wrong entry, and nothing stops a caller from reusing it. The next program shows the gap: the stale object still has all its attempts.
record CardId(int value) {}
record CardInserted(CardId card, int attemptsLeft) {
CardInserted next() {
return new CardInserted(card, attemptsLeft - 1);
}
}
public class Main {
public static void main(String[] args) {
var first = new CardInserted(new CardId(7), 3);
var second = first.next();
System.out.println("fresh object: " + second.attemptsLeft() + " left");
System.out.println("stale object: " + first.attemptsLeft() + " left");
}
}fresh object: 2 left
stale object: 3 leftA guard has to be added by hand if a design needs one, for example a used flag that throws on a second call, and that moves the check back to run time. A real ATM is driven by events (keypad, card reader, screen) that arrive one at a time from a loop, and that loop needs one variable that holds "the current session", whichever state it is in. That variable has the sealed interface type, so the full implementation uses the sealed session. See state machines with sealed types.
Full implementation
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Supplier;
record Money(long cents) implements Comparable<Money> {
Money {
if (cents < 0) {
throw new IllegalArgumentException("money cannot be negative");
}
}
Money minus(Money other) {
return new Money(cents - other.cents);
}
@Override
public int compareTo(Money other) {
return Long.compare(cents, other.cents);
}
@Override
public String toString() {
return "$%d.%02d".formatted(cents / 100, cents % 100);
}
}
record CardId(int value) {}
record AccountId(int value) {}
// ---- bank ----
sealed interface BankError {
enum Plain implements BankError { UNKNOWN_CARD, WRONG_PIN, UNKNOWN_ACCOUNT }
record InsufficientFunds(Money balance) implements BankError {}
}
final class BankException extends RuntimeException {
private final BankError error;
BankException(BankError error) {
super(error.toString());
this.error = error;
}
BankError error() {
return error;
}
}
interface Bank {
AccountId verifyPin(CardId card, String pin);
Money balance(AccountId account);
Money debit(AccountId account, Money amount);
}
final class InMemoryBank implements Bank {
private record Registration(String pin, AccountId account) {}
private final Map<CardId, Registration> cards = new HashMap<>();
private final Map<AccountId, Money> balances = new HashMap<>();
void open(CardId card, String pin, AccountId account, Money balance) {
cards.put(card, new Registration(pin, account));
balances.put(account, balance);
}
@Override
public AccountId verifyPin(CardId card, String pin) {
var registration = cards.get(card);
if (registration == null) {
throw new BankException(BankError.Plain.UNKNOWN_CARD);
}
if (!registration.pin().equals(pin)) {
throw new BankException(BankError.Plain.WRONG_PIN);
}
return registration.account();
}
@Override
public Money balance(AccountId account) {
var balance = balances.get(account);
if (balance == null) {
throw new BankException(BankError.Plain.UNKNOWN_ACCOUNT);
}
return balance;
}
@Override
public Money debit(AccountId account, Money amount) {
var balance = balance(account);
if (balance.compareTo(amount) < 0) {
throw new BankException(new BankError.InsufficientFunds(balance));
}
var remaining = balance.minus(amount);
balances.put(account, remaining);
return remaining;
}
}
// ---- cash ----
enum Note {
TEN(1_000), TWENTY(2_000), FIFTY(5_000), HUNDRED(10_000);
private final long cents;
Note(long cents) {
this.cents = cents;
}
long cents() {
return cents;
}
}
record NoteCount(Note note, int count) {
@Override
public String toString() {
return count + "x" + note;
}
}
final class NoteHandler {
private final Note note;
private int stock;
private final NoteHandler next; // null for the last handler
NoteHandler(Note note, int stock, NoteHandler next) {
this.note = note;
this.stock = stock;
this.next = next;
}
long plan(long remaining, List<NoteCount> out) {
int take = (int) Math.min(remaining / note.cents(), stock);
if (take > 0) {
out.add(new NoteCount(note, take));
}
long left = remaining - take * note.cents();
return next == null ? left : next.plan(left, out);
}
void commit(List<NoteCount> plan) {
for (NoteCount entry : plan) {
if (entry.note() == note) {
stock -= entry.count();
}
}
if (next != null) {
next.commit(plan);
}
}
long total() {
return note.cents() * stock + (next == null ? 0 : next.total());
}
}
final class CashDispenser {
private final NoteHandler head;
CashDispenser(Map<Note, Integer> stock) {
NoteHandler chain = null;
for (Note note : Note.values()) {
int count = stock.getOrDefault(note, 0);
if (count < 0) {
throw new IllegalArgumentException("negative stock for " + note);
}
chain = new NoteHandler(note, count, chain);
}
head = chain;
}
Money total() {
return new Money(head.total());
}
List<NoteCount> plan(Money amount) {
if (amount.compareTo(total()) > 0) {
throw new AtmException(AtmError.Plain.NOT_ENOUGH_CASH);
}
var out = new ArrayList<NoteCount>();
if (head.plan(amount.cents(), out) != 0) {
throw new AtmException(AtmError.Plain.CANNOT_DISPENSE_EXACTLY);
}
return out;
}
void commit(List<NoteCount> plan) {
head.commit(plan);
}
}
// ---- the ATM ----
sealed interface AtmError {
enum Plain implements AtmError { WRONG_STATE, MALFORMED_PIN, NOT_ENOUGH_CASH, CANNOT_DISPENSE_EXACTLY }
record WrongPin(int attemptsLeft) implements AtmError {}
record CardRetained(CardId card) implements AtmError {}
record InvalidAmount(Money amount) implements AtmError {}
record LimitExceeded(Money limit) implements AtmError {}
record BankFailure(BankError error) implements AtmError {}
}
final class AtmException extends RuntimeException {
private final AtmError error;
AtmException(AtmError error) {
super(error.toString());
this.error = error;
}
AtmError error() {
return error;
}
}
sealed interface Session {
record Idle() implements Session {
@Override
public String toString() {
return "Idle";
}
}
record AwaitingPin(CardId card, int attemptsLeft) implements Session {}
record Active(CardId card, AccountId account) implements Session {}
}
final class Atm {
private static final int MAX_ATTEMPTS = 3;
private final Bank bank;
private final CashDispenser dispenser;
private final Money limit;
private final List<CardId> retained = new ArrayList<>();
private Session session = new Session.Idle();
Atm(Bank bank, CashDispenser dispenser, Money limit) {
this.bank = Objects.requireNonNull(bank);
this.dispenser = Objects.requireNonNull(dispenser);
this.limit = Objects.requireNonNull(limit);
}
private static AtmException wrongState() {
return new AtmException(AtmError.Plain.WRONG_STATE);
}
private static AtmException fromBank(BankException e) {
return new AtmException(new AtmError.BankFailure(e.error()));
}
void insertCard(CardId card) {
if (!(session instanceof Session.Idle)) {
throw wrongState();
}
session = new Session.AwaitingPin(card, MAX_ATTEMPTS);
}
void enterPin(String pin) {
if (!(session instanceof Session.AwaitingPin(CardId card, int attemptsLeft))) {
throw wrongState();
}
if (pin.length() != 4 || !pin.chars().allMatch(c -> c >= '0' && c <= '9')) {
throw new AtmException(AtmError.Plain.MALFORMED_PIN);
}
try {
session = new Session.Active(card, bank.verifyPin(card, pin));
} catch (BankException e) {
if (e.error() != BankError.Plain.WRONG_PIN) {
session = new Session.Idle();
throw fromBank(e);
}
if (attemptsLeft > 1) {
session = new Session.AwaitingPin(card, attemptsLeft - 1);
throw new AtmException(new AtmError.WrongPin(attemptsLeft - 1));
}
session = new Session.Idle();
retained.add(card);
throw new AtmException(new AtmError.CardRetained(card));
}
}
private AccountId account() {
if (session instanceof Session.Active active) {
return active.account();
}
throw wrongState();
}
Money balance() {
var account = account();
try {
return bank.balance(account);
} catch (BankException e) {
throw fromBank(e);
}
}
List<NoteCount> withdraw(Money amount) {
var account = account();
if (amount.cents() == 0 || amount.cents() % Note.TEN.cents() != 0) {
throw new AtmException(new AtmError.InvalidAmount(amount));
}
if (amount.compareTo(limit) > 0) {
throw new AtmException(new AtmError.LimitExceeded(limit));
}
var plan = dispenser.plan(amount); // reads only
try {
bank.debit(account, amount); // the one step that changes the bank
} catch (BankException e) {
throw fromBank(e);
}
dispenser.commit(plan); // now the notes leave the machine
return plan;
}
CardId eject() {
CardId card = switch (session) {
case Session.Idle idle -> throw wrongState();
case Session.AwaitingPin waiting -> waiting.card();
case Session.Active active -> active.card();
};
session = new Session.Idle();
return card;
}
Money cashInMachine() {
return dispenser.total();
}
Session session() {
return session;
}
List<CardId> retainedCards() {
return List.copyOf(retained);
}
}
public class Main {
/** Runs one operation and shows either its result or the error it threw. */
static String show(Supplier<?> action) {
try {
return "Ok(" + action.get() + ")";
} catch (AtmException e) {
return "Err(" + e.error() + ")";
}
}
static String show(Runnable action) {
return show(() -> {
action.run();
return "done";
});
}
public static void main(String[] args) {
var bank = new InMemoryBank();
bank.open(new CardId(1), "1234", new AccountId(10), new Money(50_000));
bank.open(new CardId(2), "9999", new AccountId(20), new Money(1_000_000));
var dispenser = new CashDispenser(Map.of(Note.HUNDRED, 3, Note.FIFTY, 2, Note.TWENTY, 5, Note.TEN, 5));
var atm = new Atm(bank, dispenser, new Money(50_000));
System.out.println("balance without card: " + show(atm::balance));
atm.insertCard(new CardId(1));
System.out.println("insert twice: " + show(() -> atm.insertCard(new CardId(2))));
System.out.println("bad format: " + show(() -> atm.enterPin("12a4")));
System.out.println("wrong: " + show(() -> atm.enterPin("0000")));
System.out.println("right: " + show(() -> atm.enterPin("1234")));
System.out.println("balance: " + atm.balance());
System.out.println("withdraw $15: " + show(() -> atm.withdraw(new Money(1_500))));
System.out.println("withdraw $600: " + show(() -> atm.withdraw(new Money(60_000))));
System.out.println("withdraw $270: " + show(() -> atm.withdraw(new Money(27_000))));
System.out.println("balance: " + atm.balance());
System.out.println("withdraw $300: " + show(() -> atm.withdraw(new Money(30_000))));
System.out.println("withdraw $250: " + show(() -> atm.withdraw(new Money(25_000))));
System.out.println("balance still: " + atm.balance() + ", cash still: " + atm.cashInMachine());
System.out.println("withdraw $230: " + show(() -> atm.withdraw(new Money(23_000))));
System.out.println("balance: " + atm.balance() + ", cash: " + atm.cashInMachine());
System.out.println("eject: " + show(atm::eject));
atm.insertCard(new CardId(2));
for (int i = 0; i < 3; i++) {
System.out.println(show(() -> atm.enterPin("1111")));
}
System.out.println("session after retention: " + atm.session());
System.out.println("retained cards: " + atm.retainedCards());
System.out.println("unknown card: " + show(() -> {
atm.insertCard(new CardId(3));
atm.enterPin("1234");
}));
}
}balance without card: Err(WRONG_STATE)
insert twice: Err(WRONG_STATE)
bad format: Err(MALFORMED_PIN)
wrong: Err(WrongPin[attemptsLeft=2])
right: Ok(done)
balance: $500.00
withdraw $15: Err(InvalidAmount[amount=$15.00])
withdraw $600: Err(LimitExceeded[limit=$500.00])
withdraw $270: Ok([2xHUNDRED, 1xFIFTY, 1xTWENTY])
balance: $230.00
withdraw $300: Err(NOT_ENOUGH_CASH)
withdraw $250: Err(BankFailure[error=InsufficientFunds[balance=$230.00]])
balance still: $230.00, cash still: $280.00
withdraw $230: Ok([1xHUNDRED, 1xFIFTY, 4xTWENTY])
balance: $0.00, cash: $50.00
eject: Ok(CardId[value=1])
Err(WrongPin[attemptsLeft=2])
Err(WrongPin[attemptsLeft=1])
Err(CardRetained[card=CardId[value=2]])
session after retention: Idle
retained cards: [CardId[value=2]]
unknown card: Err(BankFailure[error=UNKNOWN_CARD])Check the trace against the design. The malformed PIN does not consume an attempt (the next wrong entry still leaves two). The $600 request fails on the per-transaction limit before the bank is contacted. The $270 withdrawal is paid as two hundreds, one fifty and one twenty, largest first. The $300 request fails in the dispenser, because the machine holds only $280 after that withdrawal. The $250 request passes the dispenser plan but fails at the bank, because the balance is $230, and the trace shows the balance and the cash both unchanged afterwards, which is the point of plan, then debit, then commit. The $230 request then succeeds. Retention leaves the session idle and records the card, and an unknown card is reported by the bank when its PIN is checked.
Extensions
| Follow-up | Where the change goes |
|---|---|
| Deposits | Bank.credit, plus a deposit operation on the ATM. Deposited notes are added to a separate bin, since they are unverified. |
| Bank is unreachable | BankError.Plain.UNAVAILABLE. The ATM must decide whether a debit that timed out happened. Use an idempotency key on debit, so a retry cannot debit twice. |
| Daily withdrawal limit | The bank tracks it per account (it survives across machines), or a WithdrawalPolicy interface the ATM consults. |
| Different dispensing policy (keep small notes for later, prefer twenties) | Build the chain in a different order, or wrap it in a DispensePolicy interface with plan. |
| Exact change when greedy fails | Replace the chain's plan with the recursive search from the vending machine, keeping the plan/commit split. |
| Session timeout | Inject a Clock. Active stores the last activity time, and a tick ends stale sessions. |
| Audit log of every operation | An observer on the ATM. See event bus and observers. |
| Two people at one machine | A single owner thread that receives events from a BlockingQueue, so the state machine sees one event at a time. See blocking queue and producer-consumer. |
Summary
- Model the session as a sealed interface:
Idle,AwaitingPin(card, attemptsLeft),Active(card, account). Data exists only in the states where it is meaningful. - Count PIN attempts in the session state, and keep the card after the limit.
- Put the bank behind a
Bankinterface with a small surface and give tests anInMemoryBank, so no test needs a network. - Use a chain of responsibility to dispense notes, one handler per denomination, and split it into
plan(read only) andcommit(mutating) so a refusal never leaves half a change. - Order a withdrawal so that everything the ATM can refuse comes first, then the bank debit, then the note commit.
- A type-per-state session removes operations a state does not offer, but Java cannot invalidate the old state object. An event-driven machine needs the sealed session in one field.
- Keep money as integer cents in a record, and carry failures as a sealed error value inside an unchecked domain exception, so every failure stops the operation before anything changes.
