Domain Modeling
Getting the nouns and verbs the business actually uses into the code, so a requirement in their sentence maps to a change in one of yours.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
A requirement arrives in the business's words. How do I make the code contain those words, so translating it is not the expensive part?
Operations says: "when a customer pays, reserve the stock; if we cannot reserve it within an hour, refund them and release the reservation." Four nouns in that sentence — order, payment, inventory reservation, shipment — and none of them appears in the codebase.
The database has the tables already. Write a function per endpoint that reads rows, checks a few columns, writes rows back. The domain is whatever the tables say it is, and nobody has to learn new words.
The sentence ops said has four concepts; the code has one table called orders with eighteen columns, so the translation happens in an engineer's head every single time and is never written down.
- The sentence ops said has four concepts; the code has one table called
orderswith eighteen columns, so the translation happens in an engineer's head every single time and is never written down. - Two engineers translate it differently. One reads "reserved" as
orders.reserved_at IS NOT NULL, the other adds astock_holdstable, and both are now the truth. - When ops asks "how many reservations expired yesterday", there is no answer, because expiry was implemented as a nullable timestamp being overwritten rather than as an event happening to a thing that exists.
- The next requirement — partial shipments — has no place to land, because the code never had a Shipment. It arrives as three more columns on
orders, and the table becomes the place where every concept goes to be flattened (God Object).
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The database schema already exists and is shared with a reporting system, so tables cannot be freely reshaped.
- There is no full-time domain expert; ops answers questions in Slack, in batches, a day later.
- The team is six engineers and none of them has worked in fulfilment before, so the vocabulary is genuinely unfamiliar.
- Stock reserved for one order is never simultaneously reserved for another.
- Money taken from a customer is either matched by a shipment or refunded — there is no third outcome.
- Every business term used in conversation resolves to exactly one thing in the code, or the conversation is not about the code.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Some named type owns "what an Order is and what may be done to it", so that questions about orders have an address.
- A separate type owns reservation, because reservation has its own lifetime, its own expiry rule, and its own reason to change.
- The persistence layer owns rows and columns and owes the model nothing but storage — the model is not the schema (State Ownership).
- Nobody owns "translating between what ops says and what the code says" — that job should not exist (Ubiquitous Language).
- The seam falls where the business draws one: order, payment, reservation and shipment change for different reasons and at different times, so they are four types even if they are two tables.
- The boundary between model and storage is worth drawing here specifically because the schema is shared with reporting and therefore cannot follow the model (Schema Leakage is the backend view of the same wall).
- The boundary is not between "domain" and "infrastructure" as folders. It is around each concept that has its own rules (Decomposition by Folder).
Four nouns, and where each one lives
The sentence ops said contains four things that have independent lifetimes. An order exists before a payment. A payment can succeed after the reservation has already expired. A shipment may be created twice for one order. Each of those facts is a reason for a separate type, and none of them is visible in a schema where all four are columns on one row.
Drawing the four and the arrows between them takes five minutes and settles most of the argument about where code goes. The arrows matter more than the boxes: they say who refers to whom, and therefore what has to change together.
- Order has identity and a lifecycle, so it is an entity (Entities).
- Payment has identity too — a refund refers to a specific capture, not to "the payment for order 123".
- InventoryReservation has its own expiry rule, which is the clearest possible signal that it is not a column on
orders. - Shipment may not exist, may exist twice, and outlives the order in support conversations.
- Money has no identity at all — two amounts of 12.50 EUR are the same amount, which makes it a value object (Value Objects).
The model is not the schema
The most common way this goes wrong is not laziness, it is a reasonable-looking shortcut: the tables exist, they describe the business, so let the row shape be the model. It works until a concept in the conversation has no column, at which point it is added as a column, and the flattening compounds.
The distinction that matters is not object versus row. It is whether the code contains the concept or contains an encoding of the concept. reserved_at IS NOT NULL is an encoding; every reader has to decode it, and every reader might decode it differently.
async function payOrder(orderId: string) {
const row = await db.orders.find(orderId)
if (row.status !== 'new') throw new Error('bad status')
await db.orders.update(orderId, {
status: 'paid',
paid_at: new Date(),
reserved_at: new Date(), // this is the reservation
reserved_qty: row.qty,
})
}
// "has this order reserved stock?" -> reserved_at IS NOT NULL
// "when does it expire?" -> reserved_at + 1 hour, computed
// in a cron job, a report and herefunction payOrder(order: Order, stock: StockLevels, now: Date) {
order.pay(now)
const reservation = stock.reserveFor(order, now) // may fail
return reservation // has an id and an expiry
}
class InventoryReservation {
expiresAt: Date
isExpired(now: Date) { return now >= this.expiresAt }
release() { /* one place, one meaning */ }
}The expiry rule exists in exactly one place in the second version, so the requirement "make it two hours for wholesale customers" is one edit rather than a search. It is not that objects are better than rows: it is that the reservation had a rule, and a rule needs an owner. A concept with no rule would not have earned a class.
What the translation gap actually costs
The argument for modelling is not aesthetic and it is not "the code reads like English". It is that a requirement stated in business language should map to a small number of edits, and that the mapping should be mechanical enough that a new engineer can do it.
Price one real change under both designs. The interesting part is not the module count — it is that under the row-shaped design nobody can be sure the list is complete, and that uncertainty is what turns a two-hour change into a two-day one.
If stock cannot be shipped within an hour of payment, the reservation expires, the customer is refunded automatically, and ops gets a daily count of expiries by warehouse.
Six modules, and the real cost is that "is this order reserved" is spelled slightly differently in four of them — one checks reserved_at, one checks status = paid, one joins to stock_sync. Making them agree requires reading all six, and there is no way to know you found them all.
The rule is one method. The scheduler asks the model which reservations are expired rather than deciding for itself. The report reads reservation records, which now exist as records.
reserved_at for historical orders — and history will be approximate, because the old encoding lost information the new model expects. It also adds a read: the admin view that used to be one row is now a row plus its reservations, which is a query nobody had to write before.How to build it
Most important first.
- Write down the sentence the business said, verbatim, and underline every noun and verb. That list is the first draft of the model, and it took ninety seconds.
- Give each underlined noun a type with a name identical to the word they used —
InventoryReservation, notStockHold, if reservation is what they say (Naming and Domain Language). - Give each underlined verb a method on the thing it happens to:
order.pay(),reservation.expire(). A verb that does not belong to any one noun is the signal for a domain service (Domain Services). - Model the concepts that have rules. A concept with no rules and no lifecycle is a data shape, and giving it ceremony is cost with no return (Over-Design and Under-Design).
- Keep the model free of the database, the HTTP layer and the clock, so it can be exercised in a test without any of them (Purity and Testing).
- Check the model against the expert by reading it back as English. If
reservation.release()does not describe something ops recognises, the model is wrong and the code is about to be wrong too.
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Before: "reservations expire after an hour" is a change to a cron job, two queries and a nullable column, plus a search for every place that reads that column. Cost is proportional to the number of places that know what "reserved" means, which nobody can enumerate.
- After: it is a rule inside
InventoryReservationplus one scheduled call to a method that already exists. One edit, one unit test with an injected clock. - The change that did not get cheaper: reshaping
ordersfor reporting still touches persistence and every mapper, because the model deliberately does not follow the schema. That is the price of the split and it is paid on every schema change.
- A model separate from the schema means mapping code, and mapping code is boring, plentiful and a genuine source of bugs.
- Learning the business vocabulary is real work, and for a team of six with no domain expert it can take a quarter to get right, during which the names are confidently wrong.
- Domain types make simple reads more expensive: a list screen that needs six columns now loads whole objects or gets a second read path (N+1 as a Design Problem).
What can go wrong
- The model is derived from the schema instead of the conversation, so it reproduces every historical compromise in the tables and calls it domain design.
- The model is derived from a whiteboard and never checked against an expert, so it is internally consistent and describes a business that does not exist.
- Modelling is done once, at the start, and then the code drifts from the words while the class names stay — which is worse than never having modelled, because the names now lie.
- The mitigation fails too: an "anti-corruption layer" between model and schema is added and then bypassed by one urgent query, and the bypass is permanent (Anti-Corruption Layer).
- The model depends on nothing: not the ORM, not the framework, not the transport. Everything depends inward on it (Dependency Direction).
- The application layer depends on the model and on persistence, which is where the two are joined — deliberately in one place rather than everywhere.
- The model depends on the business's vocabulary, which is a dependency on people. When they change the word, the code has to follow, and that is correct.
- "So we should do DDD." No. Modelling the domain is naming things after what they are; DDD is a specific and much larger set of tactical and strategic patterns with its own cost, and it is worth it far less often than it is adopted (When Domain-Driven Design Does Not Pay).
- "The nouns become classes, one to one." Frequently they do not. Some nouns are value objects, some are states of another noun, and some are reporting concepts that never belong in the model at all.
- "The model must not touch the database, so we need repositories, unit of work and a mapper layer." The requirement is that the rules can be tested without a database. Everything else is a means, and each means has a cost (When the Repository Is Just Indirection is the backend critique).
- primitive-obsession
- god-object
Testing it, and how it ages
- Test the model with no database: construct an order, pay it, reserve stock, expire the reservation, assert the refund is owed. If that test needs a container, the model is not separated yet (Testing as Design Feedback).
- Test the invariant "money in implies shipment or refund" as a property over sequences of operations, because it is the one rule that spans all four concepts (Property-Based Testing).
- One integration test per mapper, asserting the model round-trips through the shared schema without losing a concept.
- The model gets richer as the business explains more, and the healthy signal is that new words arrive from ops rather than from engineers.
- The schema and the model drift further apart over time. Mapping cost grows slowly and is the main thing that eventually argues for reshaping the tables.
- It stops being right when the business splits: if fulfilment and subscriptions start meaning different things by "order", one model is now wrong for both, and the answer is two models with an explicit translation between them.
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThat code containing the words the business uses is cheaper to change on business requests is true across languages and paradigms; the mechanism is only that the translation step disappears.
- DOMAIN-SPECIFICThe payoff scales with how many rules the domain actually has. Fulfilment, insurance and payroll repay modelling heavily; a link shortener or a CMS has almost no rules to model, and the same effort there buys types with no behaviour in them (Transaction Script).
- CONTESTEDThe strongest opposing view: the database schema IS the model, and a second in-memory model is duplicated knowledge that must be kept in sync forever. Practitioners who build data-centric systems point out that the mapping layer is where most of their bugs live and that SQL expresses set-based rules better than object graphs do. That argument wins outright when the rules are genuinely about sets of rows.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — System Design — once these four concepts live in different services, the arrows in the diagram become network calls and the invariant spanning them becomes a saga; that is a different problem with a different cost model.
- — Programming Languages & Runtime Internals — how faithfully a model can be expressed depends heavily on what the language's type system can say about it, which is why the same model looks different in Rust, Java and Python.