ATM System

Cards, accounts, transactions, the State pattern on the session. The textbook OOD interview problem that's almost too clean.

System Foundational
13 min read
ood case-study atm state-pattern strategy-pattern

Context#

An automated teller machine reads a customer’s card, verifies their PIN against the bank, lets them perform one or more transactions (withdraw, deposit, transfer, balance inquiry), dispenses cash or accepts envelopes, prints a receipt, and ejects the card. The hardware is fixed (card reader, keypad, cash dispenser, deposit slot, receipt printer); the software orchestrates the customer’s session through a known sequence of screens.

The problem is the textbook OOD prompt — almost too clean. Every reviewer has seen it, and the expectation is that you will reach for the State pattern within the first five minutes. The interesting work is everywhere else: a Transaction hierarchy that does not collapse to if (kind == WITHDRAW), hardware abstractions you can mock for tests, and a session that is genuinely a finite-state machine rather than a flat bag of booleans.

The interviewer’s hidden objectives, in roughly the order they will be tested:

  • Can you clarify scope — single-bank ATM vs. interbank network, cash-only vs. cheque deposit — without spinning out?
  • Can you identify the entitiesCard, Account, Session, Transaction, plus the hardware pieces?
  • Can you draw the session’s state machine and map every screen transition to a state edge?
  • Can you make Transaction polymorphic so the ATM does not switch on kind?
  • Can you defend trade-offs when the interviewer pushes (offline mode, denominations, daily limits, interbank routing)?

Requirements (functional and non-functional)#

Clarifying in the room is the most points-bearing part. The scope below is the one most interviewers expect; anything outside it should be flagged out-of-scope so you can finish.

Functional — in scope.

  • A customer inserts a card, enters a PIN, and authenticates against the bank.
  • An authenticated session supports the four transaction kinds: withdraw, deposit, transfer, balance inquiry.
  • After each transaction the customer chooses another transaction or exit.
  • Withdrawals check the account balance and the ATM cash level; both must permit the amount.
  • A receipt is offered at the end of every transaction (optional).
  • The card is ejected on session end.

Functional — out of scope (called out explicitly). Interbank routing, multi-currency dispensing, cheque deposit scanning, NFC / tap-to-pay, loan EMI, biometric auth, customer support escalation. Acknowledge them so the interviewer knows you saw them.

Non-functional.

  • A single ATM instance; concurrency is one session at a time. (A fleet of ATMs is a deployment concern.)
  • Latency target: each screen transition under 1 s; auth round-trip under 3 s.
  • Failures (network drop mid-session, dispenser jam, customer walks away) must leave the machine in a safe state — card ejected, no double-debit.
  • The hardware is mockable; unit tests must run without real dispensers.

Use case diagram#

┌────────────────┐
│ Customer │
└────────┬───────┘
┌──────────────────┼──────────────────┐
▼ ▼ ▼
[insert card] [withdraw] [exit]
│ │ ▲
▼ ▼ │
[enter PIN] [deposit] │
│ │ │
▼ ▼ │
[transfer / balance inquiry] │
│ │
▼ │
┌─────────────────────────────────────┐
│ ATM System │
└────────┬────────────────────────────┘
┌──────────────────────────────────────────┐
│ Bank (verifies PIN, debits/credits acct) │
└──────────────────────────────────────────┘
┌────────┴────────┐
│ Bank Operator │ … refill cash, retrieve deposits, audit
└─────────────────┘

Customer and operator are the two primary actors; the bank is an external system. The atomic use cases each map to a screen and to a state transition.

Class diagram#

