StateGENERALSCALE-SPECIFICILLUSTRATIVE

Finding the State Machine

When a piece of state has a small set of named values, the values are half the model; the other half is which transitions are allowed and who causes them. Order CREATED → PAID → SHIPPED → DELIVERED, with CANCELLED and REFUNDED as exits, is discovered by asking "from here, what can happen?" until nothing new appears.

The moveWorked exampleNext questions

The situation, the reflex, and why it stalls

Every lesson starts where being stuck starts: someone has a problem, and the first move that comes to mind feels like progress.

The question

A status has a handful of values. How do you find the transitions between them, the ones that must not exist, and who is allowed to cause each — without a workflow engine?

The situation

Order status can be created, paid, shipped, delivered, cancelled or refunded. I listed the values in an enum and felt done. Then a refund came in for an order that had never been paid, the code happily set it to refunded, and I understood that a list of values is not a model of anything.

The reflex

Enumerate the values and let the code set them. Each handler knows what it is doing — the payment handler sets paid, the shipping handler sets shipped — so the transitions are implied by which handler runs. Writing them down feels like documentation of the obvious.

Why it stalls

The transitions are implied by handlers, so any handler can make any transition. The refund handler set refunded on an unpaid order because nothing told it that refunded is only reachable from paid.

What the reflex produces — and fails to produce
  • The transitions are implied by handlers, so any handler can make any transition. The refund handler set refunded on an unpaid order because nothing told it that refunded is only reachable from paid.
  • The question "from paid, what can happen?" is never asked, so the transitions nobody thought of — cancel after payment, which needs a refund — are discovered by a customer.
  • The enum lists values but not who causes each change, so the customer-facing cancel button and the admin cancel do the same thing, and later, when they must differ, the code has no place for the difference.
  • The reaction to the refund bug is to reach for a workflow engine, which would encode the transitions if anyone had discovered them. The tool arrives before the model.
ProblemUnderstandRequirementsConstraintsUnknownsDecompositionSmallest StepModelExperimentObserveDebugLearnIterate

The move

Precisely enough to apply it to a problem you have never seen — not a slogan.

  • Start from the initial state and ask "from here, what can happen, and what causes it?". Each answer is a transition: a from-state, an event, an actor, a to-state. Follow every answer to its to-state and ask again. Stop when every state has been asked and no new state or transition appears. The result is the machine; the drawing is optional.
  • Then ask the negative question of each pair of states: can we go from here to there? Most pairs are no, and the no is as much a part of the model as the yes. "Refunded from created: no, there is nothing to refund" is a sentence the code must be able to say.
  • Mark which states are terminal — nothing happens after delivered or refunded — and which are waiting on something outside the system: paid waits on the warehouse, created waits on the provider. A state waiting on outside is a state that can be stuck, and the machine should say what happens when it is.
  • Now the enum is the set of states, the transition table is the set of allowed (from, event, actor) → to entries, and every handler asks the table instead of setting the value. The machine is a function, and a workflow engine is one way to host that function when there are many machines and many people who need to change them.

The order machine, as discovered

The diagram is the result of asking "from here, what can happen?" until nothing new appeared. Edges are labelled with the event and its actor; the two terminal states are marked as ends. What is not drawn is as important as what is: there is no edge into REFUNDED from CREATED and no edge out of SHIPPED to CANCELLED, and the model says why.

Order lifecycle
provider confirmscustomer cancelswarehouse shipscustomer cancels; refund succeedscarrier confirmsadmin refundsCREATEDPAIDCANCELLEDSHIPPEDDELIVEREDREFUNDED
UserLLMAgentToolDataDecisionHumanGuardrail

The table the handlers consult

This is the machine as the code sees it. Every handler calls transition and none of them assigns the status. The rejected cases are the negatives from the discovery, and the function is where the sentence "there is nothing to refund" lives as behaviour rather than as a comment.

Why ladder

We need a workflow engine for orders.

  1. Why? Because the order status keeps getting set to values that make no sense.
  2. Why does that happen? Because each handler sets the status directly and nothing checks whether the change is allowed.
  3. Why is nothing checking? Because the allowed transitions were never written down anywhere the code could consult.
real requirement A single, consultable definition of which transitions are allowed, by whom, and a record of each transition taken.
simpler The table and the function above — a page of code the order module owns, with a test per allowed transition and a test per forbidden one.

the claim was right when There are many such machines, they change often, and the people changing them are not the engineers who own the code; or the machine needs timers, retries and human tasks as first-class steps. Then hosting the table in an engine is cheaper than growing the function into one.

One function decides
1ALLOWED = {
2 (CREATED, PaymentConfirmed, provider) -> PAID,
3 (CREATED, Cancel, customer) -> CANCELLED,
4 (PAID, Shipped, warehouse) -> SHIPPED,
5 (PAID, RefundSucceeded, system) -> REFUNDED,
6 (SHIPPED, Delivered, carrier) -> DELIVERED,
7 (DELIVERED, Refund, admin) -> REFUNDED,
8}
9
10transition(order, event, actor):
11 next = ALLOWED[(order.status, event, actor)]
12 if next is missing:
13 reject "no transition from " + order.status + " on " + event + " by " + actor
14 record(order, from: order.status, to: next, event, actor, at: now)
15 order.status = next

The customer's "cancel after paying" is not in the table as a transition to REFUNDED; it is a request that starts a refund, and only the refund succeeding moves the order. That distinction came from asking who causes the change.

Where a discovered machine still fails

A correct table does not make the states safe. The rows below are the failures that remain after the machine exists — most of them about the states that wait on something outside — and each one is a question the discovery should have raised.

