skip to content

In a State-pattern design, transitions can be decided inside the concrete state classes or centrally by the context. What are the trade-offs of each placement, and how do you keep the overall transition graph reviewable?

level: middleimportance: should knowfreq 42%

answer

  1. states name successors = cohesive but invisible graph
  2. table = one diffable, testable artifact
  3. states return event/outcome, table returns destination
  4. single non-public transitionTo
  5. hooks + audit + locking hang off that one method

basics

~20 s

If states set the next state, each state is self-contained but the whole map is scattered across classes. If the context decides, the map lives in one readable place but the context grows. Either way, funnel every change through one transition method.

solid answer

~50 s

**States decide** (`context.setState(Published)` inside `InReview.publish()`): maximal cohesion — everything about being in-review sits in one class — but each state now couples to its successors, and no single artifact shows the whole graph, so validating reachability or spotting an unreachable state means reading everything. **Context decides**: states return a decision (a next-state token, an outcome object, or an event) and the context maps it via a table or a `when`/`switch`; the graph is one readable artifact you can diff, unit test, and even render as a diagram, and states stay mutually ignorant — but the context accumulates knowledge and can drift back toward a big switch. A middle ground works well: states return an *event/outcome*, never a concrete successor, and a declarative table maps `(state, event) → nextState`. Regardless of placement, expose exactly one non-public `transitionTo(next)` on the context so entry/exit hooks, invariant checks, logging, and audit events fire on every transition without exception.

code

pseudocode · 17 lines
pseudocode
// states judge legality, a table owns topology
TRANSITIONS = {
  (Draft,     Submit) : InReview,
  (InReview,  Approve): Published,
  (InReview,  Reject) : Draft,
  (Published, Archive): Archived,
}

dispatch(event):
  outcome = state.handle(event, this)          // no successor named here
  if outcome.rejected: return outcome
  next = TRANSITIONS[(state, event)] ?: throw UndeclaredTransition
  transitionTo(next)

private transitionTo(next):                    // the ONLY place state changes
  state.onExit(this); prev = state; state = next; next.onEnter(this)
  audit(prev, next, event, now())

go deeper

for a junior

Say transitions can live in the states or in the context, and that all changes should go through one method on the context so nothing is skipped.

for a middle

Compare cohesion versus graph visibility, describe the table-driven middle ground, and name entry/exit hooks and auditing as reasons to centralize the swap.

for a senior

Add guards, self-transition semantics, undeclared-pair policy, idempotency for redelivered events, and concurrency control via locking or optimistic versioning.

for a principal

Treat the table as a reviewable specification: property tests for reachability and dead ends, generated diagrams, per-transition authorization and audit, and the point where a hand-rolled machine should become a workflow engine.