┌─────────────────────────────────┐
│ ATM │
├─────────────────────────────────┤
│ session : Session │
│ cardReader : CardReader │
│ keypad : Keypad │
│ dispenser : CashDispenser │
│ depositSlot: DepositSlot │
│ printer : ReceiptPrinter │
│ bank : BankAPI │
├─────────────────────────────────┤
│ run() / cardInserted(c) / ... │
└─────────────┬───────────────────┘
│ has
┌─────────────────────────────────┐
│ Session │
├─────────────────────────────────┤
│ state : SessionState │ ◇── State
│ card : Card? │
│ user : User? │
│ pinAttempts : int │
├─────────────────────────────────┤
│ insertCard(c) / enterPin(p) │
│ choose(t : Transaction) │
│ eject() │
└─────────────────────────────────┘
┌──────────────────────┐ ┌──────────────────────┐
│ SessionState │ │ Transaction │◁── Strategy
├──────────────────────┤ ├──────────────────────┤
│ insertCard(s,c) │ │ execute(ctx) │
│ enterPin(s,p) │ │ kind() │
│ choose(s,t) │ └──────────┬───────────┘
│ eject(s) │ │
└──────────┬───────────┘ ┌────────────┼────────────┐
│ ▼ ▼ ▼
┌──────────┼─────────┐ ┌────────┐ ┌──────────┐ ┌──────────────┐
▼ ▼ ▼ │Withdraw│ │ Deposit │ │ BalanceInq. │ Transfer
NoCard CardInserted ... └────────┘ └──────────┘ └──────────────┘
┌─────┼─────┐
▼ ▼ ▼
Authenticated TxnInProgress Ejecting
┌────────────┐ ┌──────────────┐ ┌───────────────┐
│ Card │ │ Account │ │ Bank │
├────────────┤ ├──────────────┤ ├───────────────┤
│ pan │ │ id, balance │ │ verifyPin(c,p)│
│ expiry │ │ debit(a) │ │ debit(...) │
│ holderId │ │ credit(a) │ │ credit(...) │
└────────────┘ └──────────────┘ └───────────────┘

Two patterns are doing the load-bearing work:

  • State pattern on Session.stateNoCard → CardInserted → Authenticated → TxnInProgress → Ejecting → NoCard. Each state is a class that handles only the events legal in that state; illegal events throw or are ignored. The session does not switch on a state enum.
  • Strategy pattern on TransactionWithdrawTransaction, DepositTransaction, TransferTransaction, BalanceInquiry. The session does not switch on transaction kind; each Transaction.execute(ctx) knows what to do with the hardware abstractions it is handed.

What is not in the diagram and that is deliberate:

  • No if (state == ...) anywhere. Every state’s behaviour lives on its own class — that is what makes it the State pattern and not “an enum with extra steps”.
  • Hardware is injected, not constructed inside the ATM. Tests replace each piece with a fake. This is the difference between an integration-only design and one that can run in CI.

Sequence diagram (key flows)#

The card-to-withdraw flow:

Customer ATM Session BankAPI CashDispenser ReceiptPrinter
│ insertCard(c)│ │ │ │ │
│─────────────►│ │ │ │ │
│ │ insertCard(c) │ │ │
│ │──────────►│ │ │ │
│ │ state → CardInserted │ │ │
│ │ │ │ │ │
│ enterPin(p) │ │ │ │ │
│─────────────►│ │ │ │ │
│ │ enterPin(p) │ │ │
│ │──────────►│ verifyPin(c,p) │ │
│ │ │─────────────►│ │ │
│ │ │ ok │ │ │
│ │ │◄─────────────│ │ │
│ │ state → Authenticated │ │ │
│ │ │ │ │ │
│ choose(Withdraw 5000) │ │ │ │
│─────────────►│ │ │ │ │
│ │ choose(t) │ │ │ │
│ │──────────►│ │ │ │
│ │ state → TxnInProgress │ │ │
│ │ t.execute(ctx) │ │ │
│ │──────────────────────────│ │ │
│ │ debit(account, 5000) │ │ │
│ │──────────────────────────► │ │
│ │ dispense(5000) │ │
│ │────────────────────────────────────────────► │
│ │ print(receipt) │
│ │──────────────────────────────────────────────────────────────►│
│ │ state → Authenticated │ │ │
│ exit │ │ │ │ │
│─────────────►│ eject() state → Ejecting → NoCard │ │

The illegal-event flow (helpful to draw briefly):

Customer ATM Session
│ enterPin (without inserting card)│
│─────────────►│ │
│ │ enterPin(p) ◄── delegates to NoCardState
│ │────────►│
│ │ NoCardState rejects (UI: "Please insert your card")

The session does not branch on state — NoCardState.enterPin is the rejecter. That’s what “state as a class” gets you.

