Writing
Model the lifecycle as a state machine, not a bag of booleans
Boolean flags drift into impossible states. An explicit guarded state machine over an append-only log keeps a system's state trustworthy and explainable.
- Published
- 2026-07-30
- Topics
- Reliability · System design · Data integrity
Anything with a lifecycle tends to start life as a few boolean columns. A payment gets is_pending, is_paid, is_failed, is_refunded. A mandate gets is_active, is_cancelled. It feels harmless. Then six months later you are staring at a row that is both is_paid and is_failed, nobody knows how it got there, and no part of the code can answer the only question that matters: can I act on this right now?
Booleans drift because nothing stops them from combining. Four flags describe sixteen states, most of them nonsense, and every piece of code that touches the row has to remember the unwritten rules about which combinations are real. They never all remember.
One status, and a list of legal moves
Replace the flags with a single status and an explicit set of allowed transitions.
type Status = 'PENDING' | 'PROCESSING' | 'PAID' | 'FAILED' | 'REFUNDED'
const LEGAL: Record<Status, Status[]> = {
PENDING: ['PROCESSING', 'FAILED'],
PROCESSING: ['PAID', 'FAILED'],
PAID: ['REFUNDED'],
FAILED: ['PENDING'], // a retry
REFUNDED: [], // terminal
}
Now the impossible states are literally unrepresentable, the value is one of five things, never a contradictory mix, and “can I act on this” is a lookup, not a guess. Every change goes through one guarded function:
function transition(entity, next) {
if (!LEGAL[entity.status].includes(next))
throw new IllegalTransition(entity.status, next)
// ... persist the change (see below)
}
A bug that would have silently written a bad flag combination now throws at the boundary, where you can see it.
Do not overwrite the past, append to it
The second half is history. If status is a single mutable column, every transition erases the one before it. When something goes wrong you have no idea how the entity got where it is. So write transitions to an append-only log and treat the current status as a projection of that log.
events: (entity_id, from, to, reason, actor, at)
History becomes a record rather than a field you keep overwriting. You can answer “when did this fail, and what did we try next,” you can rebuild the current state by replaying, and a stuck entity announces itself instead of hiding behind a flag nobody checks.
What this looks like in a real project
My Job Hunt CRM (open source) is built exactly this way: an application moves through a lifecycle as a guarded state machine over an append-only event log. Transitions are legal-edge-only, so an illegal move is rejected rather than written, and because every change is an event, the timeline of an application is the log itself, not a reconstruction.
It also composes cleanly with idempotency. The conditional-update pattern for exactly-once processing is just a transition guard expressed in SQL:
update entity set status = 'PAID'
where id = $1 and status in ('PENDING', 'PROCESSING');
If zero rows change, the move was not legal from the current state, which is also exactly how you swallow a duplicate webhook. The state machine and the idempotency guard are the same idea in two places.
The payoff
- Illegal states cannot be represented, so a whole class of “how is this row both X and Y” bugs disappears.
- Illegal transitions fail loudly at the boundary instead of corrupting data quietly.
- History is queryable, so debugging is reading a log, not guessing.
- Concurrency gets safer, because the guard and the atomic conditional update are the same mechanism.
It is a small amount of structure up front. It buys you a system whose state you can always trust and always explain, which is the whole point when the entity in question is someone’s money.