Skip to main content

ATM

An ATM's interesting complexity isn't the state machine (card in, PIN, pick a transaction, dispense, card out - a straight line, no branching drama). It's that "pick a transaction" hides three genuinely different operations, and the design question is whether ATM gets to know what any of them do.

Requirements

Functional

  • A customer inserts a card, enters a PIN, and gets three tries before the card is retained.
  • Once authenticated, they can withdraw cash, deposit funds, or check their balance.
  • A withdrawal fails cleanly if the requested amount exceeds either the account balance or the cash the machine currently has loaded.

Non-functional

  • Adding a fourth transaction type (mini-statement, bill pay) should mean adding one class, not editing a switch inside ATM.
  • The account/balance backend is a real banking system in production, not this machine - ATM should depend on an interface it talks to, not a concrete database.

Design

The state machine (IDLE -> CARD_INSERTED -> AUTHENTICATED -> IDLE) is simple enough to live as a plain enum with guarded transitions - a full State-pattern class hierarchy would be more ceremony than the four transitions warrant. The transaction itself is the part worth pulling out: each one is a Transaction object the ATM executes without inspecting, the same way a remote control's buttons execute commands without knowing what they turn on.

CustomerATMBankServiceTransactioninsertCard(card)1enterPin(pin)2validatePin(card, pin)3selectTransaction(WITHDRAW, 200)4execute(account)5debit(account, 200)6dispense(200) + ejectCard()7
  1. 1State moves from IDLE to CARD_INSERTED. No account is touched yet.
  2. 2The ATM does not know what a correct PIN looks like - it asks the bank.
  3. 3On success, state becomes AUTHENTICATED; on failure, the retry counter increments.
  4. 4The customer picks one of three transaction types.
  5. 5ATM hands off to a WithdrawTransaction object - it never branches on transaction type itself.
  6. 6The transaction talks to the bank directly; ATM is not in this call at all.
  7. 7Cash comes out, the card is returned, and state resets to IDLE.

The enum earns its keep here precisely because the legal-transition set is small enough to draw and check by eye - every arrow below is a guard condition enterPin/insertCard/ ejectCard actually enforces, nothing more:

insertCard(card)enterPin(pin) [correct]enterPin(pin) [3rd wrong attempt - card retained]ejectCard() [after transaction]IDLECARD_INSERTEDAUTHENTICATED
Click a state to see its legal transitions.

Class diagram

«interface»BankService+ validatePin(card, pin): bool+ getBalance(account): double+ debit(account, amt)+ credit(account, amt)«interface»Transaction+ execute(account, bank)ATM- state: ATMState- retries: int- bank: BankService+ insertCard(card)+ enterPin(pin)+ selectTransaction(t)+ ejectCard()WithdrawTransaction+ execute(account, bank)DepositTransaction+ execute(account, bank)BalanceInquiryTransaction+ execute(account, bank)Card- number: string- accountId: string
implementsuses
ATM drives the state machine and hands off to whichever Transaction the customer picked; BankService is the only door to the account.

Code

import java.util.*;
 
enum ATMState { IDLE, CARD_INSERTED, AUTHENTICATED }
 
class Card {
final String number;
final String accountId;
 
Card(String number, String accountId) {
this.number = number;
this.accountId = accountId;
}
}
 
interface BankService {
boolean validatePin(Card card, String pin);
double getBalance(String accountId);
void debit(String accountId, double amount);
void credit(String accountId, double amount);
}
 
interface Transaction {
void execute(String accountId, BankService bank);
}
 
class WithdrawTransaction implements Transaction {
private final double amount;
private final CashDispenser dispenser;
 
WithdrawTransaction(double amount, CashDispenser dispenser) {
this.amount = amount;
this.dispenser = dispenser;
}
 
public void execute(String accountId, BankService bank) {
if (bank.getBalance(accountId) < amount) {
throw new IllegalStateException("Insufficient funds");
}
dispenser.dispense(amount);
bank.debit(accountId, amount);
}
}
 