Activity diagram (for non-trivial state)#

The session’s state machine, with every legal transition:

┌──────────┐
│ NoCard │◄──────────────────────────────────────────┐
└────┬─────┘ │
│ insertCard(c) │
▼ │
┌────────────────┐ bad PIN × 3 │
│ CardInserted │─────────────────────► ┌────────────┐ │
└──────┬─────────┘ │ Ejecting │────┘
│ good PIN └────────────┘
▼ ▲
┌────────────────────┐ │
│ Authenticated │◄─────────────────────────┤
└───┬────────────────┘ │
│ choose(txn) │ exit
▼ │
┌────────────────────┐ txn done │
│ TxnInProgress │──────────────────────────┘
└────────────────────┘
│ txn failed (insufficient funds, dispenser jam)
(returns to Authenticated; UI surfaces error)

Ejecting is a real state, not a method call, because card ejection has its own non-trivial behaviour (timeout retracts the card if the customer walks away) and other events arriving while ejecting must be ignored. Three bad PIN attempts also transition to Ejecting after capturing the card — the bank API tells the session whether to capture.

Java implementation#

A representative slice; the rest is mechanical.

public interface SessionState {
default void insertCard(Session s, Card c) { /* default: ignore */ }
default void enterPin(Session s, String pin) { /* default: ignore */ }
default void choose(Session s, Transaction t) { /* default: ignore */ }
default void eject(Session s) { /* default: ignore */ }
}
public final class NoCardState implements SessionState {
public void insertCard(Session s, Card c) {
s.setCard(c);
s.setState(new CardInsertedState());
}
}
public final class CardInsertedState implements SessionState {
public void enterPin(Session s, String pin) {
if (s.bank().verifyPin(s.card(), pin)) {
s.setState(new AuthenticatedState());
return;
}
s.incrementPinAttempts();
if (s.pinAttempts() >= 3) {
s.bank().captureCard(s.card());
s.setState(new EjectingState(true /* captured */));
}
}
public void eject(Session s) { s.setState(new EjectingState(false)); }
}
public final class AuthenticatedState implements SessionState {
public void choose(Session s, Transaction t) {
s.setState(new TxnInProgressState(t));
try {
t.execute(s.context());
s.setState(new AuthenticatedState());
} catch (TransactionFailedException e) {
s.context().display(e.getMessage());
s.setState(new AuthenticatedState());
}
}
public void eject(Session s) { s.setState(new EjectingState(false)); }
}
public final class TxnInProgressState implements SessionState {
private final Transaction current;
public TxnInProgressState(Transaction t) { this.current = t; }
// No legal events while a transaction is running.
}
public final class EjectingState implements SessionState {
private final boolean cardCaptured;
public EjectingState(boolean captured) { this.cardCaptured = captured; }
// Real implementation: tells card reader to eject or hold; on completion
// transitions back to NoCard. No legal events from the customer.
}
public interface Transaction {
void execute(AtmContext ctx);
TransactionKind kind();
}
public final class WithdrawTransaction implements Transaction {
private final Account account;
private final Money amount;
public WithdrawTransaction(Account account, Money amount) {
this.account = account; this.amount = amount;
}
public void execute(AtmContext ctx) {
if (!account.canDebit(amount)) throw new InsufficientFundsException();
if (!ctx.dispenser().canDispense(amount)) throw new DispenserShortException();
ctx.bank().debit(account, amount);
try {
ctx.dispenser().dispense(amount);
} catch (DispenserJamException e) {
ctx.bank().credit(account, amount); // compensate
throw new TransactionFailedException("Dispenser jam; transaction reversed");
}
ctx.printer().print(Receipt.forWithdraw(account, amount));
}
public TransactionKind kind() { return TransactionKind.WITHDRAW; }
}
public final class Session {
private SessionState state = new NoCardState();
private Card card;
private int pinAttempts;
private final AtmContext context;
public Session(AtmContext context) { this.context = context; }
public AtmContext context() { return context; }
public BankAPI bank() { return context.bank(); }
public Card card() { return card; }
public int pinAttempts() { return pinAttempts; }
void setState(SessionState s) { this.state = s; }
void setCard(Card c) { this.card = c; }
void incrementPinAttempts() { this.pinAttempts++; }
public void insertCard(Card c) { state.insertCard(this, c); }
public void enterPin(String pin) { state.enterPin(this, pin); }
public void choose(Transaction t) { state.choose(this, t); }
public void eject() { state.eject(this); }
}

