---
title: "A status machine belongs in a table"
description: "Nine statuses, two sets of allowed transitions, and two names for every state. Moving all of that into rows instead of an if chain got engineering out of the workflow business."
url: https://riteshkc.com.np/blog/a-status-machine-belongs-in-a-table
source: https://riteshkc.com.np/blog/a-status-machine-belongs-in-a-table.md
updated: 2026-08-05
site: "Ritesh KC"
---
# A status machine belongs in a table

Nine statuses, two sets of allowed transitions, and two names for every state. Moving all of that into rows instead of an if chain got engineering out of the workflow business.

- Published: 2026-08-05
- Tags: Architecture, PostgreSQL, Prisma
- Author: Ritesh KC (https://riteshkc.com.np)

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:

```ts
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:

```ts
{
  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 `if` chain 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.

## Related work

- [Gradsy](https://riteshkc.com.np/work/gradsy): A centralized platform for students, counselors, and admins with role-based access - built with Next.js and NestJS, and deployed on AWS using S3, SES, and Lightsail.
