DomainGENERALLANGUAGE-SPECIFICDOMAIN-SPECIFIC

Entities

Some things are the same thing after every one of their fields has changed. Order #123 is still order #123 — identity, not equality, is what defines them.

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

Which of my types are the same thing over time even when their contents change, and what does that force me to design?

The requirement

A customer changes the delivery address, the quantity, the payment method and the email on order #123. Support, the warehouse and the audit log must all agree it is still the same order — and the audit log must show what it looked like at each step.

The obvious build

Compare orders by their contents. If every field matches, it is the same order; that is what equality means and the language gives it for free with a record or a data class.

Why it breaks

The moment one field changes, value equality says it is a different order — so caches miss, the audit log records a deletion and an insertion, and "the same order" becomes untrue in code while staying true in the business.

How it breaks as requirements change
  • The moment one field changes, value equality says it is a different order — so caches miss, the audit log records a deletion and an insertion, and "the same order" becomes untrue in code while staying true in the business.
  • Two genuinely different orders that happen to match — same customer, same item, same minute, a double click — are now indistinguishable, and one of them silently disappears from a set or a map.
  • Identity by content means the identifier of an order depends on its contents, so any correction to a typo changes what support is quoting. That is a customer-facing bug produced by a modelling choice.
  • As soon as there are two stores, the search index and the database must agree on which order is which. Content equality gives no stable key to agree on (Stable Identifiers).
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
  • Order numbers are shown to customers and quoted in support tickets, so they cannot be regenerated.
  • The system runs on two databases — the transactional store and a search index — which are updated independently.
  • Regulation requires that the sequence of changes to an order be reconstructable for seven years.
Invariants
  • An order's identity never changes for the life of the order, regardless of what happens to its contents.
  • Two loads of the same order in one unit of work refer to the same thing, not to two things that look alike.
  • No two distinct orders ever share an identity, including across the search index and the transactional store.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • The entity owns its identity and refuses to let it be reassigned — identity is set once, at creation, and is not a settable field.
  • The entity owns the operations that change it, so that every change goes through a place where the invariants can be checked (Enforcing Invariants).
  • The id generator owns uniqueness. That is a separate responsibility with its own failure modes, and it belongs outside the entity (Stable Identifiers).
  • The audit log owns the history; the entity owns the present. Conflating them turns every entity into an event store nobody asked for.
Boundaries
  • Identity is the boundary of the thing. Everything reachable only through the entity and having no independent identity is part of it, not a peer of it — which is precisely the aggregate question (Aggregates).
  • The boundary between entity and value object falls on exactly one test: does it matter which one this is, or only what it is (Value Objects)?
  • Identity generation sits at the boundary of the model, because it is the one part that must be unique across processes and therefore cannot be pure.

Identity is not equality

The distinction is easiest to feel with a bank account. Two accounts holding exactly the same balance are not the same account, and an account is the same account after every field on it has changed. That is not a technical property — it is what an account is.

The code has to say so, because most languages default to the other answer. The one-line override below is the whole idea; the reason it matters is that everything else — caching, sets, persistence, audit — inherits it.

One rule, and everything downstream follows it
1type OrderId = string & { readonly __brand: 'OrderId' }
2
3class Order {
4 readonly id: OrderId // set once, never reassigned
5 private address: Address
6 private lines: OrderLine[]
7
8 constructor(id: OrderId, address: Address) {
9 this.id = id
10 this.address = address
11 this.lines = []
12 }
13
14 equals(other: Order) { return this.id === other.id }
15
16 changeAddress(next: Address) {
17 if (this.isDispatched()) throw new AlreadyDispatched(this.id)
18 this.address = next // one door, so the rule has a home
19 }
20}

The branded OrderId is doing quiet work: it makes passing a CustomerId into an order lookup a compile error rather than a support ticket. The changeAddress method matters more than it looks — it is the only reason there is anywhere to put "you cannot change the address after dispatch" (Enforcing Invariants).

What an entity is responsible for, and what it is not

