Lesson 0054 · System Architect · Module 2
Design State as a Machine, Not a Column
"Status" is not a field — it is a state machine with legal transitions, actors, and rules. Design the machine before you name the column.
Mission tie-in: state is the most permanent thing you design. A lead makes transitions legal, owned, and recoverable where possible before a schema makes them accidental.
Knowledge: the transition table is the contract
Beginners ask "what columns do I need?" Designers ask what is the state machine? Take the return journey's decision from lesson 0052. As a column it invites return.status = "approved" from anywhere. As a machine it demands an answer for every arrow:
| From | Command | To | Actor | Rule |
|---|---|---|---|---|
| — | assess | accepted / refused | Policy engine | Window and evidence rules; one decision per return. |
| refused | appeal | under review | Customer, via support | One appeal per refusal; new evidence required. |
| under review | uphold | refused | Policy owner | Decision must record who upheld and why. |
| under review | overturn | accepted | Policy owner | Override recorded with authority and reason. |
Transitions are where invariants live. Every rule your business has about behaviour is a statement about a change — who may make it, what must already be true, what becomes true afterwards. A status column has nowhere to put such a rule, so the rules migrate to whichever call site happens to perform the assignment. There is no compiler check that they all agree, and they do not stay agreed: each new caller re-derives the rules from whatever the previous caller did.
There is a counting argument underneath it. With n states, an assignable column admits n × (n − 1) transitions; a real workflow usually has six or eight legal ones. Modelling the machine is how you go from “everything is possible, please be careful” to “these eight are possible, everything else raises”. That is the same compression as making invalid states unrepresentable (lesson 0011), applied to change rather than to shape.
It stops being worth it in two places. When an entity has two states, one transition, and one actor, the machine is a boolean and the table is ceremony. And when the “status” is derived rather than decided — computable at any moment from other facts — it is not state at all, and storing it creates a second truth that will disagree with the first. Compute it instead.
Four questions turn this table into design:
- Who may cause each transition? The actor column. "Anyone with database access" is not an actor.
- What must be true? The rule column — the invariants that guard each arrow (lesson 0055).
- How do we recover? A true reversal restores the prior business state as though the transition had not happened. An appeal is instead a new forward transition; compensation creates another fact that offsets an irreversible effect. If a transition has no safe recovery path, make that consequence explicit before permitting it.
- Do we need the history? If auditors, customers, or debugging need the path taken, the current state alone is insufficient — you need a log of transitions.
The fourth question is the fork between current state and historical facts. Most systems keep the current state and overwrite it; the past is gone. If the business needs the past — "why did this end refused?" — start cheap: record an audit trail of transitions beside the current state, or keep the full transition history as part of the model. If the need grows to full replay and time travel, a dedicated event store — event sourcing — is a later, heavier option with real costs: replay, read models, event-schema evolution, tooling. It is earned by a genuine need, not by fashion, and it is never the first choice for "we should keep history."
# A state machine as guards, not as assignments. "status" never appears
# as a settable field; transitions are the only way to change it.
from dataclasses import dataclass, field
from enum import Enum
class Decision(Enum):
ACCEPTED = "accepted"
REFUSED = "refused"
UNDER_REVIEW = "under review"
@dataclass
class Eligibility:
decision: Decision | None = field(default=None) # the machine's current state
def assess(self) -> None:
if self.decision is not None:
raise ValueError("already decided")
self.decision = Decision.ACCEPTED # policy rule elided
def appeal(self) -> None:
if self.decision is not Decision.REFUSED:
raise ValueError("only a refusal can be appealed")
self.decision = Decision.UNDER_REVIEW
def uphold(self) -> None:
if self.decision is not Decision.UNDER_REVIEW:
raise ValueError("nothing under review")
self.decision = Decision.REFUSED
def overturn(self) -> None:
if self.decision is not Decision.UNDER_REVIEW:
raise ValueError("nothing under review")
self.decision = Decision.ACCEPTED
Worked: the double refund, and what the table would have prevented
In the pilot's third week, four customers were refunded twice. The total was small — €312 — and the fix took an afternoon. The design lesson is in what the architect looked at first, which was not the settlement job that issued the second payment.
They looked at the write sites. grep for assignments to the return's status field returned seven, spread across three modules: the customer portal, the support tool's “reopen” button, the carrier webhook handler, the settlement job, an admin script, and two test fixtures. The support tool's reopen set the status back to pending — reasonable in isolation, since an agent wanted to re-examine a case. It did so on returns that were already refunded, which put them back into the settlement job's query the same night.
The tempting fix is a guard inside the settlement job: skip anything already refunded. The architect rejected it for two reasons. It repairs one of seven paths, and the eighth path, added next quarter by someone who never reads this incident report, will be wrong again. And it places the rule in the module that reads the fact rather than the one that owns it — the placement error lesson 0055 is about. The fix that holds is structural: one guarded entry point, and a transition table that says in one place what an agent's reopen actually means.
# The return journey (lesson 0045) as an explicit table, not an assignment.
from dataclasses import dataclass, field
from enum import Enum
class State(Enum):
REQUESTED = "requested"
APPROVED = "approved"
REFUSED = "refused"
UNDER_REVIEW = "under review"
READY_TO_SHIP = "ready to ship"
RECEIVED = "received"
REFUNDED = "refunded"
# (current state, command) -> (next state, the one actor allowed to cause it)
TRANSITIONS: dict[tuple[State, str], tuple[State, str]] = {
(State.REQUESTED, "approve"): (State.APPROVED, "eligibility"),
(State.REQUESTED, "refuse"): (State.REFUSED, "eligibility"),
(State.REFUSED, "appeal"): (State.UNDER_REVIEW, "support"),
(State.UNDER_REVIEW, "uphold"): (State.REFUSED, "policy_owner"),
(State.UNDER_REVIEW, "overturn"): (State.APPROVED, "policy_owner"),
(State.APPROVED, "attach_label"): (State.READY_TO_SHIP, "journey"),
(State.READY_TO_SHIP, "record_receipt"): (State.RECEIVED, "warehouse"),
(State.RECEIVED, "settle"): (State.REFUNDED, "payments"),
}
class IllegalTransition(Exception):
"""The refusal a status column cannot make."""
@dataclass
class ReturnJourney:
return_id: str
state: State = State.REQUESTED
history: list[tuple[State, str, State, str]] = field(default_factory=list)
def apply(self, command: str, actor: str) -> State:
try:
to_state, permitted = TRANSITIONS[(self.state, command)]
except KeyError:
raise IllegalTransition(
f"{command} is not legal from {self.state.value}") from None
if actor != permitted:
raise IllegalTransition(f"{actor} may not {command}")
self.history.append((self.state, command, to_state, actor))
self.state = to_state
return self.state
# The incident, expressed as a test that now fails at the boundary.
journey = ReturnJourney("r-4711", state=State.REFUNDED)
try:
journey.apply("appeal", "support") # the support tool's "reopen"
except IllegalTransition:
pass # refused here, not paid twice at 02:00
else: # pragma: no cover
raise AssertionError("a refunded return must not re-enter the queue")
Writing the table forced the real question into the open, which is what the reopen button was for. It was not a way back to pending; it was two different business intents wearing one label — “the customer has new evidence” (an appeal, legal only from refused) and “we refunded the wrong amount” (a correction against a settled payment, which is compensation, not reversal). Neither is a state assignment. Both got their own command, their own actor, and their own row.
Twelve months on, the value is not the four avoided refunds. It is that the transition history — four rows for a clean return, more when it is appealed — answers the questions that arrive later without a project: which refusals were overturned and by whom, how long the appeal path takes at the 95th percentile, and how many returns reached ready to ship and never came back. None of that was a requirement when the table was written. It came for free with recording the arrows instead of the destinations.
| Question | Bad default | Designed answer |
|---|---|---|
| Who changes state? | Any code with the object. | One actor per transition. |
| How do we recover? | Call every later action rollback. | Name reversal, compensation, or forward recovery. |
| Do we keep the past? | Overwrite silently. | Log or event stream, by need. |
| What is the source of truth? | Whichever write came last. | One owner per state machine. |
- “A state machine means adopting a workflow engine.”
- It means a dictionary of legal transitions and one method that consults it. Reach for an engine when non-engineers must author the workflow or when transitions are long-running and need durable timers — not to get guards, which cost thirty lines.
- “Keeping history means event sourcing.”
- An append-only table of transitions gives you the audit trail, the timings, and the “who did this” answer at a fraction of the cost. Event sourcing is a different commitment: the events become the source of truth, and you take on read models, event versioning, and replay tooling with it.
- “The UI already prevents illegal transitions.”
- The UI is one writer. Admin scripts, background jobs, webhooks, migrations, and next year's integration are the others, and none of them will open your React code first. Guards belong where the state lives.
- “Every transition should be reversible.”
- Most interesting ones are not: money moved, a label was printed, an email arrived. Naming the irreversible arrows is a design output, not a failure — it tells you which transitions need confirmation, an idempotency key, or a delay before their side effect fires.
When this is the wrong move. A two-state flag with one actor does not need a table — is_archived is a fine design, and wrapping it in a machine is noise. Neither does a status you can derive: if “overdue” is just due_date < today, computing it is correct and storing it creates a second truth to keep in sync. Nor should you build a guarded machine around state you do not own: the carrier's tracking status is mirrored from their system, and your job is to translate and store what they tell you, not to adjudicate whether their transition was legal. Guard the states whose rules are yours.
Skill: read the machine from the business
A status column becomes a state machine when…
A compensating transition means…
The actor column in the transition table answers…
Practice: draw one entity's machine
Take a core entity from your model, or continue the supplied eligibility-decision case. Draw its state machine as a table: from, command, to, actor, rule. For every transition mark its side effects, audit need, and recovery as reversal, compensation, forward recovery, or deliberately none. Then choose between current state, an audit trail, and event sourcing, with one sentence of evidence for the choice.
In the lab and the capstone: the companion lab's phase-5 checkpoint hands you a returns-resolution service where the status is a plain field several call sites write. Find every write site before you change anything, then introduce the transition table and watch which existing tests start failing — the failures are the illegal transitions the code was quietly performing. The worked capstone records the finished machine and its rules in its state and contracts artifact.
Reveal: the audit test
Ask: "Three months from now, a customer asks why their request ended refused. Is the current state enough to answer?" If the answer needs the path — who, when, on what evidence — you need history. If the answer is "because it is refused," current state suffices.
- Explain to a senior engineer, in two sentences, why a transition table is a stronger guarantee than a validation check in every caller.
- Take one entity you own this week: count the places that assign its status, and say which of them would be illegal under the table you would write.
- Predict the damage: if a status column stays assignable for a year, name the class of production incident it produces and how you would discover it.
Your win
You can design state as a guarded machine with named actors and rules, distinguish reversal from compensation and forward recovery, and keep history as cheaply as the real need allows.
Read and watch deeper
- Applying UML and Patterns, Craig Larman — ch. 29: state machine diagrams — transitions, guards, events, and state-dependent behavior.
- Learning Domain-Driven Design, Khononov — ch. 6–7 (optional deep-dive: complex business logic and modeling the dimension of time).
- Designing Data-Intensive Applications, Kleppmann — ch. 3: facts over state and event streams as data (advanced).
- Domain Modeling Made Functional, Scott Wlaschin — ch. 4–5 and 9: making illegal states unrepresentable by giving each state its own type, so a transition is the only way between them.
- Related lesson: 0045 for the contract side of state; glossary state machine, event sourcing.
Ask your agent-teacher to generate every illegal transition sequence for your machine — reorder, duplicate, skip-a-state — and check your guards against each one.