Every system I've worked on with a lifecycle started the same way: 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, until the day you open a row that is somehow both paid and failed, nobody can say how it got there, and the code can't answer the one question you actually need: can I act on this right now?
The trouble with flags is that nothing stops them combining. Four of them describe sixteen states, most of them nonsense, and every bit of code that touches the row is supposed to remember which combinations are real. Something always forgets.
What I do now is replace the flags with one status and an explicit list of legal moves.
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
}
That alone kills a whole class of bugs. The value is one of five things, never a contradiction, and "can I act on this" becomes a lookup instead of a guess. Every change goes through one guarded function, so an illegal move throws at the boundary where you can see it, instead of quietly writing something wrong.
function transition(entity, next) {
if (!LEGAL[entity.status].includes(next))
throw new IllegalTransition(entity.status, next)
// ... persist the change (see below)
}
The second half is history. If status is a single column you overwrite, every change erases the one before it, and when something goes wrong you have no idea how the row got there. So I write each transition to an append-only log. The mutable status column stays, because the guard needs a fast answer to "where is this now", but it is a cache; the log is the record of how it got there, and the thing I trust if the two ever disagree.
events: (entity_id, from, to, reason, actor, at)
Now the history is something that happened, not a field I can overwrite. I can see when a payment failed and what we tried next, rebuild the state by replaying, and a stuck record shows itself instead of hiding behind a flag nobody checks.
This is the same shape as the exactly-once work I wrote about. The conditional update is just a transition guard in SQL:
update entity set status = 'PAID'
where id = $1 and status in ('PENDING', 'PROCESSING');
If no row changes, the move wasn't legal from where the record was, which is also exactly how you swallow a duplicate webhook. The state machine and the idempotency guard turn out to be the same idea in two places.
It's a little more structure than a boolean, and I used to think it wasn't worth it. Then I spent enough late nights trying to reconstruct how a record reached a state that should have been impossible. Now I reach for it first, especially when the thing changing state is someone's money.
Top comments (1)
The append-only log is the right source of truth. One production detail I’d add is state-machine versioning.
LEGALwill change over time: perhapsFAILED -> PENDINGbecomes restricted, or a new review state appears. If old events are replayed under today’s graph, valid history can suddenly look illegal. Store amachine_versionon every transition and make upgrades explicit events or migrations, so replay uses the rules that were valid when the move happened.I’d also write the event and update the status cache in the same database transaction, guarded by the expected
fromstate and a unique external/idempotency key. Then run a reconciler that periodically rebuilds state from the log and compares it with the cache. The log is only authoritative if divergence is detectable and cannot be created by an ordinary partial write.