Many business objects have a lifecycle. An order may be created, approved, fulfilled, or cancelled. A support ticket may be open, assigned, resolved, or reopened. Problems begin when those lifecycle rules are represented only by a status field and scattered if statements.
As the system grows, different code paths can start disagreeing about which changes are legal. One handler allows a cancelled order to be approved, another silently ignores the request, and a third checks a different set of statuses. The status values are visible, but the rules connecting them are not.
A useful design technique is to model the lifecycle as explicit state transitions: define which states exist, which moves between them are valid, and what must happen when a move succeeds. This article develops that mental model and shows when it is worth using.
Think in states and allowed moves
A state describes the meaningful condition an object is currently in. A transition is a permitted change from one state to another.
Consider a simplified order workflow:
created -> approved -> fulfilled
| |
+---------> cancelledThis diagram says more than a list of four status values. It says that a created order can become approved or cancelled, an approved order can become fulfilled or cancelled, and the two final states have no outgoing transitions in this simplified workflow.
The important idea is that the arrows are part of the model. If the code stores only the current state, developers must reconstruct those arrows from conditionals spread across the application.
Explicit transitions put that knowledge in one place.
Start with the smallest useful rule
Suppose an order contains a mutable status and any caller can assign a new value:
order.status = "fulfilled"This assignment answers only one question: what value should the field contain afterward? It does not answer whether the change is legal.
Instead, expose an operation that represents the transition:
function fulfill(order):
if order.status != "approved":
return InvalidTransition
order.status = "fulfilled"
return SuccessThe example is intentionally small. Its purpose is not to prescribe a particular language or error representation. It demonstrates a boundary: callers request a meaningful operation, while the object or workflow component decides whether its current state permits that operation.
Now a direct jump from created to fulfilled is rejected by the same rule regardless of which caller tries it.
Make the transition table visible
When a lifecycle has several states, individual methods can still make the overall model difficult to see. A transition table provides a compact view.
| Current state | Operation | Next state |
|---|---|---|
| created | approve | approved |
| created | cancel | cancelled |
| approved | fulfill | fulfilled |
| approved | cancel | cancelled |
Anything absent from the table is invalid under this model. For example, cancelled + approve has no next state.
The table can remain documentation, become test data, or be represented directly in code. The implementation choice matters less than having one authoritative definition of the allowed moves.
A table is especially useful during review. A developer can ask whether a new transition changes the intended lifecycle instead of trying to infer that change from several unrelated conditionals.
Put invariants at the transition boundary
Real transitions usually depend on more than the current status. Approval might require a verified payment method. Fulfilment might require an assigned shipment. Cancellation might be forbidden after physical delivery has started.
These requirements are invariants: conditions that must hold for the operation to be valid.
Keep the checks close to the transition they protect:
function approve(order):
if order.status != "created":
return InvalidTransition
if not order.has_verified_payment:
return PaymentRequired
order.status = "approved"
return SuccessThis structure makes cause and effect explicit. The caller asks to approve. The transition checks the current lifecycle state and the approval-specific requirements. Only after those checks succeed does the state change.
Avoid relying on callers to perform the checks first. If correctness depends on every caller remembering the same preconditions, adding a new caller can accidentally create a new path around the rules.
Treat side effects as part of the operation, not the state definition
A transition often triggers other work. Approving an order might reserve inventory, record an audit entry, or publish an event.
It is tempting to define a state as “approved means the status field changed and every downstream action completed.” In a system where those actions cross process or network boundaries, that guarantee may be impossible to provide atomically.
Separate two questions:
- Was the lifecycle transition accepted and persisted?
- Were the resulting side effects completed?
If both changes happen inside one local transaction, they may be committed together. If a transition also calls remote systems, failures can occur after the local state changes. The design then needs an explicit reliability strategy such as retryable work, an outbox, or another application-specific coordination mechanism.
The state model should not hide that distinction. A clear transition model tells you what the local state means; operational mechanisms determine how external consequences eventually happen.
Decide where transition authority lives
There is no requirement that every stateful object implement its own state machine class. The useful property is controlled authority over lifecycle changes.
For a small domain object, methods such as approve() and cancel() may be enough. In a larger application, a workflow service may own transitions because the rules require several collaborators. Some systems use a table-driven state machine library when workflows are numerous or configurable.
Choose the simplest representation that keeps the rules centralized and testable.
A dedicated framework adds vocabulary and machinery. That cost is justified when it reduces more complexity than it introduces. Four states and five straightforward transitions often need only ordinary code plus a clear table.
Test transitions as rules, not implementation paths
A lifecycle model creates a natural testing strategy. Verify both allowed and forbidden moves.
For the simplified order, useful tests include:
created + approve -> approved
created + cancel -> cancelled
approved + fulfill -> fulfilled
approved + cancel -> cancelled
created + fulfill -> rejected
cancelled + approve -> rejected
fulfilled + cancel -> rejectedThe negative cases matter because they protect the boundaries of the model. Testing only successful paths proves that valid transitions work but says little about whether invalid states can be reached.
Also test transition-specific invariants. If approval requires verified payment, include a created order without verified payment and confirm that it remains created after the failed attempt.
That last detail is important: a rejected transition should not leave a partially changed object unless partial progress is an intentional, documented part of the model.
Watch for states that are really separate dimensions
A common modeling mistake is putting every condition into one status field.
Suppose an order can be both approved and payment_under_review. If payment review is independent of fulfilment progress, combining the dimensions can produce awkward states such as:
approved_payment_under_review
fulfilled_payment_under_review
cancelled_payment_under_reviewAdding another independent concern multiplies the combinations.
Before adding a new state, ask whether it describes the same lifecycle or a separate dimension. Independent concerns often deserve separate fields or separate collaborating objects, each with its own rules.
Conversely, do not split states that have no meaningful behavioural difference. If two labels permit exactly the same operations and carry the same meaning to the business, the distinction may be unnecessary.
Do not confuse workflow state with historical events
Current state answers “where is the object now?” History answers “how did it get here?”
An order whose current state is cancelled does not tell you whether it was cancelled before or after approval. If that distinction matters for auditing, analytics, customer support, or compensation, store appropriate history rather than trying to encode every past fact into the current status.
A transition record might contain the previous state, next state, time, and reason. The exact audit requirements depend on the system, but the conceptual separation is stable: current state is a summary of the present; history records past changes.
Recognize the failure modes
Explicit transitions improve clarity only when the design actually controls state changes.
The most common failure is leaving a public status setter alongside transition operations. Callers can then bypass the rules, so the state machine is advisory rather than authoritative.
Another failure is duplicating transition rules in controllers, user-interface code, background jobs, and domain code. Early validation near a caller can improve feedback, but the authoritative rule still belongs at the boundary that controls the state change.
A third failure is turning every boolean into a state machine. A feature flag that is simply enabled or disabled does not automatically need transition infrastructure. The technique earns its cost when legal changes, sequencing, or state-dependent behaviour are important enough to make invalid transitions a real engineering risk.
When explicit transitions are worth using
Explicit state transitions are a strong fit when an object has a meaningful lifecycle and not every state can legally follow every other state. They become more valuable when several callers can change the object, transitions have important preconditions, invalid sequences cause costly bugs, or the workflow changes often enough that developers need one place to understand it.
A plain field may be sufficient when all values can freely replace one another and there are no transition-specific rules. Do not add a state machine merely because a field has a small set of values.
The practical test is simple: if developers repeatedly ask “can this move from X to Y, and under what conditions?”, that relationship deserves an explicit home in the design.
Conclusion
A status value records where an object is. A lifecycle model also records where it is allowed to go.
Start by naming the meaningful states and drawing the valid transitions between them. Put preconditions at the boundary that performs each transition, reject invalid moves without partial mutation, and test forbidden paths as deliberately as successful ones. Keep independent state dimensions separate, and store history separately when past transitions matter.
The goal is not to introduce a formal state-machine framework. It is to make lifecycle rules visible, enforceable, and difficult to bypass.