Eine Statusmaschine gehört in eine Tabelle
Every workflow app has a status column. And somewhere near it, almost always, sits a function that decides which status is allowed to follow which. Mine started exactly where yours probably did:
if (current === 'DRAFT' && next === 'PENDING') return true;
if (current === 'PENDING' && next === 'SENT_TO_UNIVERSITY') return true;
// ...Perfectly fine code. It works right up until the business changes its mind, which is the one thing you can count on it doing.
Applications in this system start as a draft, go through internal review, get sent out to a university, and then land in one of several endings. Nine states. Within a month of shipping the first version, three requests came in, and each one meant opening that same function: slot a new state between two existing ones, let students withdraw from a place they previously couldn't, and stop calling one of the statuses what it was called.
Three deploys. Three sentences of business logic. Not one of them an engineering problem.
That if chain was holding five things, not one
The useful exercise wasn't rewriting it. It was sitting down and writing out everything the transition rules actually had to know. I expected one thing. I got five.
Which transitions are legal. The obvious one, and the only one the if chain was honest about.
Which of those a student may trigger. Staff can move an application from pending to sent-to-university. A student can't. But a student can withdraw from nearly anywhere. So this was never one graph with a permission check bolted onto the side. It's two graphs, and one is a subset of the other.
What the state is called, and to whom. Internally, a status is REJECTED_BY_GRADSY. Now imagine showing a student "Rejected by Gradsy" when the actual meaning is "we're not forwarding this yet, here's what to fix." That's a support ticket, and quite possibly a lost customer. Staff need the blunt name to do their jobs. Students need the accurate one. Same row, two audiences.
Whether the transition needs an explanation attached. Dropping an application into a rejection state without telling the student why is the fastest way I know to generate an angry email. Notice where that requirement lives, though: it's a property of the destination state, not of the person clicking the button.
Where the state sits in the workflow. Sort an application list "by status" alphabetically and APPROVED_BY_UNIVERSITY lands above DRAFT, which tells a user precisely nothing. What people actually want is workflow order, and workflow order isn't something you can derive from the name.
None of those five fit inside a boolean function. And four of them aren't about transitions at all. They're plain facts about a state that the rest of the app kept needing to know.
So the status became a row
Each status turned into a record, seeded from a catalog file:
{
code: S.PENDING,
studentLabel: 'Under review',
staffLabel: 'Pending approval',
description: 'Submitted; awaiting Gradsy counselor/admin approval.',
studentVisible: true,
isTerminal: false,
sortOrder: 1,
allowedNextCodes: [
S.SENT_TO_UNIVERSITY,
S.REJECTED_BY_GRADSY,
S.REVISION_REQUESTED,
S.WITHDRAWN,
],
studentAllowedNextCodes: [S.WITHDRAWN],
requiresFeedback: false,
}allowedNextCodes is the staff graph. studentAllowedNextCodes is the narrower student one: from under-review, a student's only available move is to withdraw. The two label fields are the same state, described to two different readers. requiresFeedback is set to true on the rejection states, and the service flatly refuses the transition if notes are missing.
Here's the load-bearing detail, though: the application table's status column is a foreign key into this catalog, not an enum.
That single choice is what pays for everything else. A status list query can order by statusDef.sortOrder and come back in workflow order. The label a user sees becomes a join instead of a switch buried in the frontend. And adding a new state is an insert.
Validation, meanwhile, shrank to almost nothing: read two rows, check membership, narrow the set if the actor is a student, and enforce the feedback requirement on the destination. That's the whole thing.
What it bought
What I actually wanted was to stop being the bottleneck for workflow changes. That part worked. Changing which transitions are legal, renaming a state for students, making feedback mandatory somewhere new: all of it is a catalog edit and a re-seed.
The backend README spells this out, because the instinct of anyone new on the codebase is to go hunting for the if chain:
To change transitions, edit the catalog and re-seed: don't look for an
ifchain in the service.
The part I didn't see coming was how much every other status-aware feature benefited. The deadline reminder scheduler only nags about applications that aren't finished, so it joins on isTerminal instead of carrying a hardcoded list of ending states: the kind of list that goes quietly stale the second someone adds a tenth status. The admin filter pills are generated. The student-facing timeline renders studentLabel. None of them needed a second definition of anything, which means none of them can drift from the first.
Then the article CMS came along with its own workflow, and the same pattern dropped straight in: authorAllowedNextCodes playing the role that studentAllowedNextCodes plays here. That's the real evidence the shape was right. The second use didn't require bending the abstraction to fit.
What it costs
Two things. I'd pay both again, but they're real, and pretending otherwise would be dishonest.
The catalog is a deploy artifact wearing a data costume. It lives in a TypeScript file and gets seeded. So "operations can change this without engineering" is true in the sense that no service code changes, and false in the sense that somebody still has to run a seed. A proper admin UI over these rows is the version where that claim holds up completely, and it isn't built yet.
Referential integrity has teeth. Statuses are foreign-keyed from applications and from the status history table, which is exactly what makes history survive. The flip side: you cannot delete a status that any application has ever passed through. That's correct behaviour, and it still surprises people every time. Retiring a state means marking it retired, not removing it.
The rule I'd carry forward
When a switch on an enum starts showing up in more than two files, the enum is asking to become a table.
The switch was never the problem. The problem is five separate files each holding a partial copy of the same domain knowledge, all of them free to disagree with one another the moment someone edits four of them. Moving it into rows doesn't make the logic any cleverer.
It just makes there be one of it.