Guide contents

Lesson 7 of 31

State Machines with Sealed Types

Many low-level design problems are state machines in disguise: a vending machine, an ATM session, an order, an elevator, a document review. The classic state pattern spreads transitions across a hierarchy of state classes, one class per state with a method per event. Java 21 offers three simpler tools. An enum keeps the state as one of a fixed set of constants. A sealed interface of records keeps the state as a value that carries its own data, and a switch over it must cover every state. A type per state encodes the state in the class, so a method that is illegal in a state does not exist and the call does not compile. This article builds the same domain with each (a purchase order) and then says when to use which.

The domain

An order moves through these states:

stateDiagram-v2
    [*] --> Draft
    Draft --> Placed: place
    Placed --> Paid: pay
    Placed --> Cancelled: cancel
    Paid --> Shipped: ship
    Paid --> Cancelled: cancel
    Shipped --> [*]
    Cancelled --> [*]

The rules: you cannot ship an unpaid order, you cannot cancel a shipped one, and terminal states accept nothing. A design should make these rules hard to violate. See modeling domains in Java for why a fixed set of states is naturally an enum or a sealed type.

Example 1: an enum with a transition method

An enum is a class with a fixed set of instances. When every event is legal in every state, the transition is a total function: it needs no error case. A switch expression over an enum without a default branch must name every constant, so adding a state fails to compile until each switch handles it.

Java
enum Signal { TIMER, EMERGENCY }

enum Light {
    RED, GREEN, YELLOW;

    Light next(Signal signal) {
        return switch (signal) {
            case EMERGENCY -> RED;
            case TIMER -> switch (this) {
                case RED -> GREEN;
                case GREEN -> YELLOW;
                case YELLOW -> RED;
            };
        };
    }
}

public class Main {
    public static void main(String[] args) {
        var light = Light.RED;
        for (Signal signal : new Signal[] {Signal.TIMER, Signal.TIMER, Signal.EMERGENCY, Signal.TIMER}) {
            var after = light.next(signal);
            System.out.println(light + " --" + signal + "--> " + after);
            light = after;
        }
    }
}
Output
RED --TIMER--> GREEN
GREEN --TIMER--> YELLOW
YELLOW --EMERGENCY--> RED
RED --TIMER--> GREEN

Use a total function like this when every event is legal in every state. When some events are not legal, the machine needs a way to refuse them.

Example 2: an enum with allowed transitions

For the order lifecycle without payload, each constant lists the states it may move to. EnumSet is a compact set of enum constants.

Java
import java.util.EnumSet;
import java.util.Set;

enum OrderStatus {
    DRAFT, PLACED, PAID, SHIPPED, CANCELLED;

    Set<OrderStatus> allowedNext() {
        return switch (this) {
            case DRAFT -> EnumSet.of(PLACED);
            case PLACED -> EnumSet.of(PAID, CANCELLED);
            case PAID -> EnumSet.of(SHIPPED, CANCELLED);
            case SHIPPED, CANCELLED -> EnumSet.noneOf(OrderStatus.class);
        };
    }

    OrderStatus moveTo(OrderStatus target) {
        if (!allowedNext().contains(target)) {
            throw new IllegalStateException("cannot move from " + this + " to " + target);
        }
        return target;
    }
}

public class Main {
    public static void main(String[] args) {
        var status = OrderStatus.DRAFT;
        for (OrderStatus target : new OrderStatus[] {
                OrderStatus.SHIPPED, OrderStatus.PLACED, OrderStatus.PAID, OrderStatus.SHIPPED, OrderStatus.CANCELLED}) {
            try {
                status = status.moveTo(target);
                System.out.println("now " + status);
            } catch (IllegalStateException e) {
                System.out.println("rejected: " + e.getMessage());
            }
        }
    }
}
Output
rejected: cannot move from DRAFT to SHIPPED
now PLACED
now PAID
now SHIPPED
rejected: cannot move from SHIPPED to CANCELLED