After the machine is encoded
TriggerSymptomCauseResponse
Provider confirmation arrives twiceSecond transition rejected with an error, alarms fireRepeat delivery; the machine correctly refuses PAID → PAIDTreat "already in the target state from the same event" as a no-op, not an error; the machine needs an idempotent path for repeats
PAID for a week, never shippedCustomer complains; nothing in the system noticedA waiting state with no timeout transitionAdd an escalation event the system raises; a state that waits on outside needs a transition for "it never came"
Two handlers transition the same order at onceBoth succeed; the second overwrites the firstThe function read the status, then wrote it, with no lock or versionCompare-and-set on the status or a version column; the machine is only a machine if transitions are serialised (Invariants Under Concurrency)
Refund fails after cancel is requestedOrder stuck in PAID with a cancel nobody can seeThe request was not modelled as a stateAdd CANCEL_REQUESTED, or record the request separately; the machine must show what is in flight

How to do it

Most important first.

  • Write the initial state. Ask "what can happen from here, caused by whom?" and write each answer as from → event (actor) → to.
  • Repeat for every to-state until the set stops growing. Then ask each state whether it is terminal.
  • For each pair of states with no transition, write the sentence that explains why. If you cannot, you may have found a missing transition.
  • Mark states that wait on something outside — the provider, the warehouse, the customer — and decide what happens on a timeout in each (Treating External Systems as What They Are).
  • Encode the table as one function that every handler calls; the enum alone is not a model (State Machines).

Worked on a concrete problem

The move has to produce something. This is what it produced.

  • The order, from CREATED. What can happen? The customer pays (→ PAID), or cancels (→ CANCELLED), or the payment fails (stays CREATED, with a reason). From PAID: the warehouse ships (→ SHIPPED), or the customer cancels before shipping (→ REFUNDED, via a refund that must succeed), or nothing happens for too long (stuck — a state the machine must name). From SHIPPED: delivered (→ DELIVERED); a cancellation here is a return, which is a different workflow and is written down as out of scope. From DELIVERED: refund on complaint (→ REFUNDED, admin only). CANCELLED and REFUNDED: terminal. Six states, seven transitions, and every one has an actor.
  • The negatives. CREATED → REFUNDED: no, nothing was paid. SHIPPED → CANCELLED: no, the parcel has left; it is a return. DELIVERED → SHIPPED: no, time does not run backwards. REFUNDED → anything: no, terminal. Writing them exposed that "cancel" means three different things depending on the state — before payment it is free, after payment it needs a refund, after shipping it is not a cancel — and the enum had one word for all three.
  • The file-upload service, same move. UPLOADING → UPLOADED (client finishes) → SCANNING (system) → AVAILABLE or QUARANTINED (scanner) — and UPLOADING can go to ABANDONED on a timeout nobody on the client side will trigger. The machine found a system-caused transition, which is the kind the reflex never lists because no handler is written for it until someone asks.

How you know it worked

What now exists that did not before, and what question you can now ask.

  • Every state has been asked "what can happen from here?" and the set of states stopped growing.
  • Every transition has an actor, and at least one actor is the system itself or something outside it.
  • For every pair of states without a transition there is a sentence saying why, and writing those sentences changed the model at least once.
  • One function decides transitions and every handler calls it; setting the status directly is no longer possible.

The questions you can now ask

The field this whole domain exists for. After this lesson, these are the questions to put to an unfamiliar problem.

Next questions
  • ?From this state, what can happen, and who or what causes each thing?
  • ?Between these two states, is there a transition — and if not, what is the sentence that explains why?
  • ?Which states are waiting on something outside the system, and what happens when it never comes?
  • ?Where in the code is the single place that decides whether a transition is allowed?

What can go wrong

How the move itself fails
  • The machine grows to cover every conceivable situation — partial shipments, split refunds, holds — before any of them is a requirement. The move stops when the requirements stop, not when imagination does.
  • The machine is drawn and not encoded. The diagram is on the wiki and the handlers still set the column; the discovery produced a picture and no behaviour.
  • Actors are left off. The transition table says PAID → REFUNDED and does not say admin only, and the customer-facing cancel button performs it.
  • Stuck states are not named. PAID with no shipment for a week is treated as a normal PAID, and the machine has no transition for "escalate", so nothing does.
What the move costs
  • A transition table is stricter than setting a column, and strictness has a cost: the first legitimate transition nobody foresaw is rejected in production until the table is changed.
  • Naming stuck states means building the escalation for them, which is work that only pays when the warehouse actually loses an order.
  • The single transition function is a choke point every handler goes through, which is the point, and also the reason it becomes the most-edited file in the order module.
Misreads
  • "So I need a workflow engine." You need the table. An engine hosts many tables and lets non-engineers edit them; for one order machine with seven transitions it is a dependency wrapped around a function.
  • "The state machine is the diagram." The diagram is a view of the table. The model is the set of (from, event, actor) → to entries and the code that consults it; a diagram nobody encodes is documentation of an intention.
  • "Terminal states are the end of the story." REFUNDED is terminal for the order and the beginning of something for finance. A terminal state in one machine is often an initial state in another.

Where this applies

Problem-solving advice is stated as universal far more often than it is. These labels say what each method is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view.

  • GENERAL"From here, what can happen, caused by whom?" discovers the lifecycle of anything with named states: a pull request, a subscription, a job, a message.
  • SCALE-SPECIFICFor one machine owned by one team, a table and a function are the whole answer. When there are dozens of machines, or the people who change them are not engineers, a workflow engine earns its cost — and brings its own model of retries and timeouts that must then be learned.
  • ILLUSTRATIVEThe six-state order machine is a teaching shape; a real store separates payment and fulfilment into their own machines and adds returns, holds and partial shipments as the requirements arrive.

Where the depth lives

This domain asks the question and hands the answer off by name.