Coding by Hand
Rust home

Design ATM

An ATM is a museum with locked doors between the rooms. You walk in through the lobby, but the door to the ticket counter is locked until you show a card. The door to the exhibit is locked until the ticket counter checks your pin. The door to the gift shop is locked until you have actually finished looking at the exhibit. The guard at each door does not care who you are or what you want — he only checks one thing. Are you standing in the room that this door is connected to. If you are, he unlocks it. If you are in the wrong room, he does not move.

The ATM as five named rooms with one-way doors between them.
The ATM as five named rooms with one-way doors between them.

NCR shipped the first networked ATM in 1971 and Don Wetzel, the engineer who led the project at Docutel before NCR bought the idea, kept a notebook with the failure modes. People tried to withdraw money before inserting their card. People typed a pin and then tried to type it again before pressing OK. People hit cancel mid-transaction and the machine forgot which step it was on. Wetzel wrote the firmware as a list of named situations and a table that said, for each situation, which buttons did anything at all. The rest of the buttons were dead. That table is what a software person now calls a finite state machine, and the reason ATMs almost never lock up the way a 1971 vending machine did is because every press goes through the table first.

The room map for our ATM is five places you can stand. Idle is the lobby with the card slot. CardInserted is the room behind the slot where the keypad lights up. PinEntered is the same room after one wrong guess — the door to the exhibit will not open, the door back to the lobby still works. Authenticated is the exhibit hall where withdrawals and balance checks live. OutOfService is the locked broom closet you get sent to after three wrong pins, and no door from there opens at all. Each event the user can trigger is a knock on a specific door.

#[derive(Debug, Clone, Copy, PartialEq)]
enum AtmState {
    Idle,
    CardInserted,
    PinEntered,
    Authenticated,
    OutOfService,
}

#[derive(Debug, Clone, Copy)]
enum Event {
    InsertCard,
    EnterPin(u32),
    CheckBalance,
    Withdraw(u32),
    Eject,
}

struct Atm {
    state: AtmState,
    balance: u32,
    correct_pin: u32,
    pin_attempts: u8,
}

The whole design is in those three definitions. The enum AtmState is the list of rooms. The enum Event is the list of doors a user can knock on. The struct Atm holds the current room, the cash on hand, the correct pin, and a counter for how many wrong pins have come in since the card was inserted. Nothing in the ATM mutates without going through the door table — which is the handle method.

impl Atm {
    fn new(balance: u32, correct_pin: u32) -> Self {
        Self { state: AtmState::Idle, balance, correct_pin, pin_attempts: 0 }
    }

    fn handle(&mut self, event: Event) -> Result<String, &'static str> {
        match (self.state, event) {
            (AtmState::Idle, Event::InsertCard) => {
                self.state = AtmState::CardInserted;
                Ok("card accepted, please enter pin".into())
            }
            (AtmState::CardInserted, Event::EnterPin(pin)) => {
                if pin == self.correct_pin {
                    self.state = AtmState::Authenticated;
                    self.pin_attempts = 0;
                    Ok("pin ok, authenticated".into())
                } else {
                    self.pin_attempts += 1;
                    if self.pin_attempts >= 3 {
                        self.state = AtmState::OutOfService;
                        Err("3 wrong pins, card captured")
                    } else {
                        self.state = AtmState::PinEntered;
                        Err("wrong pin, try again")
                    }
                }
            }
            (AtmState::PinEntered, Event::EnterPin(pin)) => {
                if pin == self.correct_pin {
                    self.state = AtmState::Authenticated;
                    self.pin_attempts = 0;
                    Ok("pin ok, authenticated".into())
                } else {
                    self.pin_attempts += 1;
                    if self.pin_attempts >= 3 {
                        self.state = AtmState::OutOfService;
                        Err("3 wrong pins, card captured")
                    } else {
                        Err("wrong pin, try again")
                    }
                }
            }
            (AtmState::Authenticated, Event::CheckBalance) => {
                Ok(format!("balance is ${}", self.balance))
            }
            (AtmState::Authenticated, Event::Withdraw(amount)) => {
                if amount > self.balance {
                    Err("insufficient funds")
                } else {
                    self.balance -= amount;
                    Ok(format!("dispensed ${}, balance ${}", amount, self.balance))
                }
            }
            (AtmState::Authenticated, Event::Eject)
            | (AtmState::CardInserted, Event::Eject)
            | (AtmState::PinEntered, Event::Eject) => {
                self.state = AtmState::Idle;
                Ok("card returned".into())
            }
            _ => Err("invalid action for current state"),
        }
    }
}

The shape of handle is the room map written in code. The match keys on the tuple (state, event) — which room you are in plus which door you are knocking on — and every arm is one cell of the table. Idle plus InsertCard moves you to CardInserted and reports success. Authenticated plus Withdraw checks the balance, deducts the amount, and stays in Authenticated. Authenticated plus Eject sends you back to Idle and returns the card. The catch-all arm at the bottom is the bouncer for every cell of the table the designer did not draw. If you are in PinEntered and you knock on the Withdraw door, the bouncer says invalid action and your state does not change. The arm at the very top of the pin-checking branches counts wrong tries and sends you to OutOfService when the counter hits 3. That is one named place in the room map, not a flag stuck onto a different state, which is why no other arm can accidentally let you out of it.