## Two placements, honestly compared ### A. Decentralized — each state names its successors ``` InReview implements DocState { approve(doc) { doc.transitionTo(Published) } reject(doc) { doc.transitionTo(Draft) } edit(doc, t) { reject("locked") } } ``` **Pros** - Highest **cohesion**: every rule about being in review — what is allowed, what is refused, where you go next — is in one file. Onboarding a reader to one state is one file. - Adding a state is purely additive if it is only *entered* from a new event: write the class, wire the one call site. - No central object grows without bound. **Cons** - **Coupling**: `InReview` now references `Published` and `Draft`. Add a `PendingLegalReview` step between them and you edit existing, working classes — the very thing the pattern was supposed to avoid. - **The graph is invisible.** Questions any reviewer asks — is every state reachable? is any state a dead end? can a cancelled order become active again? — require reading N classes and reconstructing the graph in your head. There is no artifact to review or test. - Cycles of mutual references between state classes; in some codebases this also creates awkward initialization order. ### B. Centralized — the context (or a table) resolves the next state States return a **decision** rather than performing the move: ``` interface DocState { handle(event, doc) -> Outcome } // Outcome = Accepted(event) | Rejected(reason) TRANSITIONS = { (Draft, Submit) -> InReview, (InReview, Approve) -> Published, (InReview, Reject) -> Draft, (Published, Archive)-> Archived, } Document.dispatch(event) { outcome = state.handle(event, this) if outcome is Accepted: next = TRANSITIONS[(state, event)] ?: error("undeclared transition") transitionTo(next) } ``` **Pros** - **One reviewable artifact.** The table is data: diffable in a pull request, printable as a diagram, testable exhaustively (assert reachability, assert no dead ends, assert every `(state, event)` pair is either declared or explicitly rejected). - States are mutually **ignorant** — genuinely interchangeable units, easier to unit test. - Cross-cutting policy (auditing, metrics per transition, permission checks per transition) attaches naturally at one place. **Cons** - The context gains responsibility and can degenerate into the switch statement you removed if transition logic acquires conditions. - **Guards** — transitions valid only when a predicate holds (`Approve` only if two reviewers signed off) — do not fit a plain map; you need `(state, event) → [guard, nextState]` entries or a small rules structure, which is a step toward reimplementing a state-machine library. - Indirection: a reader must consult two places (the state class for behavior, the table for topology). ## The pragmatic middle Most mature codebases converge on: **states decide the *outcome*, a declarative table decides the *destination***. States never name a concrete successor class; they answer "is this event legal now, and what side-effect-free result does it produce?" That preserves cohesion of behavior while keeping topology as reviewable data. ## The non-negotiable: one `transitionTo` Whichever placement you choose, every state change must flow through a single method on the context: ``` private transitionTo(next) { if (!TRANSITIONS.contains(state -> next)) error(...) // optional defensive check state.onExit(this) previous = state; state = next next.onEnter(this) record(TransitionOccurred(previous, next, clock.now())) } ``` Reasons this matters more than the placement debate: - **Entry/exit hooks** run reliably. If any code path assigns `state = X` directly, a timer eventually leaks or a lock is never released. - **Invariants** get one checkpoint (`Published` requires a non-empty body; assert it once, not in five call sites). - **Observability**: one place emits the audit record, the metric, the domain event. Transition history is usually a product requirement ("who moved this to Approved and when?"), and it is essentially free here. - **Concurrency**: one place to take the lock or apply the compare-and-set, which is what makes concurrent events safe (see below). Keep the setter **non-public** (package-private/internal). A public `setState` lets any caller teleport an object past guards and entry actions, defeating the pattern's main safety property. ## Related decisions that ride along - **Undeclared `(state, event)` pairs.** Choose one policy: throw, no-op, or return a rejection. In event-driven systems that may redeliver messages, no-op-when-already-in-the-target-state is the idempotency trick that saves you. - **Self-transitions.** Does `Active --renew--> Active` re-run `onExit`/`onEnter`? Decide explicitly; both conventions exist and silent divergence causes bugs (a timer restarted, or not). - **Concurrency.** Two events arriving simultaneously can both read `InReview` and both transition. Guard with a lock, an optimistic-locking version column, or by serializing events per entity (single-threaded actor / per-key partition). - **Testing.** With a table you can write property-style tests: every state reachable from the initial state, terminal states have no outgoing entries, no event is silently unhandled. With decentralized transitions you can only test paths you thought to write.

  • Where do guard conditions belong — transitions valid only when a predicate holds?
    Either as a predicate attached to the table entry, `(state, event) -> [guard, next]`, or as a check inside the state's handler that returns a rejection. Keep guards side-effect free and evaluated before any transition begins, so a failed guard leaves the object untouched and no entry/exit hook has fired.
  • Two events for the same entity arrive concurrently. What breaks and how do you fix it?
    Both threads read the same current state and both transition, so one transition is lost or an illegal sequence executes. Fix by serializing per entity: a lock around dispatch, an optimistic-locking version column that fails the second writer, or routing all events for a key to one consumer/actor.
  • How do you test the transition graph itself rather than individual behaviors?
    With a declarative table you can assert properties: every state is reachable from the initial state, terminal states have no outgoing entries, every (state, event) pair is either declared or explicitly documented as rejected, and no transition targets a removed state. Those tests are impossible when successors are scattered across classes.

Decentralized transitions are like each room in a building having its own hand-written 'exit that way' sign — fine locally, but nobody can see the floor plan. A central table is the floor plan posted at the entrance: one page to review, easy to check that every room has a way out and no room is walled off.

saying these in an interview costs you the question

  • Exposing a public setState, which lets callers bypass guards, entry actions, and auditing.
  • Assigning the state field directly in several places, so entry/exit hooks and audit records silently do not run on some paths.
  • Letting the central table grow conditionals until it is the switch statement the pattern was meant to eliminate.
  • Ignoring concurrent events — reading the current state and writing the next without a lock or version check loses transitions.
  • Leaving undeclared (state, event) pairs undefined, so behavior depends on which branch happens to fall through.

context