DECISION RECORDPRODUCT

Why we chose modelling the domain before the schema for field service

The reasoning behind modelling the domain before the schema, including the part we expect to age badly.

FILED
READ
AUTHOR
REF

Written in the form we use internally, because the useful part of a decision record isn't the decision. It's the context that made it reasonable. In two years someone will want to reverse this, and they deserve to know what we knew at the time.

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.

We modelled it at some 12,000 work orders a month and the difference only showed up in the tail. At median load you couldn't tell them apart. At the ninety-ninth percentile, one of them stopped being able to explain itself.

// 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 deciding factor was regulatory, not technical. Schedulers have to be able to reconstruct why a given work order was handled the way it was, months later, in front of someone unfriendly. That killed two of the three options on the spot.

WHERE IT GOES WRONG

  • Discovering in month four that two teams mean different things by the same word.
  • 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.

Settle the nouns and the allowed moves before you write a migration.

CONSEQUENCES WE ACCEPTED

We took a slower first two months in exchange for a system you can still reason about in year three. On an eighteen-month horizon we'd have chosen differently, and we said so at the time.

RELATED
SAME GROUND, DIFFERENT ANGLE
ALL TRANSMISSIONS