skip to content

When would you replace polymorphic State-pattern classes with a data-driven finite state machine (a transition table or a state-machine library), and what do you gain and lose by doing so?

level: seniorimportance: should knowfreq 30%

answer

  1. classes scale with behavior; tables scale with transitions
  2. sparse 12×15 grid = 150 'not allowed' methods
  3. table = spec you can diagram and property-test
  4. lose exhaustiveness + typed payloads
  5. hybrid: data topology, object guards/actions

basics

~20 s

Switch to a table when there are many states and events and the rules matter more than the code: a table is one page you can read, diagram, and validate. You lose the ability to write rich, typed, per-state behavior easily.

solid answer

~50 s

State-as-classes shines when each state carries **substantial, distinct behavior** — different validation, different collaborators, different data — because the state class is a natural home for it and the compiler checks that every operation is implemented. It degrades when the machine is **large and sparse**: with 12 states × 15 events you get 180 method bodies, most of them "not allowed here", and no one can see the graph. A data-driven machine stores transitions as data — `(state, event, guard) → (nextState, action)` — so topology becomes reviewable, diffable, diagrammable, property-testable (reachability, dead ends, unhandled pairs), and in some systems editable without a deploy. Costs: behavior moves into loosely typed action handlers, the compiler stops proving exhaustiveness, debugging goes through a generic engine, and configurable machines need versioning so in-flight instances are not stranded by a definition change. A common hybrid keeps the table for topology and reuses ordinary polymorphic objects as the action/guard implementations.

go deeper

for a junior

Say a table lists transitions as data so you can see the whole machine at once, while classes put each state's behavior in its own file.

for a middle

Trade off number of states and events against richness of per-state behavior, and mention losing compile-time exhaustiveness when moving to data.

for a senior

Cover startup validation, property tests for reachability and dead ends, the hybrid of data topology with object guards and actions, and typed-payload loss.

for a principal

Add definition versioning for in-flight instances, ownership by non-engineers, and the escalation criteria to statecharts or durable workflow engines — hierarchical and parallel states, durable timers, compensation, exactly-once side effects.

