Make Product State Explicit
Most product code already has state machines.
The only question is whether the state machine is explicit or hidden in random
booleans and scattered if statements.
Examples:
invite: pending -> accepted -> expired -> revoked
booking: requested -> confirmed -> cancelled
subscription: trialing -> active -> past_due -> cancelled
order: draft -> submitted -> paid -> fulfilled -> refunded
None of that needs a fancy state machine library. Usually it just needs a clear status field, named transitions, and tests around the allowed moves.
Hidden state is where bugs start
This is hard to reason about:
IsPaid = true
IsCancelled = false
IsRefunded = false
IsFulfilled = true
Which states are valid?
Can something be fulfilled and refunded?
Can it be cancelled after fulfillment?
Can a retry set one flag twice?
Booleans are fine for independent facts. They are bad when the values describe one workflow.
Use a status when the object is really in one state at a time:
public enum BookingStatus
{
Requested,
Confirmed,
Cancelled
}
Put rules in transitions
The useful part is not the enum. The useful part is controlling how state changes.
public void Confirm()
{
if (Status != BookingStatus.Requested)
{
throw new InvalidOperationException("Only requested bookings can be confirmed.");
}
Status = BookingStatus.Confirmed;
ConfirmedAt = DateTimeOffset.UtcNow;
}
Now the rule has one obvious home.
The rest of the code should not set Status directly from controllers,
workers, callbacks, or UI handlers. Those entry points should ask the domain or
application code to perform a transition.
Store important transition times
For many workflows, the timestamp matters as much as the state.
confirmed at
cancelled at
paid at
refunded at
expired at
Those fields help with support, reporting, retries, and debugging. They also make transitions easier to audit.
Do not try to infer everything from UpdatedAt. If the transition matters,
store it directly.
State machines and concurrency
State machines make valid transitions clear. Concurrency control makes them safe when two requests race.
Example:
request A confirms booking
request B cancels booking
both read status = Requested
both try to save a different transition
The transition method is not enough by itself. You still need the database to protect the write.
For a normal same-row lifecycle transition, that usually means optimistic concurrency or a conditional update against the expected current status.
update booking
set status = Confirmed
where id = @id
and status = Requested
Then treat the affected row count as part of the transition:
rows affected = 1 -> transition succeeded
rows affected = 0 -> reject as stale; someone else already moved the workflow
I cover the lower-level concurrency mechanics in Common Concurrency Patterns in .NET Applications.
The state machine says what should be allowed. The database ensures two requests cannot quietly break it.
Version the product state
Concurrency tokens should match the product state, not just the table layout.
Sometimes a child row is part of the thing the user is editing. An order line changed before submit may make the order stale, even though it lives in a different table.
Other rows are only related data. A support comment or tracking event may need its own version instead of changing the order version.
The useful question is:
would this change make the user's current view stale?
If yes, protect it as the same conflict boundary. If no, keep the concurrency check closer to the thing that changed.
Test transitions
Tests should cover the allowed and rejected moves.
Examples:
requested booking can be confirmed
confirmed booking can be cancelled
cancelled booking cannot be confirmed
expired invite cannot be accepted
submitted order cannot be edited without reopening it
These tests are usually more valuable than testing every setter or handler. They document the workflow.
Rule of thumb
Use a state machine when an object has a lifecycle.
Keep it simple:
- one status field for the main lifecycle
- named methods for transitions
- timestamps for important transitions
- database constraints for rules that must always hold
- row versions around the state the user is really editing
Most product bugs happen in ordinary state transitions. Make those transitions obvious.