Beginner26 min readMachine Coding Practicelive prototype

ATM

Looks like a vending machine with a keypad. It is not. An ATM changes two things that must always agree — a number in a database and a stack of physical paper — and only one of them can be rolled back. Every interesting question in this problem lives in that gap.

The idea

What it is

“Design an ATM.” Most candidates hear “vending machine, but for cash” and start writing states. States are part of it — but they are not why this problem is asked.

An ATM is asked because it is the smallest realistic system where one action must change two independent things, and one of those things is a physical machine that can jam. Debit the account and the notes never come out, and you have taken someone's money. Push the notes out and fail to debit, and the bank has given money away. There is no ordering that is safe by itself — you need a way to undo.

the machine (local, physical, can fail) MAIN MENU 1 Balance 2 Withdraw Screen ••••4821 CardReader Keypad CASSETTES ₹2000 ×2 ₹500 ×6 ₹200 ×3 ₹100 ×5 CashDispenser ₹2,000 ×2 ₹500 ×2 Account ••••4821 ₹12,000 remote · can be rolled back
The dotted line is the whole problem. To the left, paper that cannot be un-dispensed. To the right, a number that can be put back. A withdrawal has to move both, and only one of them is reversible.

The whole system in three sentences

A session starts when a card goes in and ends when it comes out — nothing survives it. Every transaction runs its cheap checks first (balance, daily limit, can the notes even add up) and only then touches anything. If the physical dispense fails, the account debit is undone.

What separates this from a vending machine

VENDING MACHINE ATM resources one — the shelf two — cash AND the account who is asking nobody — coins are the auth an authenticated identity cost of a bug one snack somebody’s salary what you must build a state machine a state machine + rollback
If you have already done Vending Machine, you have the state machine. This lesson is about the extra row — and it is the row the interviewer is grading.

Mechanics

How it works

Step 1 · Clarify — 4 minutes

  • Which transactions?“Balance, withdraw, deposit.” Three is plenty. Transfers add an account-to-account story that eats your clock.
  • Does the ATM hold the balance, or does a bank?“A bank service holds it; the ATM asks.” This one answer creates the two-resource problem you want to talk about.
  • What denominations, and how many? — ₹2000/500/200/100 with finite counts. Finite is the interesting version.
  • PIN rules? — three attempts, then the card is retained. Cheap to build, and it shows you thought about security.
  • Daily limit? — yes, say ₹20,000. It gives you a second, policy kind of failure that is clearly not physical.
  • Out of scope — cheque deposits, receipts, card networks, multi-currency, the bank's own persistence.

The question that unlocks the good conversation

Ask: “what happens if the dispenser jams after we've debited the account?” Most candidates never raise it, and it is the single thing this problem exists to test. Asking it in minute three tells the interviewer you have seen a real system before.

Step 2 · The session — everything dies with the card

An ATM is a machine used by strangers, one after another. The most important structural rule is that nothing survives the card ejecting: not the PIN attempts, not the authenticated flag, not the account reference, not the last-viewed balance.

NO_CARD insertCard() AUTHENTICATING wrong PIN — attempts++ PIN ok SESSION_ACTIVE balance / withdraw / deposit 3rd wrong PIN CARD_RETAINED ejectCard() — and every field of the session is destroyed here
The dashed arrow home is not just a transition, it is a wipe. Storing attempts or currentAccount on the ATM instead of on the session is how the next customer sees somebody else's balance. Notation: State diagrams.

One field, one bug avoided

Model the session as an object (Session { card, account, attempts, startedAt }) and hold it as a nullable field on the ATM. ejectCard() becomes session = null. Now “did I remember to clear that?” is not a question you can get wrong.

Step 3 · The withdrawal — three checks, then two writes

A withdrawal fails in three genuinely different ways, and the order you check them in is not arbitrary. Cheapest and most reversible first; the one that touches hardware last.

1 · amount ≤ balance ? ✗ InsufficientFunds nothing touched · free to check 2 · under daily limit ? ✗ LimitExceeded a policy rule — belongs to the account 3 · notes add up ? ✗ CannotDispense a physical fact about THIS machine debit the account dispense the notes jam → ROLLBACK the debit
Three failures, three different owners: the account has the money, the bank policy has the limit, the machine has the paper. Conflating them into one “declined” is the modelling mistake here.

Now the part that makes this an ATM and not a vending machine. The two writes at the bottom are not equal:

