Transition Outcomes
Every push returns one of exactly three sealed cases. Because the failure modes are types, your orchestration logic becomes a total switch/when over the three cases — and the compiler reminds you to handle each.
sealed class Transition<StateT, EventT, ActionT> {
data class Valid(fromState, event, toState, action) // the turnstile opened
data class Invalid(fromState, event) // not a legal move from here
data class NoFromState(event) // no definition for this state
val isValid get() = this is Valid
}
| Case | Means | When you get it |
|---|---|---|
|
The turnstile opened. |
The event was a legal move from the current state. Carries |
|
Not a legal move from here. |
The current state has a definition, but no edge for this event. The state is left untouched. |
|
No definition for this state at all. |
The machine has no |
Consuming the outcome
In Java, pattern-match the sealed type — an exhaustive switch needs no default:
var transition = orderMachine.transition(new Checkout(4999, "1 Market St"));
switch (transition) {
case Transition.Valid<OrderState, OrderEvent, OrderAction> v -> {
var nextState = v.getToState();
perform(v.getAction()); // do the real-world work (see Handling Actions)
}
case Transition.Invalid<OrderState, OrderEvent> i ->
log.warn("Illegal move: {} from {}", i.getEvent(), i.getFromState());
case Transition.NoFromState<OrderEvent> n ->
log.error("No state definition for the current position");
}
A quick isValid() check is enough when you only care whether the move happened:
var bad = orderMachine.transition(new Cancel()); // from Cart, Cancel isn't a legal move
bad.isValid(); // false — Transition.Invalid
Add a new state or action variant later, and the compiler flags every switch that forgot it. That is the whole point of modelling failure as a type rather than a null or a boolean.
|