DomainGENERALDOMAIN-SPECIFICCONTESTED

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.

The question

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?

The requirement

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 obvious build

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.

Why it breaks

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.

How it breaks as requirements change
  • 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.
  • Two engineers translate it differently. One reads "reserved" as orders.reserved_at IS NOT NULL, the other adds a stock_holds table, 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).
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

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.

Constraints
  • 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.
Invariants
  • 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.

Responsibilities
  • 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).
Boundaries
  • 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 four concepts, and which way the references point
refers to order idheld for order idfulfils order idused byused bycaptured money implies shipment or refundInventoryReservation held until expiryShipment dispatched, deliveredMoney value objectWhat must stay truePayment authorized, captured, refundedOrder placed, paid, fulfilled
UserLLMAgentToolDataDecisionHumanGuardrail

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.

Reserving stock, two ways
Row-shaped: the concept is an encoding
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 here
Concept-shaped: reservation is a thing
function 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.

Reservations expire after one hour, and expiry must refund the payment
The change

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.

Order row with `reserved_at`, `reserved_qty`, `status` columns
OrderServiceExpiryCronRefundServiceWarehouseReportAdminOrderViewStockSyncJob
testsorder_service_testexpiry_cron_testrefund_testreport_test
6 modules · 4 test files

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.

InventoryReservation as its own type, with expiry as its rule
InventoryReservationExpirySchedulerOrderApplicationService
testsreservation_testexpiry_integration_test
3 modules · 2 test files

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.

what it cost A separate reservation concept means a separate table or a mapping into the existing one, plus a migration to backfill reservations from 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, not StockHold, 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.

Cost of the next change
  • 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 InventoryReservation plus 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 orders for 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.
What the recommended approach costs
  • 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

Failure modes
  • 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).
Dependencies, and their direction
  • 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.
Misreads
  • "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).
Smells this explains
  • primitive-obsession
  • god-object

Testing it, and how it ages

What to test, and at which boundary
  • 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.
How this design ages
  • 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.

Architecturemodular-monolith
Domains that do not exist yet
  • 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.