07Deep dive · domain
One domain model for scheduling, sales and delivery
Three products that looked totally unrelated, all sharing one set of core entities. The refactor that stopped a change in one of them from breaking the other two.
The problem
Scheduling, sales and delivery looked like three unrelated products, so each one made its own copy of the same ideas: a customer, a professional, an offer, an order, each slightly different. The price showed up later: changing how sales handled a customer broke how scheduling found the same person, because "the same person" was three records that only looked alike.
Constraints
- 01One set of truth entities. Customer, offer, professional: defined once, referenced everywhere, never re-modelled per feature.
- 02Reference by id, not by copy. Layers point at the truth instead of each carrying its own duplicate.
- 03Migrate without downtime. It's a production system with paying customers, so the refactor had to move under live traffic.
- 04What's shared becomes a named library. Reuse nobody names goes back to being three copies before long.
Architecture
truth entities
customer, professional, offer, order — defined once
layers reference by id
scheduling, sales and delivery point at the same ids
shared library
the building blocks over the truth, named and reused on purpose
The rule that fixed it fits on a line: everything by id. No layer keeps its own copy of a customer or an offer; it keeps the id and asks the truth. Once that held, a feature in one product lost the ability to corrupt the other two, because there was only ever one of each thing to corrupt.
The hard part
The hard part wasn't designing the unified model. It was believing the code over the diagram. On paper the three products already shared a customer. In the data they didn't: the same person existed as separate records with separate ids, and half the bugs were two layers disagreeing about which one was real.
The refactor had to collapse those duplicates without losing history, with everything working the whole time. So it moved by id: introduce the truth entity, point one layer at it, check against the database, and only then move to the next. No big-bang rewrite. That unglamorous discipline, one reference at a time, is what let a structural change reach production without a maintenance window.
Trade-offs
- Reference by id means more joins and more indirection than one fat denormalised row. I'll pay the join happily to never have two copies disagreeing.
- A shared library couples the three products: touching the truth touches all of them. The coupling is the point — it's what makes them agree — but it needs the eval suite to change safely.
- Unifying from the start would have been cheaper than unifying with three products already standing. Except I didn't have the shape of the three until they existed, so some of the duplication was the price of finding out what to unify.
What I'd do differently
I'd write the invariant down as a test, not just a principle. "Everything by id" lived in my head and in code review. A lint rule, or a schema constraint that fails when a layer stores its own copy of a truth entity, would hold the model in place on the day I'm not the one reviewing the PR.