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, 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 per 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. You can check every branch against the requirement, one at a time.

Nothing here calls 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, instead of an error. One new if inside the IDLE case.

Second, and far more disruptive, the machine needs a maintenance mode. Reachable from any state the machine happens to be in when a technician turns a key.

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. Now compare what they cost you.

The refund fix was one line. The maintenance 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

handle still compiles and still reads top to bottom, so nothing about it looks broken.

What changed is who has to reopen it. A rule about idle-state refunds and a rule about every state in the machine both become edits to the same method, mixed into each other.

Six months from now, answering "what can this machine do right now" means reading the whole method instead of one paragraph of it. The rules and the code enforcing them are the same lines.

So the cost has nothing to do with handle getting longer. 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.

Ask yourself the question that settles it. Could you hand this to somebody outside the codebase and ask "does this match what we agreed"? You could not.

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;
    }
}

Now the rows themselves. Read them and notice they are the requirement's own table, transcribed straight across instead of 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

Look hard at that last line.

DISPENSING has exactly one outgoing row, to COLLECT. Try to insert a coin while the machine is dispensing and there is no row at all, so next reports a rejection — with no if written anywhere to reject it.

A missing row is the refusal.

Follow what that buys you. A brand-new state that should reject everything except one way out costs exactly two rows. One in, one out. Nothing else to write.

That is the State pattern. The rules for what can happen next live as data a machine looks up, instead of as branches a machine's code walks through.

6. What modern Java changes here

The original catalogue 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 you get something the class-per-state version cannot give you: a table you can print, diff against the requirement, and hand to somebody who does not read Java.

One free win worth noticing in the code. A Map keyed on an enum can be an EnumMap, used above, which stores entries in the enum's declared order and beats a general-purpose HashMap, because the key space is small, fixed, and known at compile time.

When does the subclass-per-state version earn its place? Once a state needs its own behaviour and not just its own outgoing edges. An action that runs on entry, say, differing state to state.

A table answers one question well: what comes next. It says nothing about what a state does while it is active.

7. When naming it is wrong

Not every rule that sounds like "what happens next" fits two columns.

Picture a no-show timeout. A trip cancels itself if a driver has not arrived within some number of minutes of being matched.

Count the inputs. State, event, and how long the trip has been waiting. Three.

Edge carries two. No number of extra rows makes a two-column table express a rule that genuinely depends on a third value.

So here is your threshold. Write the rule as a method signature first, and count the 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 instead of folded into one.

A table built for a rule that never 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 somebody tests the timing case.

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. You never opened TransitionTable, 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 returning 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

That is a switch expression. It produces a value directly, using -> instead of :, with no break needed.

Java requires a switch expression over an enum to be exhaustive. 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 appeared without a matching case. Not at run time, months later, the first time a real machine reaches MAINTENANCE and describe throws in front of a user.

Now contrast that with TransitionTable, because this is the part worth carrying away.

Adding MAINTENANCE to the enum does not make TransitionTable fail to compile. 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 correct until you add rows for it.

The same silence would be entirely wrong for describe, which is supposed to say something about every state that exists.

One language feature — exhaustiveness checking — is a safety net in one place and a false alarm in the other. Telling those 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 whether you can write a switch.

It tests whether you notice that the rules and the code enforcing them can be the same lines, or can live apart. And 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 pointing at the no-show timeout and saying, correctly, "that one does not belong in the table" — before anybody asks you 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 →