Skip to content
← All insights

Model the state machine, not the screens

9 min readOptima

There is a particular way workflow software goes wrong, and it is almost always the same way. Somebody looks at the spreadsheet the process currently lives in, builds forms that mirror its columns, and ships. Six months later the system has fourteen boolean columns, nobody can say what happens when three of them are true at once, and the reporting is subtly wrong in a way nobody can locate.

The spreadsheet was not the process. It was one representation of it, and not a particularly good one.

What the flags cost you

Boolean columns accumulate one feature at a time, and each one looks reasonable on its own. is_submitted. Then approvals arrive, so is_approved. Then rejections, so is_rejected. Then payment, so is_paid.

Four booleans describe sixteen combinations. Perhaps five are states the business actually has a name for. The other eleven are not prevented by anything.

Boolean flags compared with an explicit state machineOn the left, four boolean columns (submitted, approved, rejected, paid) describe sixteen possible combinations, of which most are meaningless, including approved and rejected being true at the same time. On the right, the same process modelled as five explicit states: draft, submitted, approved and paid in sequence, with rejected branching from submitted. Only the transitions drawn are possible.FOUR BOOLEANSis_submittedis_approvedis_rejectedis_paid16 combinations, 11 meaninglessis_approved AND is_rejectednothing prevents thisFIVE STATESDraftSubmittedApprovedPaidRejectedsubmitapprovepayrejectApproved and Rejected cannot both hold. There is no transition between them.
The flags on the left do not prevent anything. The states on the right make the illegal combinations unrepresentable, which is the whole point: the model, not a validation rule, is what stops them.

The problem is not that somebody might deliberately set both is_approved and is_rejected. It is that nothing stops it. A retried request, a half-finished migration, two people acting at once, or one missed else branch, and the row now describes something that cannot happen in the real world. Every query that touches it has to guess what it means, and different queries will guess differently.

States are a closed set

The alternative is to name the states, name the transitions between them, and make everything else unrepresentable.

CREATE TABLE requests (
    id           BIGINT       IDENTITY PRIMARY KEY,
    state        VARCHAR(20)  NOT NULL,
    -- one column, one truth
    CONSTRAINT ck_requests_state CHECK
        (state IN ('draft','submitted','approved','rejected','paid'))
);

That constraint is doing real work. There is no combination of writes that produces a request which is both approved and rejected, because the column cannot hold both. You have not written a validation rule that someone can forget to call. You have made the bad state impossible to express.

Transitions carry the rules

The states are only half of it. What makes a workflow a workflow is which moves between them are legal, who is allowed to make them, and what has to be true first.

private static readonly Dictionary<(string From, string Action), Transition> Allowed = new()
{
    [("draft",     "submit")]  = new("submitted", Role.Owner),
    [("submitted", "approve")] = new("approved",  Role.Approver, RequiresDifferentUser: true),
    [("submitted", "reject")]  = new("rejected",  Role.Approver, RequiresDifferentUser: true),
    [("approved",  "pay")]     = new("paid",      Role.Finance),
};

public Result Apply(Request request, string action, User user)
{
    if (!Allowed.TryGetValue((request.State, action), out var t))
        return Result.Fail($"Cannot {action} a request in state {request.State}.");

    if (!user.HasRole(t.RequiredRole))
        return Result.Fail($"{action} requires {t.RequiredRole}.");

    if (t.RequiresDifferentUser && user.Id == request.SubmittedBy)
        return Result.Fail("The submitter cannot approve their own request.");

    return Result.Ok(t.To);
}

Two things follow from writing it this way.

The legal moves are a table you can read, rather than behaviour distributed across controllers, services and whatever a background job happens to do. When somebody asks whether finance can reject a request, you look it up instead of tracing code.

The four-eyes rule sits on the transition rather than in a form validator. It applies however the transition is triggered, including from an API, an import, or an administrative tool written next year by somebody who never read the original ticket.

The transition log is the audit trail

Storing the current state answers what is true now. It does not answer how it got there, which is what people actually ask.

CREATE TABLE request_transitions (
    id           BIGINT IDENTITY PRIMARY KEY,
    request_id   BIGINT       NOT NULL,
    from_state   VARCHAR(20)  NULL,       -- null for creation
    to_state     VARCHAR(20)  NOT NULL,
    action       VARCHAR(40)  NOT NULL,
    actor_id     BIGINT       NOT NULL,
    reason       NVARCHAR(500) NULL,
    occurred_at  DATETIME2    NOT NULL
);

Append-only, never updated. This costs one insert per transition and gives you the audit trail as a by-product rather than as a feature somebody has to remember to build. In regulated work it is the deliverable. Everywhere else it is what lets you answer a dispute with a record instead of a recollection.

Concurrency will find you

Two people open the same request. Both see it as submitted. One approves, one rejects. Both requests are valid when they are checked.

The check-then-act pattern loses here, and it loses intermittently, which means it passes every test you write. The fix is to make the transition conditional on the state you believed you were transitioning from:

UPDATE requests
   SET state = @to
 WHERE id = @id
   AND state = @from;   -- 0 rows means somebody moved it first

Zero rows affected is not an error condition to swallow. It means the world changed underneath the user, and the correct response is to tell them so rather than to overwrite whatever the other person did.

Side effects belong to transitions, not to screens

Notifications, integrations and generated documents attach to the transition, not to the button that happened to trigger it. Otherwise the email fires when a human clicks approve and stays silent when the same approval arrives through a bulk import, and nobody notices for a month.

The practical version: the transition commits, and the side effects are queued in the same transaction. If the notification service is down, the state change is still correct and the message is still owed. Doing it the other way round gives you notifications for transitions that were rolled back.

Testing becomes enumeration

The transition table is small and finite, which makes the test suite obvious: for every state, for every action, assert the outcome. Legal moves succeed, illegal moves fail with a comprehensible message.

That test does not just check the code. It checks that the model still says what the business thinks it says, which is the thing that quietly drifts over a couple of years of change requests.

What this buys you

Every question the business asks about the process becomes answerable. Which requests are stuck, and for how long. Who approved this and when. Whether a rejected request can be resubmitted, and what happens to the original.

None of those are reporting features you build later. They fall out of having modelled the thing properly in the first place, which is why this decision is worth making before anyone designs a screen.

Working on something similar?

Tell us what you are building. We will come back with an honest view of scope, approach and timeline.

We reply within one business day.