The enum states the legal graph in one place. Its limit is that a constant carries no per-order data: the receipt number and the tracking number have to live in other fields that exist in every state, including states where they mean nothing. A sealed interface removes that limit.

Example 3: a sealed interface of records

Put each state in a record, carry only the data that state needs, and model events as a second sealed interface. One function maps (state, event) to the next state or a rejection. The rejection is an expected outcome, because events arrive from outside, so the function returns a sealed Transition instead of throwing (see errors in domain design).

Java
sealed interface OrderState {
    record Draft(int items) implements OrderState {}
    record Placed(int items) implements OrderState {}
    record Paid(int items, long receipt) implements OrderState {}
    record Shipped(String tracking) implements OrderState {}
    record Cancelled() implements OrderState {}
}

sealed interface Event {
    record Place() implements Event {}
    record Pay(long receipt) implements Event {}
    record Ship(String tracking) implements Event {}
    record Cancel() implements Event {}
}

sealed interface Transition {
    record Moved(OrderState next) implements Transition {}
    record Rejected(String from, String event) implements Transition {}
}

final class OrderMachine {
    private OrderMachine() {}

    static Transition apply(OrderState state, Event event) {
        return switch (state) {
            case OrderState.Draft(var items) -> switch (event) {
                case Event.Place place when items > 0 -> moved(new OrderState.Placed(items));
                default -> rejected(state, event);
            };
            case OrderState.Placed(var items) -> switch (event) {
                case Event.Pay(var receipt) -> moved(new OrderState.Paid(items, receipt));
                case Event.Cancel cancel -> moved(new OrderState.Cancelled());
                default -> rejected(state, event);
            };
            case OrderState.Paid paid -> switch (event) {
                case Event.Ship(var tracking) -> moved(new OrderState.Shipped(tracking));
                case Event.Cancel cancel -> moved(new OrderState.Cancelled());
                default -> rejected(state, event);
            };
            case OrderState.Shipped shipped -> rejected(state, event);
            case OrderState.Cancelled cancelled -> rejected(state, event);
        };
    }

    static String name(Object value) {
        return value.getClass().getSimpleName();
    }

    private static Transition moved(OrderState next) {
        return new Transition.Moved(next);
    }

    private static Transition rejected(OrderState state, Event event) {
        return new Transition.Rejected(name(state), name(event));
    }
}

public class Main {
    public static void main(String[] args) {
        OrderState state = new OrderState.Draft(2);
        var events = new Event[] {
            new Event.Ship("T1"),   // illegal: not paid
            new Event.Place(),
            new Event.Pay(9001),
            new Event.Ship("T1"),
            new Event.Cancel(),     // illegal: already shipped
        };
        for (Event event : events) {
            String label = OrderMachine.name(event);
            switch (OrderMachine.apply(state, event)) {
                case Transition.Moved(var next) -> {
                    System.out.println(label + ": " + OrderMachine.name(state) + " -> " + OrderMachine.name(next));
                    state = next;
                }
                case Transition.Rejected(var from, var rejectedEvent) ->
                    System.out.println(label + ": rejected (cannot apply " + rejectedEvent + " in state " + from + ")");
            }
        }
        System.out.println("final: " + state);
    }
}
Output
Ship: rejected (cannot apply Ship in state Draft)
Place: Draft -> Placed
Pay: Placed -> Paid
Ship: Paid -> Shipped
Cancel: rejected (cannot apply Cancel in state Shipped)
final: Shipped[tracking=T1]

Four points about this design:

  • States are immutable records, so apply returns the next state and never changes the old one. Code that holds the old state still sees it unchanged.
  • Data lives only in the states where it exists: receipt is present only in Paid, and tracking only in Shipped.
  • The outer switch over the state has no default, so adding a state to OrderState fails to compile until apply handles it. The inner switch over the event has a default that rejects every event not listed. That is the safe default for unknown pairs, and its price is that a new event type does not force each state to consider it. To get that check as well, list every event in every state and drop the default.
  • The guard when items > 0 keeps an empty draft from being placed. The rejection report is the same one used for illegal events.