How one call to handle() reads the table, picks the matching arm, and writes the new state.
How one call to handle() reads the table, picks the matching arm, and writes the new state.

The driver in main does not click buttons. It hands the ATM a hardcoded list of events and prints what happened. Every print shows the room before, the event, the room after, and whether the door opened or the bouncer blocked it.

fn run(label: &str, atm: &mut Atm, events: &[Event]) {
    println!("--- {label} ---");
    for event in events {
        let before = atm.state;
        let result = atm.handle(*event);
        let after = atm.state;
        let outcome = match result {
            Ok(msg) => format!("ok: {msg}"),
            Err(msg) => format!("err: {msg}"),
        };
        println!("{:?} + {:?} -> {:?} ({})", before, event, after, outcome);
    }
}

Three runs through the machine. The first is the happy path — insert card, correct pin, check balance, withdraw 60, check balance again, eject. The second is the error path that recovers — insert card, wrong pin, try to withdraw from the wrong room (blocked), retype the right pin, try to withdraw more than the balance (blocked), withdraw a sensible amount, eject. The third is the lockout — three wrong pins in a row sends the machine to OutOfService and the CheckBalance that follows bounces off the catch-all.

fn main() {
    let happy = [
        Event::InsertCard,
        Event::EnterPin(4321),
        Event::CheckBalance,
        Event::Withdraw(60),
        Event::CheckBalance,
        Event::Eject,
    ];
    let mut atm = Atm::new(200, 4321);
    run("happy path", &mut atm, &happy);

    let wrong_pin = [
        Event::InsertCard,
        Event::EnterPin(1111),
        Event::Withdraw(20),
        Event::EnterPin(4321),
        Event::Withdraw(500),
        Event::Withdraw(50),
        Event::Eject,
    ];
    let mut atm = Atm::new(100, 4321);
    run("error path", &mut atm, &wrong_pin);

    let lockout = [
        Event::InsertCard,
        Event::EnterPin(1111),
        Event::EnterPin(2222),
        Event::EnterPin(3333),
        Event::CheckBalance,
    ];
    let mut atm = Atm::new(100, 4321);
    run("lockout path", &mut atm, &lockout);
}
--- happy path ---
Idle + InsertCard -> CardInserted (ok: card accepted, please enter pin)
CardInserted + EnterPin(4321) -> Authenticated (ok: pin ok, authenticated)
Authenticated + CheckBalance -> Authenticated (ok: balance is $200)
Authenticated + Withdraw(60) -> Authenticated (ok: dispensed $60, balance $140)
Authenticated + CheckBalance -> Authenticated (ok: balance is $140)
Authenticated + Eject -> Idle (ok: card returned)
--- error path ---
Idle + InsertCard -> CardInserted (ok: card accepted, please enter pin)
CardInserted + EnterPin(1111) -> PinEntered (err: wrong pin, try again)
PinEntered + Withdraw(20) -> PinEntered (err: invalid action for current state)
PinEntered + EnterPin(4321) -> Authenticated (ok: pin ok, authenticated)
Authenticated + Withdraw(500) -> Authenticated (err: insufficient funds)
Authenticated + Withdraw(50) -> Authenticated (ok: dispensed $50, balance $50)
Authenticated + Eject -> Idle (ok: card returned)
--- lockout path ---
Idle + InsertCard -> CardInserted (ok: card accepted, please enter pin)
CardInserted + EnterPin(1111) -> PinEntered (err: wrong pin, try again)
PinEntered + EnterPin(2222) -> PinEntered (err: wrong pin, try again)
PinEntered + EnterPin(3333) -> OutOfService (err: 3 wrong pins, card captured)
OutOfService + CheckBalance -> OutOfService (err: invalid action for current state)

Read down the output and the table comes alive. Every line is room + door -> room (outcome). The third line of the error run is the one to watch — PinEntered + Withdraw(20) -> PinEntered (err: invalid action for current state). The state did not change. The balance did not change. The bouncer at the bottom of the match handled it. Without the state-and-event tuple match, that line would have been a Withdraw running against an unauthenticated session, and the only thing stopping you from giving away 20 dollars would have been a stray if self.authenticated check that someone could forget to write. The compiler enforces the table for you because every variant of AtmState paired with every variant of Event must either appear as a real arm or fall into the catch-all.

The error path branches off the happy path and then rejoins or terminates.
The error path branches off the happy path and then rejoins or terminates.

One question worth asking — what stops the user from typing a pin while in the Authenticated room and accidentally getting bumped back to PinEntered? Look at the match. There is no (Authenticated, EnterPin) arm. The catch-all returns the bouncer error and the state stays Authenticated. The room map does not have that door. If you wanted that door, you would draw it in the match, and the moment you did the compiler would force you to decide what room it leads to.

The thing this design cannot do is hold more than one session at once. The ATM is a single struct with one current room — fine for a physical machine with one slot, fine for a single-threaded program. The moment two cards need to flow through the same logic at the same time, the next bottleneck is how to give each session its own state without copying the whole rule book.