Modelling a domain state machine with Java 21 records and sealed interfaces — exhaustive transitions, records as events, and invalid states that don't compile.
A trading order has a lifecycle: it is placed, it can be matched in part or in full, it can be cancelled, and eventually it settles. Model that lifecycle as scattered booleans and string states and the invalid states multiply — an order that is both cancelled and matched, a settlement applied twice, a cancellation accepted after the off. Model it as a state machine with Java 21’s records and sealed interfaces, and most of those states stop being representable at all. The compiler becomes the first reviewer of your domain rules.
For a betting-exchange order, the states and transitions are roughly:
PENDING
→ PLACED (accepted by the exchange)
PLACED
→ PARTIALLY_MATCHED (some stake matched)
→ MATCHED (fully matched)
→ CANCELLED (unmatched portion cancelled)
PARTIALLY_MATCHED
→ MATCHED
→ CANCELLED
MATCHED
→ SETTLED (market result applied)
Two rules stand out: CANCELLED and MATCHED are mutually exclusive, and SETTLED is a terminal state. A good model makes both structurally impossible to violate.
public sealed interface OrderState permits
Pending, Placed, PartiallyMatched, Matched, Cancelled, Settled {
}
sealed restricts implementations to exactly the listed types. Any switch over the state must now either handle every state or be exhaustive by construction — there is no default branch hiding a forgotten case, because there is no unknown case.
The transitions themselves are data, so model them as records:
public sealed interface OrderEvent permits
OrderAccepted, OrderPartiallyMatched, OrderFullyMatched,
OrderCancelled, MarketSettled {
}
A record gives you value equality, immutable fields and a canonical constructor in one line. The order’s history becomes a List<OrderEvent> of immutable records — which is exactly the shape you want for audit and replay, and the reason the state-machine pattern composes so well with event sourcing.
The heart of the machine is one method on the state: given the current state and an event, return the next state. With sealed types on both sides, an exhaustive switch is not just possible — it is required:
static OrderState transition(OrderState current, OrderEvent event) {
return switch (current) {
case Pending p -> switch (event) {
case OrderAccepted a -> new Placed(a.orderId());
default -> illegal(current, event);
};
case Placed p -> switch (event) {
case OrderPartiallyMatched m -> new PartiallyMatched(m.matchedAmount());
case OrderFullyMatched m -> new Matched();
case OrderCancelled c -> new Cancelled();
default -> illegal(current, event);
};
case PartiallyMatched p -> switch (event) {
case OrderPartiallyMatched m -> new PartiallyMatched(m.matchedAmount());
case OrderFullyMatched m -> new Matched();
case OrderCancelled c -> new Cancelled();
default -> illegal(current, event);
};
case Matched m -> switch (event) {
case MarketSettled s -> new Settled();
default -> illegal(current, event);
};
case Cancelled c -> illegal(current, event);
case Settled s -> illegal(current, event);
};
}
private static OrderState illegal(OrderState current, OrderEvent event) {
throw new IllegalStateException("Invalid transition " + current + " + " + event);
}
Notice what the compiler enforced: Settled and Cancelled have no case clauses at all — any event routed at them falls to illegal, but the exhaustive switch still compiles because they are handled. The rules live in the code shape, not in comments.
The switch above rejects illegal transitions at runtime. Java 21 lets you go one step further and make many invalid states unrepresentable at compile time — for example, an order that carries both matchedAmount and a cancelled flag can never exist if Matched and Cancelled are different types with different fields:
public record Matched(BigDecimal matchedStake, BigDecimal averagePrice) implements OrderState {}
public record Cancelled(BigDecimal unmatchedStake) implements OrderState {}
Code that would set a matched amount on a cancelled order simply does not compile — the field does not exist on that type. This is the “make illegal states unrepresentable” principle, and sealed interfaces are the tool that finally makes it practical in Java. If you want the mechanics of sealed declarations and exhaustive matching rather than the state-machine pattern, the sealed interfaces deep-dive covers the language side.
Because every event is a record, appending to history is trivial and the history is a first-class citizen:
order.history().forEach(System.out::println);
// OrderAccepted[orderId=..., at=...]
// OrderPartiallyMatched[matchedAmount=...]
// MarketSettled[result=...]
That history is precisely what an audit trail needs, and it slots straight into an event-sourced design — the reason the state machine and CQRS with event sourcing are natural partners: the transition function is the aggregate, the event list is the store.
An exhaustive transition table test writes itself — for every (state, event) pair, either the transition is legal and returns the expected state, or it throws:
@Test
void everyStateHandlesEveryEvent() {
for (OrderState s : allStates()) {
for (OrderEvent e : allEvents()) {
// must either return a state or throw IllegalStateException — never silently ignore
assertDoesNotThrow(() -> transition(s, e), s + " + " + e);
}
}
}
The machine is deterministic and side-effect free, which makes it trivially testable — and a natural target for property-based testing, which can throw random event sequences at it and check that invariants hold after every prefix.
Spring StateMachine is a real framework with persistence, guards and actions. For a domain this size — six states, five event types — the hand-rolled version is less code, no dependencies, no XML or Java DSL configuration, and the entire behaviour is one function you can read top to bottom. Adopt a framework when the machine has dozens of states and needs persistence and distributed coordination. For an order lifecycle, the records-and-sealed-interfaces version is the right size, and the compiler does the guarding.
Get the domain model right and the hard parts — audit, replay, testing — get easier on their own. Work together on getting your domain model right the first time.