✗ NO ROLLBACK debit ₹5,000 dispenser jams ✗ error shown balance ₹12,000 → ₹7,000 · cash in hand ₹0 · the customer is out ₹5,000 ✓ WITH ROLLBACK debit ₹5,000 jams ✗ throws catch credit ₹5,000 back balance ₹12,000 → ₹7,000 → ₹12,000 · cash in hand ₹0 · nobody lost anything the reversal is a real transaction of its own — it is recorded, not erased
Press 🔧 Jam the dispenser in the prototype and withdraw ₹5,000. You will see the balance fall to ₹7,000 and then climb back. That flicker is the entire point of the problem.
the shape that matters
public Receipt withdraw(Money amount) {
    requireSession();

    // ---- every check that can fail, before the first write ----
    if (!account.hasFunds(amount))            throw new InsufficientFundsException(amount);
    if (!policy.withinDailyLimit(account, amount)) throw new DailyLimitExceededException();
    NotePlan plan = dispenser.planFor(amount)             // can the CASSETTES build it?
            .orElseThrow(() -> new CannotDispenseAmountException(amount));

    // ---- two writes; the second one can physically fail ----
    account.debit(amount);
    try {
        dispenser.dispense(plan);
    } catch (DispenserFaultException e) {
        account.credit(amount);           // <-- the whole problem, in one line
        throw new TransactionReversedException(e);
    }
    return new Receipt(account.id(), amount, plan, clock.now());
}

“Just dispense first, then debit” does not fix it

Then a failure after dispensing means the bank handed out cash and never charged for it. There is no ordering of two independent writes that is safe on its own — which is exactly why the answer is a compensating action, not a clever order. Say that sentence out loud in the interview; it is the one that lands.

Where this goes if they push further

Real ATMs do not roll back optimistically — the network can drop between the debit and the acknowledgement. They use an idempotency key per transaction so a retry cannot double-charge, plus overnight reconciliation against the machine's physical note count. You do not need to build any of that. Naming it in two sentences is worth more than building half of it.

Choosing the notes

Same greedy walk as making change, with one difference that trips people up: an ATM can only pay in the notes it physically holds, so “₹150” is not a rounding problem, it is impossible.

CASSETTES ₹2000 ×2 ₹500 ×6 ₹200 ×3 ₹100 ×5 ₹5,000 ₹2000 ×2 + ₹500 ×2 biggest first, then fill ₹2,700 ₹2000 ×1 + ₹500 ×1 + ₹200 ×1 exact, using three cassettes ₹150 ✗ impossible smallest note is ₹100 greedy is enough here — but it can fail while a smarter search would succeed, so a real ATM validates the plan before committing
Worth one sentence in the interview: greedy can fail to find a plan that exists (say the ₹500 cassette is empty but ₹200 ×5 would work). Because you validate the plan before dispensing, a greedy miss is a harmless decline — never a wrong payout.

The class diagram

ATM - session : Session? + insertCard / enterPin / eject 0..1 — null when no card Session - attempts : int - account : Account Card - number, pinHash CashDispenser + planFor(amount) + dispense(plan) ↑ can physically fail 1..* Cassette - denom, count asks «interface» BankService debit / credit RemoteBank FakeBank Transaction «abstract» Withdraw Deposit Balance
Two details earn points. session is nullable — that is “no card inserted”, modelled instead of flagged. And BankService is an interface with a FakeBank, because you cannot demo a rollback against a real bank. Notation: Class diagrams.

The FakeBank is not a shortcut, it is the deliverable

A FakeBank you can tell to fail on demand is how you show the rollback working in your main(). Interviewers remember the demo that printed balance restored: ₹12,000. Depending on an interface rather than a concrete bank is Dependency Inversion (DIP) doing real work for you.

The follow-ups

  • “The network dies right after the debit — you never learn if it succeeded.” → an idempotency key per transaction, so the retry is safe, plus reconciliation later. This is the follow-up they most want to hear you handle.
  • “Two ATMs, one account, at the same moment.” → the balance lives in the bank, so the bank serialises it. Notice that the ATM's own concurrency problem is trivial — one person stands at it. Contrast with Parking Lot, where many gates hit one lot.
  • “Add transfers between accounts.” → now two accounts change, and you are looking at the same both-or-neither problem with no physical component. Same answer, cleaner.
  • “Different limits for premium customers.” → the limit is a policy object on the account, not a constant in the ATM. Swappable, like pricing in Parking Lot.
  • “Print a receipt / send an SMS.” → the transaction publishes an event; printers and notifiers subscribe. Observer, and it keeps the withdrawal path clean.
  • “Support multiple currencies.”Money becomes amount + currency, and cassettes are per-currency. Mostly a value-object exercise; see Immutability & value objects.

