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.