The failure mode for entities is not getting identity wrong — that is usually caught. It is accumulation: every module that touches orders adds the field it needs, and after two years the entity changes for eleven different reasons.

Writing the responsibility down makes the accumulation visible. More than one or two entries in changesWhen is the finding, and here there are five, which is a diagnosis rather than a description.

responsibilitiesOrderThe Order entity after two years of ordinary feature work
Knows
  • Its identity and creation time
  • Its lines, quantities and prices
  • Its delivery address
  • Its current lifecycle state
  • Its loyalty points accrual
  • Its marketing attribution source
  • Its warehouse pick-slot preference
Does
  • Adds and removes lines
  • Recalculates its total
  • Transitions between lifecycle states
  • Formats itself for the customer-facing PDF
  • Decides whether it qualifies for free shipping
  • Writes its own audit rows
Depends on
  • Money and Address value objects
  • The pricing rules module
  • The PDF renderer
  • The audit logger
  • The clock
Changes when — 6 distinct reasons
  • A fulfilment rule changes
  • A pricing or promotion rule changes
  • The invoice PDF layout changes
  • The loyalty scheme changes
  • The audit schema changes
  • Marketing adds an attribution field

Identity is fine; the boundary is not. Six unrelated reasons to change means six teams editing one file and every change carrying the risk of all six. The first three responsibilities are the order; PDF rendering, loyalty and attribution are other modules reading an order. Splitting them costs a few extra reads and buys back local reasoning (Divergent Change, Single Responsibility, Carefully).

How identity actually fails in production

Identity bugs are unusually nasty because they rarely announce themselves. Nothing throws; two things merely become one, or one becomes two, and the symptom surfaces days later in a report that does not add up.

Each row below is a real, common mechanism rather than a category. The response column is the design change, not the hotfix.

Identity failures and what they are really telling you
TriggerSymptomCauseResponse
A customer changes their email addressTheir order history splits in two; support sees a "new" customer with no orders.Email was used as the customer identity — a natural key that the business allows to change.Generate an internal id at creation; treat email as a mutable attribute with its own uniqueness rule (Stable Identifiers).
An entity is created and put in a Set before it is savedTen new orders collapse into one, or all ten are treated as distinct copies of each other.Identity is assigned by the database on insert, so every unsaved entity has the same null id.Generate the id in application code at construction, so an entity is never without identity.
The same order is loaded twice in one requestOne of two concurrent updates vanishes with no error and no conflict.Two in-memory objects for one identity; the second save overwrites the first.One identity map per unit of work, and optimistic concurrency at the write (Optimistic Concurrency: Versions and If-Match in Backend has the mechanism).
An acquisition merges two order namespacesOrder 1042 exists twice with different customers.Ids were unique within a system rather than globally.This one has no cheap fix — it is a migration with a namespace prefix and a rewrite of every stored reference. It is the reason identity schemes are chosen carefully once (Data Migration).

How to build it

Most important first.

  • Give the entity an explicit identity field, assigned at creation and immutable thereafter. Prefer an id you control over any natural key, because natural keys change (Stable Identifiers).
  • Define equality by identity only. In a language with structural equality by default, override it deliberately and say why in a comment, because the default is the wrong one here (Comments).
  • Make the identity a typed value rather than a bare string, so an OrderId cannot be passed where a CustomerId is expected (Units in Names and Types).
  • Expose state changes as named operations — order.changeAddress(a) — rather than public setters, so the entity has one door and the invariant has a place to live.
  • Keep the entity ignorant of how it is stored. Identity is a domain concept that happens to be convenient as a primary key, not the other way round.

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: adding an audit trail to an order costs a rewrite of comparison logic everywhere, because "the same order changed" is not expressible when equality is by content.
  • After: the audit trail is a stream of events keyed by OrderId, and adding one is additive — no existing comparison changes.
  • The next change that is genuinely cheap: adding a field to an order. Nothing that identifies an order moves, so nothing that references orders is touched.
  • The next change that is not: changing the identity scheme — merging two order namespaces after an acquisition, say — touches every stored reference, every URL and every support ticket. Identity is the most expensive thing in the model to change, which is the argument for choosing it deliberately on day one.
