ATM
Part of the free Low-Level Design course on LogicWiz, in the chapter: Case studies, step by step. The class-diagram exercises run in the app.
ATM: a small machine with a big neighbour
"You debited the account, then found you could not pay. Which line gives the customer the money back?"
Priya, reading an ATM log
The prompt, and the boundary with the bank
Priya says: "Design an ATM." Arjun asks his questions in order and writes the assumptions on the board.
| Question | Assumption |
|---|---|
| What can a customer do? | Check the balance, withdraw cash and deposit money, then get a receipt |
| How does login work? | Insert a card, enter a PIN. Three wrong PINs in a row and the machine keeps the card, and the bank blocks it |
| What cash does it hold? | Notes of 500, 200 and 100 rupees, in limited stock. Amounts are multiples of 100, paid with the largest notes first |
| Who knows the balance and the PIN? | The bank. The ATM only calls it |
| What if the bank refuses, or the cash cannot come out? | The customer is never charged for cash they did not get |
| Do other ATMs use the same account? | Yes, at the same time. The bank makes each debit atomic |
The fourth row is the important one. An ATM is a small machine with a big neighbour. Everything about accounts lives in the bank, and the ATM reaches it through a few calls. Deciding what is inside your design and what you only call is the first real decision of this problem.
From nouns and verbs to classes
The story is: A customer inserts a card and enters a PIN. The ATM asks the bank to check the PIN. The customer asks for an amount. The ATM checks it can pay that amount in notes, asks the bank to take the money, and counts out the notes. Then a receipt is kept and the card comes back.
The classes are:
- Atm receives the actions and holds the current state.
- Session, which holds the card in the machine and its count of wrong PINs.
- Bank, an interface for the bank: card status, check a PIN, balance, debit, credit and block.
- CardStatus, an enum: ACTIVE, BLOCKED or UNKNOWN.
- NoteDispenser, which holds the note stock and works out which notes make an amount.
- AtmState, with one class for each stage: idle, card inserted, and PIN accepted.
- Receipt, which records the kind of transaction, the amount and the balance after it.
Account is a noun in the story, but it is not a class in this design. It lives inside the bank, so the ATM never sees a balance except as an answer to a call. Leaving it out is a decision.
Now look at the verbs. Insert, enter PIN, withdraw and eject mean different things at different moments, which is the signal you met in the vending machine. This problem adds two things the vending machine did not have: an outside system that can refuse, and an amount that must be split into notes.
The first draft, and where it breaks
public class NaiveAtm {
private final RealBankClient bank = new RealBankClient();
private String state = "IDLE"; // "IDLE", "CARD_IN" or "PIN_OK"
private int notes500 = 2, notes200 = 3, notes100 = 4;
public void withdraw(String card, int amount) {
if (state.equals("PIN_OK")) {
bank.debit(card, amount);
int n500 = amount / 500;
amount = amount % 500;
int n200 = amount / 200;
amount = amount % 200;
int n100 = amount / 100;
notes500 -= n500;
notes200 -= n200;
notes100 -= n100;
System.out.println("Dispensed 500 x" + n500 + ", 200 x" + n200 + ", 100 x" + n100);
System.out.println("Stock now: " + notes500 + ", " + notes200 + ", " + notes100);
}
}
public void pinAccepted() { state = "PIN_OK"; }
}
Priya asks for 3700 rupees, and the machine holds two 500 notes, three 200 notes and four 100 notes:
Connecting to the bank over the network...
REAL BANK: debited 3700 from C1
Dispensed 500 x7, 200 x1, 100 x0
Stock now: -5, 2, 4
Three things went wrong, and each has its own cause:
- The machine paid with notes it does not have. The ladder of divisions never looks at the stock, so the stock count went to minus 5.
- The money moved before anyone knew the machine could pay. The debit is the first line, and the check never comes.
- The first two lines show the third problem. Constructing the class created a real bank client, so every test of the ladder would touch the real bank. There is no way to swap it.
A fourth problem is waiting. A new note value means editing the ladder, and the strings for the state have the same missing-case danger as the vending machine.
Three decisions, each forced by a pain
Decision 1: one class per state, with the session data in the Atm. The stages are idle, card inserted and PIN accepted. The state pattern works as it did in the vending machine: every action on Atm goes to the current state, and the interface refuses by default.
interface AtmState {
default String insertCard(Atm atm, String card) { return "NOT_ALLOWED"; }
default String enterPin(Atm atm, String pin) { return "NOT_ALLOWED"; }
default String balance(Atm atm) { return "NOT_ALLOWED"; }
default String withdraw(Atm atm, int amount) { return "NOT_ALLOWED"; }
default String deposit(Atm atm, int amount) { return "NOT_ALLOWED"; }
default String eject(Atm atm) { return "NOT_ALLOWED"; }
}
final class Session {
private final String card;
private int wrongPins;
Session(String card) { this.card = card; }
String card() { return card; }
int recordWrongPin() { return ++wrongPins; }
}
Each state overrides only what it allows. The wrong-PIN count raises a design question: which object stores it? A state object is created fresh at each move, so it holds no data about one customer. The count belongs to the Session, which Atm starts when a card goes in. This is HasCardState.enterPin:
@Override
public String enterPin(Atm atm, String pin) {
String card = atm.session().card();
if (atm.bank().checkPin(card, pin)) {
atm.moveTo(new AuthenticatedState());
return "OK";
}
int wrong = atm.session().recordWrongPin();
if (wrong < maxWrongPins) return "WRONG_PIN " + (maxWrongPins - wrong) + " left";
atm.bank().block(card); // the machine keeps the card
atm.endSession();
atm.moveTo(new IdleState());
return "CARD_BLOCKED";
}
stateDiagram-v2
[*] --> Idle
Idle --> HasCard: card accepted
HasCard --> Authenticated: PIN correct
HasCard --> HasCard: wrong PIN, tries left
HasCard --> Idle: third wrong PIN, card kept and blocked
HasCard --> Idle: eject
Authenticated --> Idle: eject
Decision 2: the notes come from a chain. Choosing notes for an amount is a rule per note value, and the set of values may change. Picture a line of cashiers, each with one tray of notes. The first takes as many 500s as fit and passes the rest of the amount to the next. This is the chain of responsibility pattern: handlers in a line, each doing its part and passing the rest on. The picture stops working in one place: a real cashier sees the whole counter, while each handler sees only its own value and the remaining amount.
DispenseHandler is the type of a link, and NoteHandler is the link for one note value. It has its own stock:
public boolean plan(int amount, Map<Integer, Integer> plan) {
int take = Math.min(amount / value, stock);
if (take > 0) plan.put(value, take);
int left = amount - take * value;
if (left == 0) return true;
return next != null && next.plan(left, plan);
}
public void remove(Map<Integer, Integer> plan) {
stock -= plan.getOrDefault(value, 0);
if (next != null) next.remove(plan);
}
Take 1300 with two 500s, three 200s and four 100s. The 500 link takes min(2, 2) notes, so two, and 300 is left. The 200 link takes one, and 100 is left. The 100 link takes one and nothing is left. The plan is 500x2 200x1 100x1. plan only fills a map and never touches the stock. remove takes the notes out later, and NoteDispenser calls it as commit. NoteDispenser keeps the links sorted largest first and relinks the chain when a new value is loaded.
Decision 3: the order of steps, and a fake bank to test it. The opening quote is about this. The steps of withdraw are: check the amount, check the balance, plan the notes, debit the bank, commit the notes, and keep a receipt. The order is chosen by what can be undone. Planning changes nothing. A bank debit can be undone with a credit. Notes in a customer's hand cannot be undone. So the machine plans first, debits second, and moves the notes last. If the notes fail to come out after the debit, the machine credits the money back. This is a compensating action: a new step that cancels the effect of an earlier one.
String card = atm.session().card();
if (amount <= 0 || amount % NoteDispenser.SMALLEST_NOTE != 0) return "INVALID_AMOUNT";
if (atm.bank().balance(card) < amount) return "INSUFFICIENT_FUNDS";
var plan = atm.dispenser().plan(amount); // nothing is taken from the machine yet
if (plan == null) return "CANNOT_DISPENSE";
if (!atm.bank().debit(card, amount)) return "INSUFFICIENT_FUNDS";
try {
atm.dispenser().commit(plan);
} catch (IllegalStateException jam) {
atm.bank().credit(card, amount); // compensating action: put the money back
return "DISPENSER_JAMMED";
}
To test these paths, Atm talks to the Bank interface, and tests pass a FakeBank. A fake is a simple working stand-in: cards, PINs and balances in maps. The dispenser has a jam switch for the same reason. Here is Arjun's run, with the bank's own lines indented:
insertCard 1111 -> OK
withdraw 500 (no PIN yet) -> NOT_ALLOWED
enterPin 0000 -> WRONG_PIN 2 left
enterPin 4321 -> OK
bank: debit 1300 from 1111
withdraw 1300 -> DISPENSED 500x2 200x1 100x1
receipt -> WITHDRAW 1300, balance 3700
withdraw 1300 -> CANNOT_DISPENSE
bank: debit 300 from 1111
bank: credit 300 to 1111
withdraw 300 -> DISPENSER_JAMMED
balance -> BALANCE 3700
The second 1300 never reached the bank, because the plan failed first. After the jam, the debit and the credit cancel out, and the balance is still 3700. Three wrong PINs print WRONG_PIN 2 left, WRONG_PIN 1 left and CARD_BLOCKED, and the next insertCard for that card says CARD_BLOCKED.
Extend it, then break it
"Add a 2000 rupee note." Arjun loads one 2000 note, and the dispenser puts a new link at the front of the chain. No state class and no withdraw code changes. With one 2000, one 500 and one 200 in stock, withdraw 2700 gives DISPENSED 2000x1 500x1 200x1.
"Withdraw 600 with one 500 and three 200s." The chain takes the 500, has 100 left, and the next link has no 100s. The answer is CANNOT_DISPENSE, although three 200s would work. This is greedy dispensing: each link takes the most it can and never changes its mind. It is simple, and it fails here. The vending machine had the same limit, and the answer is the same. Refusing is safe, and a search over other mixes would find the answer, at the cost of more code. Say which rule you chose.
"Two ATMs, one account, 4000 rupees each from a 5000 balance, at the same moment." The machines share no objects, so no lock inside Atm can help. The shared thing is the account, so protection belongs in the bank: its debit checks the balance and subtracts as one step, and returns false when the money is not there. The ATM treats a refused debit as a refused withdrawal. In the demo both machines read a balance of 5000 first, and then race to debit:
bank: debit 4000 from 4444
results [DISPENSED 500x8, INSUFFICIENT_FUNDS], final balance 1000
One withdrawal is paid and the other is refused.
⚠️ Warning: Never move the notes first and debit second. If the debit then fails, the customer holds cash and the bank has nothing to take back.
Your turn: the state classes and the bank boundary
Start from an ATM that keeps its state in a string and builds a real bank client inside itself. Add the state interface and its classes, and a session for the card in the machine. Then put the bank behind an interface that a fake can implement.
📌 Summary: Keep session data in a Session, not in states. Plan notes in a chain, then debit, then move the notes, and credit back if that fails. Put the bank behind an interface for a fake.
Next, open the problem ATM Machine in the problem library. Design it again from a blank page, compare it with the model answer, and then use its "Build it on your machine" kit to write and run it.
What Arjun learns next
Arjun has now designed four problems from a blank page, and each has been about one machine. The next problem has many parts moving at once: an elevator with several cars, buttons on every floor and a rule for who goes where.
Check your understanding
- In withdraw, which order of steps is right, and why?
- Three wrong PINs in a row make the machine keep the card. Where should the wrong-PIN count be stored?
- The bank starts stocking 2000 rupee notes. What is the smallest correct change?
- Two ATMs withdraw 4000 rupees each from a 5000 rupee account at the same moment. Where must the protection be?