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.
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;
}
}
}RED --TIMER--> GREEN
GREEN --TIMER--> YELLOW
YELLOW --EMERGENCY--> RED
RED --TIMER--> GREENUse 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.
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());
}
}
}
}rejected: cannot move from DRAFT to SHIPPED
now PLACED
now PAID
now SHIPPED
rejected: cannot move from SHIPPED to CANCELLEDThe 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).
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);
}
}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
applyreturns 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:
receiptis present only inPaid, andtrackingonly inShipped. - The outer
switchover the state has nodefault, so adding a state toOrderStatefails to compile untilapplyhandles it. The innerswitchover the event has adefaultthat 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 thedefault. - The guard
when items > 0keeps 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().
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());
}
}
}cannot place an empty order (0 items)
shipping 2 items for receipt 9001
tracking T1
rejected: this order was already paidDraft.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:
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
}
}error: cannot find symbolJava 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.
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);
}
}
}
}paid 100
declined: insufficient fundsThe 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
Placeddirectly, 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.
defaultarms. Adefaultin aswitchover 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
switchover a sealed type or an enum withoutdefaultmakes adding a state a compile error until everyswitchis 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
consumedflag 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.