What the recommended approach costs
  • Identity-based equality is surprising in languages where structural equality is the idiom, and it will be re-broken by a well-meaning contributor who adds a data-class annotation.
  • Generating ids in application code rather than the database gives up a small amount of database convenience and monotonicity, in exchange for entities that are complete before they are saved.
  • Named operations instead of setters make the entity more work to write and mean serialization frameworks need explicit support, which is a real friction in some ecosystems.

What can go wrong

Failure modes
  • A natural key is chosen as the identity — email address, order number from the vendor — and then it changes, which reassigns the identity of an existing thing and silently corrupts every reference.
  • Ids are generated by the database on insert, so an entity has no identity until it is saved; code that puts an unsaved entity in a set or a map hits collisions between all of them.
  • Two loads of the same row in one transaction produce two objects, both mutated, and the last write wins — an identity map bug that looks like a race and is not (What an ORM Actually Does in Backend covers the mechanism).
  • The mitigation fails as well: making the entity immutable to dodge the problem means every change produces a new object, and now identity must be threaded through explicitly or it is lost at the first copy.
Dependencies, and their direction
  • Everything that refers to an order depends on OrderId and nothing else about it — that is what makes ids the cheapest possible coupling between modules.
  • The entity depends on its value objects, never the reverse; a Money does not know what an order is (Dependency Direction).
  • Persistence depends on the entity's identity to map rows to objects, which is why an identity that is stable and generated by you and not by the database keeps persistence swappable.
Misreads
  • "Everything with an id in the database is an entity." Rows have primary keys for storage reasons. A row of currency conversion rates has a key and is a value; whether it is an entity depends on whether it matters which one it is (Value Objects).
  • "Entities must be mutable." They must have stable identity. An immutable entity that returns a new instance on every change is entirely valid, and is the usual choice in functional languages — identity is then a field, not the object's address (Immutability).
  • "Then everything should be an entity, to be safe." Entities cost identity management, lifecycle and storage. A postal address given identity acquires the question "is this the same address?", which nobody wanted to answer.
Smells this explains
  • primitive-obsession
  • god-object

Testing it, and how it ages

What to test, and at which boundary
  • A test that an entity with every field changed still equals itself, and that two entities with identical fields and different ids do not.
  • A test that identity cannot be reassigned — in a language where that is a compile error, the test is the compiler (Making Illegal States Unrepresentable).
  • At the persistence boundary, a test that loading the same id twice in one unit of work yields one object, not two.
  • A property test that generated ids never collide under concurrent creation, because that is a real failure and it is invisible at low volume (Property-Based Testing).
How this design ages
  • Entities accumulate operations as the business adds rules, and this is normal growth. What is not normal is accumulating fields for other modules' convenience — that is how an entity becomes a god object (God Object).
  • Over years, the pressure is toward splitting: an Order that has grown fulfilment, billing and returns behaviour is three entities sharing an id, and the split is cheap precisely because the id is stable.
  • The identity scheme itself almost never changes, and when it does — a merger, a migration to UUIDs — it is one of the largest projects a codebase can undertake (Data Migration).

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 some things are defined by continuity rather than contents is a property of the problem, not the technology, and holds identically in a functional language where the entity is a value with an id field.
  • LANGUAGE-SPECIFICIn languages with structural equality by default — Kotlin data classes, Python dataclasses, Rust derived PartialEq, records in Java and C# — the default is wrong for entities and must be overridden explicitly. In languages with reference equality by default the default happens to be right for in-memory use and wrong across a reload, which is a subtler trap.
  • DOMAIN-SPECIFICAnalytical and reporting code often has no entities at all: a fact table row is a value, and asking whether it is "the same row" is meaningless. Identity discipline matters in transactional systems and is close to irrelevant in pipelines.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Distributed Systemsidempotent-operations
Domains that do not exist yet
  • Programming Languages & Runtime Internals — whether equality defaults to structural or referential, and whether it can be overridden safely, is a language-design decision that changes how much discipline this lesson requires.