LLD Dojo

Patterns you will actually be asked for · chapter 23 of 33

State: a transition table you can read

Chapter 3.5 · Part 3, Patterns you will actually be asked for · about 35 minutes

What you need before this chapter: Part 1 in full, especially enum (1.9) and exceptions (1.8). Part 2 in full, especially open/closed (2.5). This chapter applies the same "what varies" question from chapter 3.1 to something that varies over time inside one object, rather than between implementations.

When you finish this chapter you will be able to:


1. The situation

A vending machine moves through three states: idle, accepting money, and dispensing. Four things can happen to it: a coin goes in, a selection is made, a refund is requested, or the item finishes dropping. The obvious way to encode which combinations are legal is a switch on the current state, with an if for each event inside it.

static MachineState handle(MachineState state, MachineEvent event) {
    switch (state) {
        case IDLE:
            if (event == MachineEvent.INSERT_COIN) return MachineState.ACCEPTING_MONEY;
            if (event == MachineEvent.SELECT) return MachineState.DISPENSING;
            break;
        case ACCEPTING_MONEY:
            if (event == MachineEvent.INSERT_COIN) return MachineState.ACCEPTING_MONEY;
            if (event == MachineEvent.SELECT) return MachineState.DISPENSING;
            if (event == MachineEvent.REFUND) return MachineState.IDLE;
            break;
        case DISPENSING:
            if (event == MachineEvent.COLLECT) return MachineState.IDLE;
            break;
    }
    throw new IllegalStateException("no transition for " + state + " on " + event);
}

2. Naive code that is fine

javac Step1.java
java Step1
after INSERT_COIN: ACCEPTING_MONEY
after SELECT: DISPENSING
after COLLECT: IDLE

For three states and four events, this reads top to bottom, and a reviewer can check every branch against the requirement one at a time. There is no reason to reach for anything more elaborate yet.

3. A new requirement

Two changes land close together. First, a customer should be able to press the coin-return button while the machine is idle and get nothing, rather than an error — one new if inside the IDLE case. Second, and more disruptive, the machine needs a maintenance mode, reachable from any state the machine happens to be in when a technician's key is turned.