How this round is lost

  • No rollback. The single most common miss, and the one the problem exists to catch.
  • Session state on the ATM. attempts as an ATM field means the next customer inherits it.
  • Only one kind of decline. Merging insufficient funds, over the limit and cannot dispense into one message throws away the entire modelling insight.
  • A real Bank class with no seam. Then you cannot demonstrate the failure path, which is the best thing you had to show.
  • double for money. On an ATM. Use a minor-unit long or BigDecimal and say why.

Interactive prototype

See it. Build it. Break it.

A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.

About this simulation

A working ATM, with the two resources shown side by side: the cash cassettes and the account balance. Get the PIN wrong three times and the card is retained. Then find the three different ways a withdrawal can be declined — ₹99,000 (not enough money), ₹150 (no combination of notes makes it), and ₹2,700 after 💸 Run out of small notes (the money exists, the paper doesn't). Finally, the one that matters: hit 🔧 Jam the dispenser and withdraw ₹5,000. Watch the balance drop to ₹7,000 — and then watch it come back, because the notes never left the machine.

Hands-on

Try these yourself

Open the prototype above, predict what happens, then verify.

try 01

Get the card eaten

Insert the card, then press 🔢 9999 (wrong) three times. The counter is visible on screen each time, and on the third the card is retained. Note what the machine never tells you: which digit was wrong, or whether the card number even exists.

try 02

Find all three declines

Reset, authenticate with 1234, then 💵 Withdraw. Try ₹99,000 — insufficient funds, decided purely from the balance. Try ₹150 — the balance is fine, but no combination of ₹2000/500/200/100 makes 150. Two failures, two completely different causes, two different messages.

try 03

Separate the account from the paper

Press 💸 Run out of small notes, then try ₹2,700. The account has ₹12,000 sitting right there and the machine still says no: ₹2,000 + ₹500 = ₹2,500 and nothing can make the last ₹200. Then try ₹5,000 — it works, because 2000×2 + 500×2 fits. The money and the notes are different resources.

try 04

Break it on purpose — the one that matters

Press 🔧 Jam the dispenser, then withdraw ₹5,000. Watch the balance panel: ₹12,000 → ₹7,000 → back to ₹12,000. The tray stays empty the whole time. That flicker is a compensating transaction, and it is the reason this problem is in the interview set.

try 05

Watch the session die

Authenticate, check the balance, then ⏏ Eject card. Everything on screen resets to WELCOME and the balance panel goes back to ₹—. Ask yourself which fields had to be cleared for that to be true, and what would happen if attempts were stored on the ATM instead of the session.

try 06

Build it from memory

Blank file: MoneyCassette and CashDispenser.planFor()SessionBankService interface with a FakeBankwithdraw() with all three checks and the try/catch rollback → main(). Make your demo call the fake bank in failure mode at least once, and print the balance before, during and after.

In practice

When to use it — and what trips people up

The pattern behind the ATM, and where else it shows up

Strip away the cards and cassettes and you are left with a shape that appears everywhere: one user action that must change two systems, where at most one of them can be rolled back. Once you recognise it, a lot of “hard” design questions become the same question.

  • Checkout — charge the card, then reserve the stock. Payment succeeded but the last item just sold? Refund. Same compensating action.
  • Booking a seat — take the money, then hold the seat. Any system where the second half is contended has this problem; it is what makes BookMyShow the advanced version of it.
  • Sending a file and recording that you sent it — the send is irreversible in exactly the way dispensing cash is.
  • Any two-service write without a distributed transaction — which is nearly all of them. The industry name for the general solution is a saga: a sequence of local transactions, each with a compensating action.

The vocabulary that earns the point

“I'd treat this as a saga: local transaction plus compensating action, with an idempotency key so a retry can't double-charge, and reconciliation as the backstop.” One sentence, and you have covered what a real payments team would actually do — without building any of it.

Where this design would stop working

  • When the network can fail between debit and acknowledgement. The try/catch only helps if you learn the dispense failed. In production you cannot always tell, which is exactly why idempotency keys and reconciliation exist.
  • When the ATM must work offline. Then it needs its own ledger and a sync protocol, and the single source of truth is gone.
  • When one account is used from many machines at once. The bank has to serialise it; the ATM cannot. Worth saying explicitly, because it shows you know where the concurrency actually lives.

If you only remember one thing

Do all the checks that can fail, then do the reversible write, then the irreversible one — and be ready to undo the reversible one. That single ordering rule is the ATM problem.

What it gives you

  • The rollback path is explicit and demonstrable — a FakeBank plus a faulty dispenser proves it in the main() method.
  • Three distinct decline reasons keep the account, the bank policy and the physical machine as separate concerns instead of one vague failure.
  • Session-as-a-nullable-object makes “no card inserted” a modelled state, so nothing can leak between customers.
  • BankService as an interface means the ATM is testable with no bank at all, and the daily limit lives with the account where it belongs.
  • Money as whole paise removes an entire class of rounding bugs from a system that handles cash.

Common mistakes

  • The try/catch rollback only works when the failure is observable — a network timeout after the debit is a genuinely harder problem this design does not solve.
  • Greedy note selection can decline an amount that a smarter search could have paid, so the machine occasionally refuses a valid request.
  • Checking the note plan and then dispensing is two steps; between them another process could in principle change the cassettes, so a real machine needs the dispenser to hold its own lock.
  • Modelling Withdrawal / Deposit / BalanceEnquiry as a Transaction hierarchy is often over-engineering at this size — three methods on the ATM are usually enough.
  • The design assumes one customer at a time, so it says nothing useful about how the bank keeps a shared account consistent across machines.

Reference

Code & further reading

A minimal reference implementation and pointers worth bookmarking.

import java.util.*;

// ---------- money as whole paise; never a double ----------
record Money(long paise) {
    static Money rupees(long r) { return new Money(r * 100); }
    Money plus(Money o)  { return new Money(paise + o.paise); }
    Money minus(Money o) { return new Money(paise - o.paise); }
    boolean gt(Money o)  { return paise > o.paise; }
    long rupees()        { return paise / 100; }
    public String toString() { return "₹" + rupees(); }
}

class Account {
    private final String id;
    private Money balance;
    private Money withdrawnToday = new Money(0);

    Account(String id, Money opening) { this.id = id; this.balance = opening; }

    String id()        { return id; }
    Money balance()    { return balance; }
    Money usedToday()  { return withdrawnToday; }

    boolean hasFunds(Money amount) { return !amount.gt(balance); }

    void debit(Money amount) {
        if (!hasFunds(amount)) throw new IllegalStateException("overdraw");
        balance = balance.minus(amount);
        withdrawnToday = withdrawnToday.plus(amount);
    }
    /** The compensating action. Not "undo" — a real, recorded credit. */
    void credit(Money amount) {
        balance = balance.plus(amount);
        withdrawnToday = withdrawnToday.minus(amount);
    }
}

// ---------- the bank is behind an interface so failure is demoable ----------
interface BankService {
    Account authenticate(Card card, String pin);
    void debit(Account a, Money amount);
    void credit(Account a, Money amount);
}

record Card(String number, String pin) {}

class FakeBank implements BankService {
    private final Map<String, Account> accounts = new HashMap<>();
    void register(Card c, Account a) { accounts.put(c.number(), a); }

    public Account authenticate(Card card, String pin) {
        if (!card.pin().equals(pin)) throw new WrongPinException();
        return accounts.get(card.number());
    }
    public void debit(Account a, Money amount)  { a.debit(amount); }
    public void credit(Account a, Money amount) { a.credit(amount); }
}

class WrongPinException extends RuntimeException {}
class InsufficientFundsException extends RuntimeException {}
class DailyLimitExceededException extends RuntimeException {}
class CannotDispenseAmountException extends RuntimeException {}
class DispenserFaultException extends RuntimeException {}
class TransactionReversedException extends RuntimeException {
    TransactionReversedException(Throwable cause) { super("dispenser fault — debit reversed", cause); }
}

// ---------- the physical half ----------
class Cassette {
    final int denomination;      // in rupees
    int count;
    Cassette(int denomination, int count) { this.denomination = denomination; this.count = count; }
}

class CashDispenser {
    private final List<Cassette> cassettes;   // highest denomination first
    private boolean faulty = false;

    CashDispenser(List<Cassette> cassettes) {
        this.cassettes = new ArrayList<>(cassettes);
        this.cassettes.sort(Comparator.comparingInt((Cassette c) -> c.denomination).reversed());
    }
    void setFaulty(boolean f) { this.faulty = f; }

    /** Greedy over what is physically held. Empty == this machine cannot pay that number. */
    Optional<Map<Integer, Integer>> planFor(Money amount) {
        Map<Integer, Integer> plan = new LinkedHashMap<>();
        long left = amount.rupees();
        for (Cassette c : cassettes) {
            int n = (int) Math.min(left / c.denomination, c.count);
            if (n > 0) { plan.put(c.denomination, n); left -= (long) n * c.denomination; }
        }
        return left == 0 ? Optional.of(plan) : Optional.empty();
    }

    void dispense(Map<Integer, Integer> plan) {
        if (faulty) throw new DispenserFaultException();          // the jam
        for (Cassette c : cassettes) {
            Integer n = plan.get(c.denomination);
            if (n != null) c.count -= n;
        }
        System.out.println("   [tray] " + plan);
    }
}

/** Everything that must die when the card comes out. */
class Session {
    final Card card;
    final Account account;
    Session(Card card, Account account) { this.card = card; this.account = account; }
}

class ATM {
    private static final int MAX_PIN_ATTEMPTS = 3;
    private static final Money DAILY_LIMIT = Money.rupees(20_000);

    private final BankService bank;
    private final CashDispenser dispenser;
    private Session session;              // null == NO_CARD. The state, modelled.
    private Card insertedCard;
    private int pinAttempts;

    ATM(BankService bank, CashDispenser dispenser) { this.bank = bank; this.dispenser = dispenser; }

    void insertCard(Card card) { this.insertedCard = card; this.pinAttempts = 0; }

    void enterPin(String pin) {
        try {
            session = new Session(insertedCard, bank.authenticate(insertedCard, pin));
        } catch (WrongPinException e) {
            if (++pinAttempts >= MAX_PIN_ATTEMPTS) { retainCard(); throw new IllegalStateException("card retained"); }
            throw e;
        }
    }

    Money balance() { return requireSession().account.balance(); }

    Money withdraw(Money amount) {
        Account account = requireSession().account;

        // ---- three checks, cheapest first, before ANY write ----
        if (!account.hasFunds(amount))                                throw new InsufficientFundsException();
        if (account.usedToday().plus(amount).gt(DAILY_LIMIT))          throw new DailyLimitExceededException();
        Map<Integer, Integer> plan = dispenser.planFor(amount)
                .orElseThrow(CannotDispenseAmountException::new);

        // ---- two writes; only the first can be taken back ----
        bank.debit(account, amount);
        try {
            dispenser.dispense(plan);
        } catch (DispenserFaultException e) {
            bank.credit(account, amount);                             // compensate
            throw new TransactionReversedException(e);
        }
        return amount;
    }

    void deposit(Money amount) { bank.credit(requireSession().account, amount); }

    void ejectCard() { session = null; insertedCard = null; pinAttempts = 0; }   // the wipe
    private void retainCard() { session = null; insertedCard = null; }

    private Session requireSession() {
        if (session == null) throw new IllegalStateException("no authenticated session");
        return session;
    }
}

public class Main {
    public static void main(String[] args) {
        Card card = new Card("4821", "1234");
        Account account = new Account("A-1", Money.rupees(12_000));
        FakeBank bank = new FakeBank();
        bank.register(card, account);

        CashDispenser dispenser = new CashDispenser(List.of(
                new Cassette(2000, 2), new Cassette(500, 6), new Cassette(200, 3), new Cassette(100, 5)));
        ATM atm = new ATM(bank, dispenser);

        atm.insertCard(card);
        try { atm.enterPin("9999"); } catch (WrongPinException e) { System.out.println("wrong pin 1/3"); }
        atm.enterPin("1234");
        System.out.println("balance " + atm.balance());

        try { atm.withdraw(Money.rupees(99_000)); }
        catch (InsufficientFundsException e) { System.out.println("declined: insufficient funds"); }

        try { atm.withdraw(Money.rupees(150)); }
        catch (CannotDispenseAmountException e) { System.out.println("declined: no note combination makes ₹150"); }

        // ---- the one that matters ----
        dispenser.setFaulty(true);
        try { atm.withdraw(Money.rupees(5_000)); }
        catch (TransactionReversedException e) {
            System.out.println("dispenser jammed → balance restored to " + atm.balance());
        }

        dispenser.setFaulty(false);
        atm.withdraw(Money.rupees(5_000));
        System.out.println("after clean withdrawal: " + atm.balance());

        atm.ejectCard();
        System.out.println("card out — session cleared");
    }
}

References & further reading

6 sources

Knowledge check

Did it land?

Quick questions, answers revealed on submit. Sign in to save your best score.

question 01 / 08

The account is debited and then the dispenser jams. What must happen?

question 02 / 08

Why not simply dispense the cash first and debit the account afterwards?

question 03 / 08

A customer with ₹12,000 asks for ₹2,700, but the machine holds only ₹2,000 and ₹500 notes. What is the correct response?

question 04 / 08

Where should the PIN-attempt counter live?

question 05 / 08

Why should BankService be an interface with a FakeBank implementation?

question 06 / 08

Which failure is a policy rule rather than a physical or account fact?

question 07 / 08

The interviewer asks: “what if the network drops after the debit, so you never learn whether the dispense succeeded?”

question 08 / 08

Why check the note plan before debiting rather than while dispensing?

0/8 answered