This design suits events that come from outside at run time (user input, network messages, a replayed log), because the machine is data and every rejected event is an ordinary value the caller can report.

Example 4: a type per state

In a type-per-state design each state is a separate class, and a transition is a method that returns an object of the next class. A method that is not legal in a state does not exist on that state's class, so the illegal call is a compile error instead of a run-time rejection.

The state classes are nested in one holder class with private constructors, so the only way to get a Placed is through Draft.place().

Java
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

final class OrderFlow {
    private OrderFlow() {}

    static final class Draft {
        private final List<String> items = new ArrayList<>();
        private boolean consumed;

        void add(String item) {
            items.add(item);
        }

        int itemCount() {
            return items.size();
        }

        Optional<Placed> place() {
            if (consumed) {
                throw new IllegalStateException("this draft was already placed");
            }
            if (items.isEmpty()) {
                return Optional.empty(); // the draft stays usable
            }
            consumed = true;
            return Optional.of(new Placed(List.copyOf(items)));
        }
    }

    static final class Placed {
        private final List<String> items;
        private boolean consumed;

        private Placed(List<String> items) {
            this.items = items;
        }

        Paid pay(long receipt) {
            if (consumed) {
                throw new IllegalStateException("this order was already paid");
            }
            consumed = true;
            return new Paid(items, receipt);
        }
    }

    static final class Paid {
        private final List<String> items;
        private final long receipt;
        private boolean consumed;

        private Paid(List<String> items, long receipt) {
            this.items = items;
            this.receipt = receipt;
        }

        Shipped ship(String tracking) {
            if (consumed) {
                throw new IllegalStateException("this order was already shipped");
            }
            consumed = true;
            System.out.println("shipping " + items.size() + " items for receipt " + receipt);
            return new Shipped(tracking);
        }
    }

    static final class Shipped {
        private final String tracking;

        private Shipped(String tracking) {
            this.tracking = tracking;
        }

        String tracking() {
            return tracking;
        }
    }
}

public class Main {
    public static void main(String[] args) {
        var empty = new OrderFlow.Draft();
        System.out.println(empty.place().isPresent()
            ? "placed"
            : "cannot place an empty order (" + empty.itemCount() + " items)");

        var draft = new OrderFlow.Draft();
        draft.add("keyboard");
        draft.add("mouse");
        var placed = draft.place().orElseThrow();
        var paid = placed.pay(9001);
        var shipped = paid.ship("T1");
        System.out.println("tracking " + shipped.tracking());

        try {
            placed.pay(9001); // the old object is still reachable
        } catch (IllegalStateException e) {
            System.out.println("rejected: " + e.getMessage());
        }
    }
}
Output
cannot place an empty order (0 items)
shipping 2 items for receipt 9001
tracking T1
rejected: this order was already paid

Draft.place() returns an Optional<Placed>: an empty draft stays usable and the caller learns that nothing was placed. Java has no move semantics, so after placed.pay(...) the variable placed still refers to a usable object. A type-per-state design therefore adds a consumed flag to reject a second transition at run time. That flag is the price of the approach in Java: the compiler prevents calling ship on an unpaid order, and a run-time check prevents paying twice.

Attempting to ship an order that has not been paid does not compile:

Java✗ does not compile
final class Placed {
    Paid pay() {
        return new Paid();
    }
}

final class Paid {
    void ship() {
        System.out.println("shipped");
    }
}

public class Main {
    public static void main(String[] args) {
        var order = new Placed();
        order.ship(); // no method ship() on Placed
    }
}
Output
error: cannot find symbol

Java generics cannot restrict an instance method to one type argument, so a single generic class such as Order<State> cannot offer ship() only when State is Paid. Separate classes, as above, are the way to get the compile-time check.

