
Engineering
Finite state machines are the right abstraction for business software
FastYoke Engineering · 8 min read · Jul 13, 2026
- Architecture
- FSM
- Workflow
The problem
Open almost any line-of-business app's schema and you'll find the same
tell: a status column that's a plain string or enum, sitting next to a
scatter of if status == "pending" checks spread across a dozen route
handlers, a couple of cron jobs, and a frontend component that reimplements
the same logic to decide which button to show. Nobody designed it this
way on purpose. It accretes — a new status gets added because a customer
needs it, a new if branch gets added because two statuses turned out to
interact, and eighteen months later nobody on the team can tell you, with
confidence, every legal way a job can move from "processing" to "done"
without going through it twice.
That accretion is the actual problem, and it's not a code-quality
complaint — it's a correctness one. A status column plus scattered
conditionals doesn't encode which transitions are legal. It encodes
whatever the union of every if statement that's ever been written
happens to allow, which is a very different and much looser thing. Nothing
stops a bug, a race condition, or a hand-edited row from moving a shipment
straight from "created" to "delivered," skipping "in transit," "customs
hold," and whatever compliance step lived in between. The schema can't
tell you that's wrong, because the schema never said what was right.
Why it matters now
The tell is usually the same complaint from three different people. The
support engineer can't answer "how did this job get into this state" —
because there's no single ledger of transitions, just whatever
after-the-fact inference they can do from timestamps on unrelated tables.
The product owner can't safely add a new status — because they don't
know which of the forty if checks scattered through the codebase assume
the old, smaller set of states. And the frontend team is maintaining a
second, parallel copy of the business logic, because the UI needs to know
which actions are valid from the current state, and the only place that
logic lives is duplicated in a component instead of derived from a single
source of truth.
All three complaints trace back to one root cause: the state transitions were never modeled as data. They were modeled as code, spread across however many places needed to make a decision based on status. A finite state machine is what you get when you stop doing that — you write down the states, the transitions between them, and the conditions under which each transition is allowed, once, as an explicit artifact. Everything else — the API route, the UI, the audit trail — reads from that one definition instead of re-deriving it.
How FastYoke approaches it
FastYoke's workflow engine takes this literally: a workflow is an explicit
JSON document — a set of named states, and a set of transitions, each
naming a from state, a to state, and an optional guard condition that
must hold for the transition to fire. There's no implicit state anywhere
in the system; if a transition isn't in that document, the engine won't
perform it, full stop.
Guards are the part of this that people usually expect to be a footgun —
"business logic" tends to mean somebody's bespoke expression language,
and expression languages tend to mean eval() on user input with extra
steps. FastYoke doesn't evaluate guard conditions by parsing strings at
all. Guards are either declarative JSONLogic predicates — a data
structure, not a script, so there's nothing to parse in the dangerous
sense — or, for logic that needs real computation, code that runs inside
a WebAssembly sandbox with bounded fuel and memory. Both paths share the
same invariant: no host syscalls, no raw string evaluation, ever. A guard
can check a field on the job's payload, or aggregate across related
records, but it can't open a socket or read a file it wasn't handed.
Once a transition is defined this way, three things fall out of it that you don't get from a status column and scattered conditionals.
Illegal transitions are rejectable, structurally. The engine checks the current state and the requested transition against the schema before it does anything else. There's no path from "created" to "delivered" that skips the states in between, because that edge simply doesn't exist in the document — not because someone remembered to write a guard for it.
The audit trail is inherent, not bolted on. Every transition the
engine fires appends a row to an append-only event_log table — no
UPDATE, ever, against that history. You don't have to remember to log a
state change; the engine can't make one without producing the record.
When support needs to answer "how did this get here," the answer is a
SELECT against a ledger that's structurally incapable of having been
edited after the fact.
The model is inspectable and testable independent of the code that
runs it. Because the workflow is data — states, transitions, guards —
you can look at the whole thing, diff two versions of it, or write a test
that walks every transition and asserts the guard behaves as expected,
without touching a route handler. Compare that to reverse-engineering the
legal state graph from forty if statements spread across a codebase.
The same document that defines the workflow also drives the UI, and this is the part that surprises people the first time they see it: FastYoke doesn't hand-code a form or a board per workflow. Forms, job boards, and the set of transition buttons a user sees are generated directly from the schema. Add a state or a transition to the JSON definition, and the UI that presents it updates without a frontend engineer writing a new component. That's the "UI for free" part of modeling business rules explicitly — it isn't a separate feature bolted on top of the FSM, it's the same definition read twice, once by the engine and once by the renderer.
On the client, transitions are applied optimistically — the UI updates the job's state immediately on user action, rather than spinning until the server round-trip completes. If the server rejects the transition with a 409 (conflict — someone else moved it first) or a 422 (guard failed), the client reverts cleanly to the last known-good state and surfaces the server's message. The state the user sees is never allowed to drift further from truth than one round trip.
Not every terminal state fits the transition-and-guard model, though, and
FastYoke doesn't pretend it does. Force-cancelling a stuck job is
deliberately not a normal transition — there's no "Cancelled" state
wired into the workflow graph for an operator to route into via a guard.
Instead there's a separate, out-of-band admin override: an admin-only
action that writes the job directly to a target terminal state, requires
a non-empty reason, and records that reason in the same append-only
event_log — but bypasses the transition engine and guard evaluation
entirely. That's an intentional split, not an oversight: normal business
logic stays inside the sandboxed, auditable, guarded path, and the "break
glass" case for operators is a visibly different, separately-authorized
mechanism rather than a state that a bug or a bad guard could route into
accidentally.
The same schema shape also covers a case that looks like a no-op but
isn't: a transition where from and to are the same state. That's a
legitimate construct, not a workaround — a self-loop still fires its
guard, still writes to current_state (with the same value), still
appends to event_log, and still broadcasts the change, identically to a
forward-moving transition. It's the right tool for an audit-only event
that doesn't move the job's lifecycle position — a driver check-in, a
note added, an idempotent retry of a side effect while waiting on a
forward-progress guard, or a counter increment where the state column is
fixed. The visible artifact is just a new row in the ledger, which is
exactly the point: the state machine can represent "something happened"
distinctly from "something changed the job's position," instead of
overloading the status column to mean both.
What to watch out for
None of this is a reason to model everything as an FSM. A boolean flag
that's genuinely a boolean — is_archived, say — doesn't need states,
transitions, and a guard to toggle it; wrapping it in workflow machinery
adds ceremony without buying you anything, because there was no
meaningful "illegal transition" to reject in the first place. The
signal that you actually have a state machine on your hands is that the
order of changes matters and some orders are wrong — a shipment can't
go from "created" to "delivered" without passing through "in transit," an
invoice can't be "paid" before it's "sent." If order doesn't matter, you
don't have an FSM, you have a set of independent attributes, and treating
them as one just means more schema to maintain for no correctness payoff.
The other trap is guard creep. Because the sandboxed evaluator is genuinely capable — JSONLogic composes, and the WebAssembly path handles real computation — it's tempting to push more and more decision logic into guards until one of them is doing the work of an entire service. Guards should stay bounded: check the shape of the payload in front of them, maybe look at a related record, and return a yes-or-no. If a guard needs to orchestrate multiple systems or run for a meaningful chunk of time, that's a sign the logic belongs in a job or a connector that the transition kicks off, not in the predicate deciding whether the transition itself is legal. The sandbox bounds what a guard can do; it's still on you to keep what a guard should do narrow.
And self-loops need the same discipline as any other transition: because every firing appends to an append-only ledger, an unbounded self-loop — an auto-retry with no cap, say — turns into an unbounded ledger. The fix is the same guard mechanism that governs every other transition: gate the loop on something like a retry counter in its own guard, rather than assuming it'll stop firing on its own.
Where to go next
Modeling business rules as an explicit FSM is the design decision that makes the rest of FastYoke's workflow story possible — the schema-driven UI, the append-only audit ledger, and the sandboxed guard evaluator are all downstream of writing the states and transitions down as data instead of code. If you want to see what that looks like end to end, take a look at FastYoke's runtime, or read about why FastYoke chose WebAssembly for the sandbox that keeps guard logic safe to run. If you're building on top of this, FastYoke for developers is the place to start.