class DepositTransaction implements Transaction {
private final double amount;
 
DepositTransaction(double amount) {
this.amount = amount;
}
 
public void execute(String accountId, BankService bank) {
bank.credit(accountId, amount);
}
}
 
class BalanceInquiryTransaction implements Transaction {
public void execute(String accountId, BankService bank) {
System.out.println("Balance: " + bank.getBalance(accountId));
}
}
 
class CashDispenser {
private double cashAvailable;
 
CashDispenser(double cashAvailable) {
this.cashAvailable = cashAvailable;
}
 
void dispense(double amount) {
if (amount > cashAvailable) {
throw new IllegalStateException("ATM out of cash");
}
cashAvailable -= amount;
}
}
 
class ATM {
private static final int MAX_RETRIES = 3;
private final BankService bank;
private ATMState state = ATMState.IDLE;
private Card currentCard;
private int retries = 0;
 
ATM(BankService bank) {
this.bank = bank;
}
 
void insertCard(Card card) {
if (state != ATMState.IDLE) throw new IllegalStateException("Card already inserted");
this.currentCard = card;
this.state = ATMState.CARD_INSERTED;
}
 
void enterPin(String pin) {
if (state != ATMState.CARD_INSERTED) throw new IllegalStateException("Insert a card first");
if (bank.validatePin(currentCard, pin)) {
state = ATMState.AUTHENTICATED;
retries = 0;
} else if (++retries >= MAX_RETRIES) {
ejectCard();
throw new IllegalStateException("Card retained after too many attempts");
}
}
 
void performTransaction(Transaction transaction) {
if (state != ATMState.AUTHENTICATED) throw new IllegalStateException("Not authenticated");
transaction.execute(currentCard.accountId, bank);
}
 
void ejectCard() {
currentCard = null;
state = ATMState.IDLE;
}
}

Design decisions

  • State as an enum with guard checks, not a class per state. Four states and four transitions is not enough branching logic to earn a State-pattern hierarchy - that would be three extra files answering a question a single switch in insertCard/enterPin/ ejectCard answers just as clearly. The State pattern paid for itself on the elevator page because that state machine has real per-state behavior (moving vs not); this one is mostly a checklist.
  • Each transaction type is its own class. WithdrawTransaction, DepositTransaction, and BalanceInquiryTransaction all implement one execute(account) method. ATM calls that method without an if (type == WITHDRAW) anywhere - the same shape as Command, minus the undo stack this problem doesn't need.
  • BankService is an interface, not a Database field. The ATM's job is the human-facing flow, not owning account data; a fake in-memory BankService is enough to demo the whole machine, and a real deployment swaps in one that calls the actual bank over a network without ATM noticing.
  • What's missing for a real system: every withdrawal needs to be atomic against the bank's ledger (two ATMs draining the same account concurrently is a real failure mode, not a hypothetical one), and a network timeout mid-transaction has to leave the ATM able to tell "I dispensed cash but never heard back" from "I never dispensed" - this walkthrough assumes BankService calls always return promptly.

Common follow-ups

  • What happens if the call to BankService.debit() times out right after cash was already dispensed? This design assumes prompt responses, which is named directly as a gap. A real system needs an idempotent transaction ID and a reconciliation step so "dispensed but never heard back" can be told apart from "never dispensed" and resolved without double-charging.
  • How would you add a fourth transaction type, like a mini-statement? One new Transaction implementation, handed to performTransaction exactly like the other three - ATM's code does not change at all.
  • Customer enters the wrong PIN three times mid-session - does anything get left half-done? No - enterPin ejects the card and throws before a Transaction is ever selected, so there is no partial withdrawal or deposit to unwind.
  • How would you support multiple accounts per card, like checking versus savings? Transaction.execute already takes an accountId rather than assuming one implicit account; the change is in how ATM asks the customer which account to use, not in Transaction or BankService.

Check yourself

Question 1 of 4

Why is the ATM state machine a plain enum with guarded transitions, instead of a State-pattern class hierarchy like the elevator page uses?