Example 5: combining both with an outcome type

A step that can branch on run-time data returns a sealed interface of the resulting states. Each branch is a state class again, and a switch handles the fork that only run time can decide.

Java
record Placed(long total) {
    PaymentOutcome pay(long cardBalance) {
        if (cardBalance >= total) {
            return new Paid(total);
        }
        return new Declined("insufficient funds");
    }
}

sealed interface PaymentOutcome {}

record Paid(long total) implements PaymentOutcome {}

record Declined(String reason) implements PaymentOutcome {}

public class Main {
    public static void main(String[] args) {
        for (long balance : new long[] {500, 50}) {
            switch (new Placed(100).pay(balance)) {
                case Paid(var total) -> System.out.println("paid " + total);
                case Declined(var reason) -> System.out.println("declined: " + reason);
            }
        }
    }
}
Output
paid 100
declined: insufficient funds

The type-per-state classes still prevent misuse inside each branch, and the sealed outcome handles the fork. See design vending machine and design elevator system for full machines driven by events, and encapsulation and invariants for the principle behind all three designs.

Choosing between them

Question Enum Sealed interface of records Type per state
Does a state carry its own data? No Yes, per record Yes, per class
When are illegal transitions caught? Run time Run time, as a returned rejection Compile time for missing methods, run time for reuse
Where do events come from? Anywhere Anywhere: input, network, logs Code that you write and the compiler sees
Can the state be stored in a collection or field? Yes Yes, one type for all states Awkward: each state is a different class, so use a sealed wrapper
Can the state be serialized or replayed? Easily Easily Needs conversion
Number of states Any Any Best for a handful, with a linear path
Compiler help when a state is added switch without default switch without default New class, no automatic check
Good fit Traffic light, status flags Vending machine, ATM session, elevator, order lifecycle driven by messages Connection handshake, builder with required steps, protocol with a fixed order

A practical rule for design problems: use an enum when states carry no data, a sealed interface of records when the state must be stored, inspected or driven by external events (most interview problems), and a type per state when a sequence of steps must be followed in order by code you control. They combine: a sealed outcome type wraps the state classes when the result depends on run-time data.

Pitfalls

  • Public constructors. If callers can construct Placed directly, the type-per-state guarantee disappears. Keep constructors private and expose a public constructor only for the initial state.
  • No consumed flag. Java does not stop a caller from reusing an old state object. A transition that must happen once checks and sets a flag.
  • default arms. A default in a switch over a state hides new states from the compiler. Prefer explicit cases for the part that matters.
  • Mutable state in an enum. Enum constants are shared by the whole program, so a mutable field in a constant is global state. Keep per-order data in the order object.
  • Too many types. Ten state classes with cross-transitions become unreadable. That is the signal to switch to records.

Interview points

  • An enum suits states without data. A sealed interface of records suits states that carry data and an event function from (state, event) to the next state.
  • A switch over a sealed type or an enum without default makes adding a state a compile error until every switch is updated.
  • A type-per-state design turns a missing method into a compile error. An old state object stays usable after a transition, so rejecting its reuse needs a run-time flag.
  • Rejected events from outside are an expected outcome, so return a rejection value. A rejected call from code you control is a bug and may throw.
  • Keep state records immutable and keep constructors of state classes private.

Summary

  • A state machine is an enum constant, a sealed value changed by a function, or a set of classes that return the next class.
  • An enum machine is a method from event to next constant. It suits states without data.
  • A sealed machine is a function from (state, event) to a sealed Transition. Give each record only the data valid in that state.
  • A type-per-state design makes illegal calls fail to compile and needs a consumed flag to stop reuse of an old object.
  • Records are immutable, so a transition returns a new state and leaves the old one unchanged.
  • Use an enum for states without data, records when events arrive at run time or the state must be stored, and a type per state for fixed step sequences you control.
  • Keep state constructors private so the type system, not convention, enforces the rules.