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.

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.
0%0 of 122 pages studied