## Two ways to build the same finite state machine A **finite state machine (FSM)** is a formal model: a finite set of states, a set of events (inputs), a transition relation mapping `(state, event)` to a next state, optional **guards** (predicates that must hold), and optional **actions** (side effects on transition, entry, or exit). Both approaches below implement the same model; they differ in whether transitions live in **code** or in **data**. **State pattern (behavior-as-types).** One class per state; each implements the event-handling operations. Dispatch is the language's virtual-method lookup. **Data-driven FSM (topology-as-data).** One generic engine plus a table: ``` [ {from: "Draft", on: "Submit", guard: hasBody, to: "InReview", action: notifyReviewers}, {from: "InReview", on: "Approve", guard: twoApprovals, to: "Published", action: publishFeed}, {from: "InReview", on: "Reject", to: "Draft"}, ... ] ``` The engine looks up the row, evaluates the guard, runs exit/action/entry, and assigns the new state. ## The forces that decide **1. Size and density.** Classes scale with *behavior per state*; tables scale with *number of transitions*. Three or four states with meaty, distinct behavior → classes. Ten-plus states where most `(state, event)` pairs are simply illegal → a table, because it lists only the legal rows instead of forcing you to write 150 rejecting method bodies. **2. Who needs to read it.** If a product owner, compliance reviewer, or support engineer must confirm "a refunded order can never ship", a table or generated diagram is a *specification*. Reconstructing the graph from ten classes is not a review anyone will actually perform. **3. Behavior richness.** If `Connecting` needs a socket, a backoff policy, and a deadline while `Open` needs a heartbeat scheduler, classes give you a typed home for each with constructor-injected collaborators. Squeezing that into stringly-typed action handlers is a downgrade. **4. Change cadence and authorship.** Rules changing weekly, or authored by non-engineers, or differing per tenant, push toward data — potentially data in a database, edited without a deploy. Rules that change quarterly and only by the owning team are fine in code, where they get code review and type checking for free. **5. Verification appetite.** Data lets you *prove* properties mechanically: all states reachable from the initial state; every non-terminal state has an outgoing transition; no transition targets an undefined state; no duplicate `(from, on, guard)` rows; every declared event is handled somewhere. With classes you can only test the paths you thought of. ## What you give up with data - **Compile-time exhaustiveness.** An interface method forces every state class to answer for every operation (or inherit a default). A table can silently omit a row; you need runtime validation at startup to recover that safety. - **Type safety of payloads.** Events usually become a generic `Event(name, payload)`. Guards and actions receive loosely typed context and cast. Some libraries recover this with generics/sealed types, at the cost of verbosity. - **Straightforward debugging.** Stack traces run through the engine, not through your domain; you need good transition logging to compensate. - **Locality.** Understanding "what happens in InReview" means grepping the table for rows plus opening the referenced action handlers, instead of opening one class. - **Versioning burden** (only if the table is truly configurable): an instance sitting in state `X` when someone deletes `X` from the definition is stranded. Real systems pin each running instance to a definition version and migrate deliberately — the same problem workflow engines like BPMN/Temporal-style systems solve explicitly. ## The hybrid most large systems land on Keep **topology as data** and **behavior as objects**: the table names guards and actions by key; a registry maps those keys to injected, unit-testable objects. You get the reviewable graph, the property tests, and the generated diagram, while behavior stays typed and testable. Validate the table at startup (every referenced state, guard, and action key resolves) so a typo fails fast rather than at 3 a.m. ## Knowing when to stop hand-rolling Escalate from your own engine to an existing state-machine or workflow engine when you start needing: **hierarchical states** (nested substates sharing a parent's transitions), **orthogonal/parallel regions** (two independent sub-machines in one entity), **durable timers** ("if unpaid after 7 days, cancel"), **compensation** for failed multi-step side effects, or **history states**. Those are exactly the features of statecharts (Harel) and durable workflow engines, and reimplementing them correctly — especially durable timers and exactly-once side effects — is a project, not a class. ## Rule of thumb Small machine, rich behavior, engineers-only ownership → State classes. Large sparse machine, thin actions, cross-functional review, appetite for verification → data-driven. Both are FSMs; the question is only whether the transition relation should be *code the compiler checks* or *data you can validate, diagram, and change safely*.

  • How do you recover compile-time safety after moving transitions into data?
    Validate the table at startup: every from/to state exists, every guard and action key resolves in the registry, no duplicate (from, event, guard) rows, no unreachable states, no non-terminal dead ends. Run the same checks as unit tests so a bad edit fails in CI, not in production.
  • When should you stop hand-rolling and adopt a state-machine or workflow engine?
    When you need hierarchical or parallel states, durable timers that survive restarts ('cancel if unpaid after 7 days'), compensation for partially applied side effects, or history states. Those are statechart and durable-workflow features that are genuinely hard to implement correctly, especially exactly-once side effects across crashes.
  • If transition rules are stored in a database and edited at runtime, what breaks for entities already in flight?
    An instance can be stranded in a state that no longer exists, or take a transition its authors never validated. Pin each instance to the definition version it started under, keep old versions readable, and migrate instances explicitly when retiring a version.

Writing each state as a class is like giving every airport its own hand-written rulebook for where flights may go. A transition table is the route map: one sheet that shows every legal connection at a glance — invaluable once you have fifty airports, overkill when you have three.

saying these in an interview costs you the question

  • 'Tables are always better because they are data' — data loses compile-time exhaustiveness and typed payloads; small machines with rich behavior are clearer as classes.
  • 'The State pattern does not scale' — it scales with behavior per state; what it scales badly with is a large sparse event grid.
  • Storing transitions in a database without versioning, stranding in-flight instances when a state is renamed or removed.
  • Hand-rolling hierarchical states, parallel regions, and durable timers instead of adopting a statechart or workflow engine.
  • Assuming a diagram generated from the table is proof of correctness — guards and action side effects still need their own tests.

context