if (event == MachineEvent.ENTER_MAINTENANCE) {
    return MachineState.MAINTENANCE;
}
switch (state) {
    case IDLE:
        // ...
        if (event == MachineEvent.REFUND) return MachineState.IDLE;
        break;
    // every other case unchanged, plus a new MAINTENANCE case
javac Step2.java
java Step2
refund while idle: IDLE
entered maintenance from: MAINTENANCE

Both changes work. The refund fix was one line. The maintenance mode fix had to be checked against every existing case, because "any state" is exactly the kind of rule a per-state switch has no natural place for.

4. Watch where it goes, and the real cost

The handle method still compiles and still reads top to bottom, so nothing about it looks broken. What has changed is who has to reopen it. A rule that only concerns idle-state refunds and a rule that concerns every state in the machine both end up as edits to the exact same method, mixed in with each other. Six months from now, reading "what can this machine do right now" means reading the whole method, not one paragraph of it, because the rules and the code that carries them out are the same lines.

The cost is not that handle got longer. It is that the rules the business actually cares about — one row per legal move — have no representation of their own. They exist only as side effects of a particular if being reachable from a particular case. Nobody can hand this to someone outside the codebase and ask "does this match what we agreed."

5. The move

Pull the rules out as data: a plain list of rows, each one a state, an event, and the state that follows. Then build a small class whose only job is looking a row up.

record Edge(MachineState from, MachineEvent on, MachineState to) {}

final class TransitionTable {
    private final Map<MachineState, Map<MachineEvent, MachineState>> edges;

    TransitionTable(List<Edge> rows) {
        Map<MachineState, Map<MachineEvent, MachineState>> built = new EnumMap<>(MachineState.class);
        for (Edge row : rows) {
            built.computeIfAbsent(row.from(), s -> new EnumMap<>(MachineEvent.class))
                    .put(row.on(), row.to());
        }
        this.edges = built;
    }

    MachineState next(MachineState from, MachineEvent on) {
        Map<MachineEvent, MachineState> row = edges.get(from);
        MachineState to = row == null ? null : row.get(on);
        if (to == null) {
            throw new IllegalStateException("no transition for " + from + " on " + on);
        }
        return to;
    }
}

The rows themselves read like the requirement's own table, transcribed rather than translated into branches.

static List<Edge> standard() {
    return List.of(
            new Edge(MachineState.IDLE, MachineEvent.INSERT_COIN, MachineState.ACCEPTING_MONEY),
            new Edge(MachineState.IDLE, MachineEvent.SELECT, MachineState.DISPENSING),
            new Edge(MachineState.IDLE, MachineEvent.REFUND, MachineState.IDLE),
            new Edge(MachineState.ACCEPTING_MONEY, MachineEvent.INSERT_COIN, MachineState.ACCEPTING_MONEY),
            new Edge(MachineState.ACCEPTING_MONEY, MachineEvent.SELECT, MachineState.DISPENSING),
            new Edge(MachineState.ACCEPTING_MONEY, MachineEvent.REFUND, MachineState.IDLE),
            new Edge(MachineState.DISPENSING, MachineEvent.COLLECT, MachineState.IDLE));
}
javac Step3.java
java Step3
after INSERT_COIN: ACCEPTING_MONEY
after SELECT: DISPENSING
after COLLECT: IDLE
rejected: no transition for DISPENSING on INSERT_COIN

Notice the last line especially. DISPENSING has exactly one outgoing row in the table, to COLLECT. Trying to insert a coin while the machine is dispensing finds no row at all, and next reports that as a rejection with no if written anywhere to reject it. A missing row is the refusal. A brand new state that should reject everything except one way out of it costs exactly two rows: one in, one out, and nothing else to write. This is the State pattern: the rules for what can happen next, held as data a machine looks up, rather than as branches a machine's code walks through.

6. What modern Java changes here

The original catalogue's version of this pattern gives each state its own class implementing a common State interface, with a handle method every state overrides. Nothing here needs that. MachineState as a plain enum and Edge as a record do the whole job, and a table is something you can print, diff against the requirement, and hand to somebody who does not read Java at all. A Map keyed on an enum also gets one thing for free: an EnumMap, used above, stores its entries in the enum's own declared order and is faster than a general-purpose HashMap, because the key space is small, fixed, and known at compile time.

The subclass-per-state version earns its place once a state needs its own behaviour, not just its own outgoing edges — an action that runs on entry, say, that differs state to state. A table answers one question well: what comes next. It has nothing to say about what a state actually does while it is active.

7. When naming it is wrong

Not every rule that looks like "what happens next" fits two columns. Picture a no-show timeout: a trip should cancel itself if a driver has not arrived within some number of minutes of being matched. That rule needs the state, the event, and how long the trip has been waiting — three inputs. Edge only carries two, and no number of additional rows can make a two-column table express a rule that genuinely depends on a third value.

The threshold: write the rule as a method signature first, and count its parameters. Two — a state and an event — belongs in the table as a row. Three or more belongs in code, checked separately, after a table lookup rather than folded into one. A table built to carry a rule that never actually needs more than state and event is the right design. A table stretched to carry a rule that secretly needs a third input is the harder mistake, because it still compiles, still passes an obvious test, and fails only when someone tests the timing case specifically.

Your turn

Add a rule: if the machine jams while dispensing, a refund should return it straight to IDLE. Do it as one new row, with no change to TransitionTable.

The answer.

new Edge(MachineState.DISPENSING, MachineEvent.REFUND, MachineState.IDLE)
javac Step5.java
java Step5
dispensing + refund -> IDLE

One row, appended to the list standard() returns. TransitionTable itself was not opened, because it never knew the rules in the first place — it only knows how to look one up.

Going deeper

A table you can print is also a table the compiler can help you keep complete, once you ask a question about a state some other way: through a switch. Write a method that returns a human-readable description for each state.

static String describe(MachineState state) {
    return switch (state) {
        case IDLE -> "waiting for a customer";
        case ACCEPTING_MONEY -> "counting coins";
        case DISPENSING -> "dropping the item";
    };
}
javac Step6.java
java Step6
IDLE: waiting for a customer
ACCEPTING_MONEY: counting coins
DISPENSING: dropping the item

This is a switch expression — it produces a value directly, with -> instead of : and no break needed. Java requires a switch expression over an enum to be exhaustive: either every constant gets a case, or there is a default. Now add MAINTENANCE to the enum, and change nothing else.

enum MachineState { IDLE, ACCEPTING_MONEY, DISPENSING, MAINTENANCE }
javac Step6Bad.java
Step6Bad.java:6: error: the switch expression does not cover all possible input values
        return switch (state) {
               ^
1 error

The build fails at the exact place a fourth state was introduced without a matching case, rather than at run time, months later, the first time a real machine actually reaches MAINTENANCE and describe throws in front of a user. This is worth contrasting directly with TransitionTable itself: adding MAINTENANCE to the enum does not make TransitionTable fail to compile, because an EnumMap accepts a new key with no code change at all. That silence is exactly right for the table — a state with no rows yet is a state that legitimately rejects everything, which is the correct behaviour until rows are added for it. It would be entirely wrong for describe, which is supposed to say something about every state that exists. The same language feature, exhaustiveness checking, is a safety net in one of these two places and would be a false alarm in the other, and telling the two apart is the actual skill. The same guarantee extends to a sealed interface with a pattern-matching switch, which chapter 3.10 covers directly.

Why this matters in an interview

A state machine question tests something narrower than "can you write a switch." It tests whether you notice that the rules and the code that enforces them can be the same lines, or can be kept apart. It also tests whether you can say, for a specific new rule, how many inputs it actually needs before deciding where it belongs. Naming State matters far less than being able to point at the no-show timeout and say, correctly, "that one does not belong in the table," before you are asked why.


Next: chapter 3.6, Decorator and Chain of Responsibility. Both patterns in that chapter are about a request passing through a sequence of steps, which is a different shape from one object's own internal states, and worth telling apart from this chapter before you meet it.

← 3.4 Observer: telling other objects something happened · All chapters · 3.6 Decorator and Chain of Responsibility →