When designing a State machine in Java, how do you handle illegal transitions and enforce invariants safely across states?
answer
- Make illegal states unrepresentable (immutable, invariants in constructor, closed set)
- Fail fast with a typed exception; no silent no-ops (except specified idempotent ops)
- Centralize the transition table; unit-test every (state, trigger) incl. illegal
- Validate-then-act; keep transitions atomic with side effects
- Safe publication of the current-state ref; AtomicReference CAS for lock-free transitions
basics
~20 sMake illegal moves impossible or loud: each state only implements the operations valid in it, and invalid operations throw a clear exception (like IllegalStateException) instead of silently doing the wrong thing. Keep states immutable and validate transitions in one place.
solid answer
~50 sA robust state machine treats illegal transitions as first-class. Each state implements only the operations legal in that state, and any illegal operation fails fast with a clear, typed exception (commonly IllegalStateException) rather than corrupting data or returning a misleading result — exactly how a closed JDBC Connection throws SQLException. Prefer making bad states unrepresentable: model states as immutable objects (enums, records, or a sealed interface) so a transition produces a new valid state rather than mutating a half-updated one, and centralize the allowed-transition rules so they aren't duplicated. Validate the trigger against the current state in one well-tested place, keep the transition atomic, and under concurrency publish the new current-state reference safely (volatile/AtomicReference/lock) so observers never see a torn state. Invariants that must hold in a given state belong in that state's constructor, so an invalid state can't be built. Finally, log and surface refused transitions for observability — silent no-ops hide bugs.
code
java · 22 lines// Immutable states + lock-free, legal-only transitions via AtomicReference CAS
sealed interface OrderState permits New, Paid, Shipped {}
record New() implements OrderState {}
record Paid(String paymentRef) implements OrderState {
Paid { // invariant enforced at construction
if (paymentRef == null || paymentRef.isBlank())
throw new IllegalArgumentException("paymentRef required for Paid");
}
}
record Shipped(String tracking) implements OrderState {}
final class Order {
private final AtomicReference<OrderState> state = new AtomicReference<>(new New());
void pay(String ref) {
OrderState cur = state.get();
if (!(cur instanceof New)) // fail fast on illegal transition
throw new IllegalStateException("Cannot pay from " + cur);
if (!state.compareAndSet(cur, new Paid(ref))) // atomic swap
throw new IllegalStateException("Concurrent transition; retry");
}
}go deeper
Knows that invalid operations on a wrong state should throw rather than do something unexpected.
Implements per-state method legality and throws IllegalStateException for illegal operations, with a single happy path.
Centralizes the transition table, tests illegal transitions, keeps states immutable, and handles basic thread-safety of the state reference.
Designs for unrepresentable-illegal-states, atomic transitions with side effects, lock-free CAS transitions, invariants-in-constructor, observability, and chooses table-driven vs. state-driven FSM deliberately.
## The core challenge A state machine is only useful if it **forbids the wrong things**. The hard part of design isn't the happy path (`New → Paid → Shipped`); it's deciding what happens when someone calls `ship()` on a `New` order, or `commit()` on a closed connection. Done poorly, illegal transitions corrupt data or fail silently far from the cause. Done well, they fail fast with a clear signal. ## Principle 1 — make illegal states unrepresentable The strongest defense is to design so an invalid state simply *can't exist*: - Model states as **immutable** values — enums, `record`s, or a `sealed interface`'s implementations. A transition returns a **new** valid state object instead of mutating fields one at a time (which can leave a half-updated, momentarily invalid object). - Put **invariants in the state's constructor**. If a `Paid` state requires a non-null payment reference, validate it in the constructor; then *holding* a `Paid` proves the invariant. You can't build an invalid one. - Use the **closed set** of a sealed interface / enum so the compiler forces you to handle every state in `switch` expressions — adding a state and forgetting a branch becomes a compile error, not a runtime surprise. ## Principle 2 — fail fast on illegal operations When an operation isn't valid in the current state: - **Throw a clear, typed exception.** `IllegalStateException` is the conventional Java signal for "method called at an inappropriate time/state." Domain code may use a custom checked or unchecked exception (`InvalidOrderTransitionException`) carrying the from-state, the attempted trigger, and *why*. - **Don't silently no-op.** A quiet no-op hides the caller's bug; the system drifts and you debug it later, far from the cause. (The deliberate exception is genuinely idempotent operations like `close()`, where a no-op is the *specified* contract.) - This is exactly JDBC's behavior: calling `createStatement()` on a closed `Connection` throws `SQLException` — loud and immediate. ## Principle 3 — centralize and test the transition table Scattering `if (state == X) allow else throw` across many methods re-creates the conditional sprawl State was meant to remove and lets rules drift. Instead: - Keep the **allowed-transition rules in one place** — either each state declares its legal successors (state-driven) or a single transition function/table validates `(currentState, trigger) → nextState` (table-driven). Table-driven FSMs make the whole machine auditable and easy to unit-test exhaustively. - **Unit-test every (state, trigger) pair**, including the illegal ones, asserting they throw. The set is finite, so full coverage is realistic. ## Principle 4 — keep transitions atomic and side-effect-safe A transition often has side effects (send email, write DB row). Two hazards: - **Atomicity:** decide the next state, perform side effects, and publish the new state as one logical unit. If a side effect can fail, either make it part of a transaction or move to an explicit error/compensation state rather than leaving the machine wedged between states. - **Ordering:** validate-then-act. Check the transition is legal *before* performing irreversible side effects. ## Principle 5 — concurrency and safe publication If multiple threads can trigger transitions or observe the current state: - The mutable **current-state reference** in the context must be published safely — `volatile`, an `AtomicReference` (enabling compare-and-set transitions), or a lock around read-modify-write. Otherwise a reader can see a stale or torn state. - Immutable state objects help enormously: the *objects* can't be corrupted, so only the single reference swap needs synchronization. A CAS on an `AtomicReference<State>` gives you a lock-free legal-transition check: read current, compute next, `compareAndSet`; retry if it changed. - Beware **check-then-act races**: "is it Open? then commit" can interleave with a concurrent close. Either hold a lock across the check+act or design the operation to fail atomically. ## Principle 6 — observability Log refused transitions (from-state, trigger) at an appropriate level, and consider metrics/alerts on illegal-transition rates — a spike usually means a caller is misusing the API or a race is firing. Silent refusal gives you nothing to debug. ## Putting it together (mental model) - States = immutable, invariant-checked-on-construction, closed set. - Operations legal-in-state succeed; illegal ones throw a typed, informative exception (never a silent no-op, except specified idempotent ops). - Transition rules live in one tested place. - Transitions are validate-then-act, atomic, and the current-state reference is published safely under concurrency. - Refusals are observable. This is the same discipline the JDBC driver applies to `Connection`: a fixed set of states, illegal operations throwing `SQLException`, and `close()` as the one specified idempotent no-op.
- Why is a silent no-op usually worse than throwing on an illegal transition?A no-op hides the caller's mistake: the program continues as if the operation happened, so the system drifts into an inconsistent state and the bug surfaces later, far from its cause, much harder to diagnose. Throwing fails fast at the point of misuse with full context. The legitimate exception is an operation whose contract is explicitly idempotent (e.g. close()), where doing nothing is the specified, expected behavior.
- How can you make a legal-transition check lock-free under concurrency?Model states as immutable objects and hold the current state in an AtomicReference. To transition: read the current state, verify the trigger is legal and compute the next state, then compareAndSet(current, next). If the CAS fails another thread changed the state, so re-read and retry (or fail). This gives an atomic check-and-swap without a lock, and immutable states mean only the reference, not the objects, needs synchronizing.
saying these in an interview costs you the question
- Handling illegal transitions with a silent no-op instead of failing fast.
- Mutating a state object's fields step by step, leaving it transiently invalid, instead of swapping to a new immutable state.
- Scattering transition rules across every method, recreating the conditional sprawl State removes.
- Ignoring concurrency — sharing a mutable current-state reference without safe publication or guarding check-then-act races.