Why We Need State Machines
Published on May 23, 2026 · 5 min read
I first met state machines in a Theory of Computing module. They looked like academic diagrams. Years later, I keep finding the same idea inside production incidents: an order stuck between ””“submitted””” and ””“failed,””” a duplicate confirmation, or a retry that arrives after cancellation.
Any workflow with stages already has a state machine. The choice is whether its states and transitions are enforced in one model or scattered across booleans, timestamps, database columns, and assumptions.
Naming States Is Only The Start
Consider an order sent to an external fulfillment provider:
enum OrderState {
CREATED, SUBMITTING, SUBMITTED, FULFILLED, FAILED, CANCELLED
}This implementation looks reasonable:
void processOrder(String orderId) {
Order order = db.getOrder(orderId);
order.setState(SUBMITTING);
provider.submit(order);
order.setState(SUBMITTED);
db.save(order);
}It has two separate problems.
First, setState permits any move. Nothing prevents CANCELLED -> FULFILLED.
Second, there is a crash window. If provider.submit succeeds and the process dies before db.save, the provider has accepted work while the database still says the order was never submitted. Retrying can submit it twice.
A transition table solves the first problem. It does not solve the second by itself.
Events And Allowed Transitions
Name the events that cause change:
enum OrderEvent {
SUBMIT_REQUESTED,
REQUEST_PUBLISHED,
PROVIDER_ACCEPTED,
PROVIDER_REJECTED,
PROVIDER_FULFILLED,
PROVIDER_FAILED,
CANCEL_REQUESTED,
RETRY_REQUESTED
}| Current state | Event | Next state |
|---|---|---|
| CREATED | SUBMIT_REQUESTED | SUBMITTING |
| SUBMITTING | REQUEST_PUBLISHED | SUBMITTED |
| SUBMITTING | PROVIDER_REJECTED | FAILED |
| SUBMITTED | PROVIDER_FULFILLED | FULFILLED |
| SUBMITTED | PROVIDER_FAILED | FAILED |
| FAILED | RETRY_REQUESTED | SUBMITTING |
| CREATED | CANCEL_REQUESTED | CANCELLED |
A central transition function can reject undefined pairs. Guards can add conditions that do not belong in the table, such as ””“cancellation is allowed only before inventory is reserved.”””
The model should also define what happens to events that are:
- duplicates;
- late but harmless;
- late and contradictory;
- valid only from an earlier version;
- impossible according to the current rules.
””“Reject””” is not always enough. Some invalid events should be ignored idempotently; others need an alert or manual review.
Persist Transitions Atomically
Two workers can read the same state and both attempt a valid transition. The database write must prove that the state has not changed since it was read.
One option is optimistic concurrency:
UPDATE orders
SET state = 'SUBMITTED',
version = version + 1
WHERE id = :id
AND state = 'SUBMITTING'
AND version = :expected_version;Exactly one concurrent update should affect the expected row. A zero-row result means the caller must reload and decide whether the event is a duplicate, stale, or conflicting.
Row locks can provide another approach, but locks should cover short database work. Do not keep a transaction open while calling an external provider.
Publish With An Outbox
To connect a state transition with external messaging:
- start a local database transaction;
- validate and persist the transition;
- insert an outbox record describing the command or event;
- commit;
- let a separate publisher send the outbox record;
- mark publication progress and retry failures.
The state change and outbound intent now commit together. Network I/O happens outside the transaction.
This still does not create exactly-once delivery. The publisher can send a message and crash before marking it sent. Consumers need an idempotency key and durable deduplication or naturally idempotent state transitions.
Late And Duplicate Events
Suppose PROVIDER_FULFILLED arrives twice. The second delivery can be treated as an idempotent confirmation if it has the same provider operation ID and result.
Suppose it arrives after CANCELLED. That is not merely an invalid pair. It indicates disagreement with the provider. The system may need to record the event, stop automatic transitions, and open reconciliation.
The state machine needs a policy for external truth, not just a Java exception.
Recovery And Observability
Persist a transition history containing:
- aggregate ID and version;
- previous and next state;
- triggering event and idempotency key;
- timestamp;
- actor or external provider;
- reason and relevant metadata.
Monitor workflows that remain in non-terminal states beyond their expected duration. A reconciliation job can query the provider using the durable external operation ID and either apply a missing event or escalate.
This is how the model handles process crashes: not by assuming they do not happen, but by leaving enough durable evidence to resume.
Testing The Model
The transition table gives useful test cases:
Given SUBMITTED
When RETRY_REQUESTED
Then reject the transitionProduction behavior needs more:
- every allowed state/event pair reaches the expected state;
- every undefined pair follows its documented reject, ignore, or escalate policy;
- a duplicate event does not repeat side effects;
- two concurrent transitions cannot both win;
- a late callback follows the reconciliation policy;
- an outbox publication failure leaves retriable durable work;
- replaying an already published outbox record is safe;
- recovery can find and resolve an order stuck in
SUBMITTING.
Use a controllable clock for timeout behavior and a real database for concurrency and transaction tests. Mocks do not reproduce locking, constraints, or isolation.
Why This Matters With AI-Generated Code
A transition table and invariants reduce ambiguity for both humans and coding agents. They are a stronger prompt than ””“implement the order flow,””” but they are not a complete contract.
The specification must also include guards, concurrency behavior, side effects, idempotency, timeout policy, and recovery. Otherwise an agent can implement a correct pure transition function around an unreliable workflow.
AI does not remove the need for design. It increases the value of explicit design.
State machines do not remove complexity. They move important rules into a model that can be inspected, tested, and operated. For workflows involving retries, callbacks, approvals, or external providers, that visibility is usually worth the ceremony.