State + Chain of Responsibility
ATM
An ATM is a state machine: the same keypad verbs (insert card, enter , withdraw, eject) mean different things depending on the current . A failed PIN must never dispense cash. If that logic is one ATM class with twelve booleans, a retry of an old message can spit money while Idle.
Which state am I in, and what must be true before cash leaves the machine? This is in-process machine coding: one machine, one session. A network of ATMs and a switch is high-level design (HLD). Do not draw that box. Compare with the vending machine: same idea, fewer bank rules.
Session path
Idle, CardIn, Authenticated, Dispensing; each owns the keypad verbs.
Pin ok, note multiple, balance, cassette; ordered checks before cash.
Snapshot and debit only. Fake it in tests; keep SQL out of Atm.
Physical notes. Dispense can fail; say debit-versus-cash order out loud.
What actually happens at the keypad
The twelve-booleans story above is the default interview trap. Think of the session as a hallway of rooms. Each implements the same verbs. Idle rejects withdraw. Authenticated starts a validation : card not blocked, pin already ok, amount multiple of the note, balance sufficient, cash in the . The chain is ordered. A new fraud check is a new , not a new if in Atm.withdraw().
After the pin succeeds, the session holds the card identity and a for balance and debit. The ATM must not talk SQL. A failed handler leaves the state unchanged and returns a reason. Dispensing is its own state so a second withdraw cannot start mid-cash. Pin retries are a small counter on CardIn; three failures swallow the card or eject and return to Idle. Pick one policy, write the arrow, reject the others. See State and state machines.
Simple rule of thumb
The hallway story needs a fast filter before you draw classes. Ask: which room am I in, and what must be true before cash leaves?
Prefer the when each room has real behavior for the same keypad verbs. Flags like hasCard && pinOk && dispensing look shorter until a retry sets the wrong flag and Idle dispenses. Write the state list and the forbidden arrows first; then use state objects.
How to explain this in an interview
Say: rooms own the verbs; Authenticated starts an ordered withdraw chain; BankPort is faked; one machine, not a switch network. Debit versus cassette order is named out loud.
Rooms, arrows, and who owns what
The rule of thumb names the rooms; this is the allow and reject map, then the session arrows, then the types. Each is one room. The runs only from Authenticated.
Idle
Allows
insertCard only
Rejects
withdraw and enterPin rejected; no session yet
CardIn
Allows
enterPin and eject
Rejects
withdraw blocked until pin succeeds
Authenticated
Allows
withdraw, eject, maybe balance query
Rejects
second card insert; eject first
Dispensing
Allows
complete the dispense in flight
Rejects
second withdraw and insertCard blocked
Session arrows
Read the diagram definition
stateDiagram-v2
[*] --> Idle
Idle --> CardIn: insertCard
CardIn --> Authenticated: pin ok
CardIn --> Idle: eject / swallow after retries
Authenticated --> Dispensing: withdraw chain ok
Authenticated --> Idle: eject
Dispensing --> Idle: cash out, eject
note right of Idle: withdraw rejectedResponsibility walk
| Type | Job |
|---|---|
| Atm | ATM is a : it delegates keypad verbs to the current state and holds session, , and the cash . |
| AtmState | Each defines what the keypad means and which transitions are forbidden. |
| WithdrawHandler | Each does one check and forwards. A new fraud rule is a new class in the chain. |
| BankPort | exposes balance and debit. Fake it in tests. Keep JDBC out of the ATM core. |
| Cassette | The holds physical notes. Dispense is a command that can fail when empty. |
Java API sketch
The rooms and table above are the same types the sketch implements: , ordered , and a on Atm.
interface AtmState {
void insertCard(Atm atm, Card card);
void enterPin(Atm atm, Pin pin);
void withdraw(Atm atm, Money amount);
void eject(Atm atm);
}
interface WithdrawHandler {
void setNext(WithdrawHandler next);
void handle(WithdrawContext ctx);
}
abstract class AbstractWithdrawHandler implements WithdrawHandler {
private WithdrawHandler next;
public void setNext(WithdrawHandler next) { this.next = next; }
protected void pass(WithdrawContext ctx) {
if (next != null) next.handle(ctx);
}
}
class PinOkHandler extends AbstractWithdrawHandler {
public void handle(WithdrawContext ctx) {
if (!ctx.session.authenticated()) throw new NotAuthenticated();
pass(ctx);
}
}
class NoteMultipleHandler extends AbstractWithdrawHandler {
public void handle(WithdrawContext ctx) {
if (!ctx.amount.isMultipleOf(ctx.cassette.minNote())) throw new InvalidAmount();
pass(ctx);
}
}
class BalanceHandler extends AbstractWithdrawHandler {
public void handle(WithdrawContext ctx) {
if (ctx.account.balance().lessThan(ctx.amount)) throw new InsufficientFunds();
pass(ctx);
}
}
interface BankPort {
Account snapshot(Card card);
void debit(Card card, Money amount);
}
class Atm {
private AtmState state = new IdleState();
private final BankPort bank;
void insertCard(Card card) { state.insertCard(this, card); }
void enterPin(Pin pin) { state.enterPin(this, pin); }
void withdraw(Money amount) { state.withdraw(this, amount); }
}Worked example: walk the hallway, then the chain
Use the session arrows and the sketch together. Idle.withdraw throws. insertCard moves to CardIn. enterPin wrong twice; on the third failure swallow or eject (pick the arrow). Pin ok moves to Authenticated.
withdraw 70 when notes are 100s: NoteMultipleHandler fails, state stays Authenticated. withdraw 200 with balance 50: BalanceHandler fails. withdraw 200 with an empty : the last handler fails with no debit, or you debit-and-compensate; say that order out loud. Dispensing rejects a second withdraw. This is one machine, not a switch network.
Numbers to know
- Three PIN failures: swallow or eject; CardIn owns the counter and Idle return.
- Amount must be a multiple of the smallest note; never round down silently.
- Failed chain leaves Authenticated unchanged; card stays in until eject.
- One physical machine: one session thread; block withdraw until Dispensing finishes.
Invalid cases, and what still breaks
The hallway walk fails in interviews for a short list of mistakes. Each one either skips , runs the too early, or muddles bank debit versus order.
Invalid cases
- Withdraw in Idle: no card, no session; state rejects (the twelve-booleans bug).
- Withdraw after two wrong pins before lockout: still CardIn; chain never runs.
- Amount not a multiple of the smallest note: NoteMultipleHandler rejects; no silent rounding.
- Balance 500, withdraw 1000: BalanceHandler fails; stay Authenticated with card in.
- Debit the bank then fail to dispense: say cassette-first or compensating credit out loud.
- Insert a second card while Authenticated: illegal; eject first for one session.
Booleans and concurrency
Booleans instead of states
hasCard && pinOk && dispensing. A retry sets the wrong flag. Write the state list and the forbidden arrows. Then use when each state has real behavior.
Concurrency
One physical machine: one session thread is enough. If the bank call is async, do not accept another withdraw until Dispensing finishes. Cassette counts need a lock if two commands could overlap; they should not, if the state machine is correct.
Still fails when…
- A retry flips flags and Idle dispenses cash without an authenticated session.
- Dispensing accepts a second withdraw and starts two cash outs at once.
- Debit succeeds then cassette empties with no compensating credit path.
Trade-offs
- State objects cost classes; booleans cost illegal transitions under retries.
- Cassette-first avoids orphan debits; bank-first needs a clear compensate path.
- One session thread is honest; async bank calls need Dispensing as a gate.
What the follow-up changes
Once the rooms and the chain in the failure card are honest, interviewers stretch the same machine. Do not rebuild it as a network of ATMs.
- Deposit and transfer as new commands. New verbs on Authenticated, still rejected in Idle. Not new booleans on Atm.
- A fraud velocity check. Another in the chain, not an if inside Atm. ATM.withdraw stays unchanged.
- After three PIN failures. Swallow or lock the card per policy. CardIn owns the counter and the transition to Idle with a swallowed flag.
is a value. Do not log it. BankPort sees it once.
Wrap-up
You have the rooms, the chain, and the invalid cases. Use the seam test, then the related notes. You are done when illegal transitions are refused (for example withdraw before auth), PIN failures lock after the configured attempts, and Money stays a value object, not a double.
Tests that prove the seams
@Test
void withdrawRejectedInIdle() {
Atm atm = new Atm(fakeBank, cassette);
assertThrows(NoSession.class, () -> atm.withdraw(Money.of(200, "INR")));
}Withdraw in Idle
Asserts NoSession; catches the twelve-booleans retry bug early.
Debit then empty cassette
Needs compensating credit or cassette-first; say the order aloud.
Read next
Key takeaway
Twelve booleans are not a session. Idle, CardIn, Authenticated, and Dispensing are. The keypad is stable; the room changes. A withdraw chain is ordered checks. is faked. One machine, not a network of ATMs. Failed pin must not dispense.