Modelling the domain before the schema: a cost model for laboratory sample tracking
The arithmetic behind modelling the domain before the schema, with the assumptions written out so you can disagree honestly.
The question is never whether something is worth doing in the abstract. It's whether it's worth doing at your volume, with your failure rate, valuing your team's time properly. So let's do the sum.
A schema is a set of answers. If you write it before you've settled the questions, you've encoded guesses into the hardest part of the system to change. Spend the extra week on the model and the schema takes an afternoon.
NOUNS ARE DECISIONS
Every entity you name is a claim that this thing exists independently, has a life of its own, and can be pointed at. Most arguments that look like technical arguments are really disagreements about whether something is an entity or an attribute, and they get much shorter once you say that out loud.
The test we use is lifecycle. Does it change on its own schedule? Does anybody care about its history? Does someone own it? Three yeses and it's an entity. Three noes and it's a column, and making it a table will cost you joins forever for no return.
GET THE VERBS TOO
Modelling usually stops at the nouns, which is half a model. The verbs are where the domain actually lives: the transitions, who's allowed to make them, what has to be true first. Write them down as a list of allowed moves before you write a single migration.
Do that and something useful falls out for free: the states nobody mentioned. Every domain has two or three legitimate states that don't appear in any documentation because everyone handles them by hand. Those are the ones that generate support tickets for the next five years.
At around 20,000 samples a month with a two percent exception rate, lab managers were absorbing about nine hours of manual reconciliation a week. That's the number the build had to beat, and it's a lower bar than anyone in the room expected.
// Transitions first. The schema is downstream of this.
type Transition = {
from: State
to: State
actor: Role
requires: Guard[]
}
const TRANSITIONS: Transition[] = [
{ from: "draft", to: "submitted", actor: "coordinator", requires: ["complete"] },
{ from: "submitted", to: "accepted", actor: "reviewer", requires: ["in_scope"] },
{ from: "submitted", to: "returned", actor: "reviewer", requires: ["reason_given"] },
// The state nobody mentioned in the workshop, and the reason we do this first:
{ from: "accepted", to: "withdrawn", actor: "coordinator", requires: ["before_cutoff"] },
]THE WEEK THIS COSTS
It looks like a week of not building. In practice it's a week of finding out that two departments use the same word for different things, which you were going to find out anyway, just later, in production, after the migration.
We've never regretted the week. We have repeatedly regretted skipping it, and the regret always arrives in the same shape: a nullable column added in a hurry with a name like status_2.
The interesting term wasn't engineering time. It was the cost of a batch was reported before its confirmatory run had finished landing once in the wrong quarter, which the client could size to the pound and we couldn't size at all.
WHERE IT GOES WRONG
- Adding a nullable status column in a hurry, then never being able to remove it.
- Generating the schema from a UI mock, which bakes this month's screen layout into next decade's data.
- Modelling the nouns and leaving the transitions to whoever writes the controller.
- Discovering in month four that two teams mean different things by the same word.
Settle the nouns and the allowed moves before you write a migration.
WHERE THE MODEL BREAKS
Do the numbers before the meeting, not during it. A decision that survives arithmetic tends to survive the next reorg too, because the reasoning outlives the people who made it.