Notes the interviewer will look for:

  • Default-method no-ops on SessionState. Illegal events do nothing instead of throwing — UX-friendly and avoids littering each state with empty overrides.
  • The compensating credit in WithdrawTransaction. A dispenser jam after debiting the account is the textbook ATM bug; compensating the bank balance is the right answer.
  • No state enum. If you see enum SessionState { NO_CARD, ... } and switch (state) in Session.enterPin, the State pattern is not actually in use — even if the diagram says it is.
  • AtmContext is the bag of hardware references. Pass it to each Transaction.execute rather than threading individual hardware pieces. Keeps Transaction signatures stable.

Trade-offs and extensions#

Decisions explicitly made and what they cost:

DecisionWhyCost if requirements change
State pattern on SessionMaps directly to the screen flow; illegal events handled by silent no-ops, not branching.Adding a new state (e.g. LanguageSelection) means a new class but no surgery on existing states.
Strategy on TransactionNew transaction kinds (mini-statement, mobile-recharge) don’t touch the session.None — the right shape now.
Hardware via interfacesTests run without real devices; fakes verify call sequences.Required for any non-toy implementation; mocking concrete hardware classes is brittle.
Synchronous bank APIThe interview ATM is one customer at a time; latency budget fits.A real interbank network is async; the session would gain a WaitingForBank state.
Coarse compensating action on jamDebit-then-fail is the realistic failure; compensate is the textbook recovery.A real ATM logs and escalates to a reconciliation queue; the design hook is there.
Three-attempt PIN with card captureIndustry standard; bank decides capture vs. retain.Configurable; the count moves to a SecurityPolicy.

Likely follow-up extensions and the shape of the answer:

  • Daily withdrawal limit. Account.canDebit(amount) consults a per-account LimitPolicy that tracks today’s withdrawals. Strategy pattern on the policy (per customer tier / per card type).
  • Denomination-aware dispensing. CashDispenser.dispense(amount) returns a Bills object; the dispenser refuses if no valid denomination combo fits. Greedy by default with a fallback.
  • Multi-currency. Money already carries a currency; the dispenser advertises supported currencies. The bank API gains a currency arg.
  • Receipt opt-out. A printReceipt boolean on the transaction or a session preference; the printer is a hardware reference, not a hard requirement.
  • Command pattern for undo / audit log. Each Transaction becomes a Command with execute / undo. The undo is the same compensating action as the jam path — clean place to unify the two.
  • Fleet of ATMs. Each ATM has its own session; the bank is the shared dependency. Concurrency moves out of the ATM and onto the bank’s account-locking story.

Mock interview follow-ups#

Questions interviewers reach for and the briefest correct answer:

  • “Why State pattern instead of an enum and a switch?” — Each state owns its legal events; adding a new state is a new class, not edits to every existing switch. Open/closed.
  • “What happens if the network drops mid-transaction?” — The transaction throws; the session moves back to Authenticated; the bank-side debit either committed (in which case the compensating credit on the dispenser-jam path applies — same recovery) or didn’t. The reconciliation is the bank’s problem; the ATM’s job is to leave itself in a safe state.
  • “Where do receipts live?”ReceiptPrinter is a hardware abstraction; each Transaction formats its own receipt and hands it to the printer. The printer doesn’t know what’s on the page.
  • “How do you test withdrawal logic without real hardware?” — Inject fake CashDispenser and BankAPI; assert the sequence of calls. The State pattern + dependency injection together make this trivial.
  • “What’s the right place for the daily limit check?” — In the Account, not in the Transaction. The transaction asks account.canDebit(amount); the account’s LimitPolicy is the policy seam.
  • “Can two transactions interleave on the same account?” — At a single ATM, no — the session is sequential. Across ATMs, the bank serializes. The ATM doesn’t lock; the bank does.
Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.