Software Architecture · Domain Modeling

Value Objects and Entities: Deciding What Has Identity

Why some domain types are defined by their attributes and others by a lifecycle, and how equality, mutation, and identifier choice all follow from that one decision.

A payment of forty dollars and a refund of forty dollars carry the same amount and the same currency. No accounting system treats them as the same object. Two customer records with the same name, address, and phone number are either the same customer recorded twice or two people who happen to share those attributes; only a stable identifier distinguishes the cases. The distinction between a type defined by its attributes and a type defined by its continuity over time is the first decision in domain modeling, and every later decision about equality, mutation, persistence, and identifier generation follows from it.

Equality is a modeling decision, not a language default

Most languages give objects reference equality by default: two instances are equal only if they are the same instance. That default encodes a claim — that each instance is a distinct individual — which is correct for entities and wrong for values. A monetary amount, a date range, a postal address, a colour, a temperature reading: these are values. Two instances of Money(4000, "EUR") denote the same amount of the same currency, and any code that distinguishes them is tracking something the domain does not have.

Structural equality has two obligations that are easy to state and easy to violate. First, equality must be reflexive, symmetric, and transitive. Second, it must agree with hashing: if a.equals(b) then a.hashCode() === b.hashCode(). Violating the second obligation does not fail immediately; it produces a lookup miss in a hash map months later, when an object that is equal to a key cannot be found under that key. The usual cause is a field included in equals but excluded from the hash, or the reverse. The fix is mechanical: derive both from the same ordered field list, and add a test that puts two independently constructed equal instances into a set and asserts the set has size one.

Immutability is not a stylistic preference here, it is a precondition. A value used as a hash key must not change, and a value shared between two aggregates must not be mutable through one of them. Languages without a const-like guarantee on fields rely on discipline: no setters, a constructor that copies mutable inputs (including arrays and maps, which are the fields people forget), and replacement rather than mutation at every call site.

class Money {
  private constructor(
    readonly minorUnits: number,      // integer cents; no floating point
    readonly currency: string,       // ISO 4217 alphabetic code
  ) {
    if (!Number.isSafeInteger(minorUnits)) throw new RangeError("minorUnits must be an integer");
    if (!/^[A-Z]{3}$/.test(currency)) throw new RangeError("currency must be an ISO 4217 code");
  }

  static of(minorUnits: number, currency: string): Money {
    return new Money(minorUnits, currency);
  }

  add(other: Money): Money {
    if (other.currency !== this.currency) throw new Error("currency mismatch");
    return new Money(this.minorUnits + other.minorUnits, this.currency);
  }

  equals(other: Money): boolean {
    return this.minorUnits === other.minorUnits && this.currency === other.currency;
  }
}

The constructor is private and validation lives inside it, so no instance can exist in an invalid state. Article Invariants and Illegal States takes that idea much further; here it matters only because a value’s equality is meaningless if half the fields can hold nonsense.

What identity buys and what it costs

An entity is a type whose instances are distinguished by a thread of continuity rather than by their current attributes. A customer who changes address is the same customer. An order that moves from PENDING to SHIPPED is the same order. The identifier carries that continuity, and it must be stable across the entity’s whole life, including across storage, across process restarts, and across representation changes at system boundaries.

The cost is immediate: an entity cannot be a pure function of its fields. Any code that rebuilds an entity from a payload must preserve the identifier, which means the identifier has to be carried in every representation — wire format, database row, cache entry, log line, event. Models that omit the identifier from an internal DTO and re-derive it from a natural key later tend to break exactly when the natural key is corrected, which is when the customer’s email address changes.

Identity also imports a lifecycle. Values can be created and discarded at will; entities are created once, then loaded, modified, and eventually archived or deleted. Persistence frameworks exploit this. An identity map keeps one instance per identifier per transaction so that two loads of the same row return the same object, and a unit of work flushes changes by comparing the object against its loaded snapshot. Both mechanisms depend on the identifier being assigned before the object is stored in the map and on equality being reference-based. Give an ORM a type with structural equality and it will collapse two distinct rows into one instance. The repository boundary that brokers this is covered in Repositories and the Persistence Boundary.

Reference semantics leak through shared mutable state

Two entities that reference the same value object are safe as long as the value is immutable. Two entities that reference the same mutable object share state whether the model admits it or not. The classic failure is an aggregate that returns its internal list from a getter: caller code appends to it, an invariant that the aggregate was supposed to maintain is bypassed, and nothing throws. The defect surfaces later as a duplicate line item or a total that does not match its parts.

Three strategies exist, with different costs. Return a copy on every read: correct, allocates per call, and the allocation is visible in profiles where the list is large. Return an unmodifiable view: allocation is one wrapper, but the view is live, so it changes under the caller’s feet and cannot be held across a mutation. Expose a query method instead of the collection (total(), containsItem(sku)) and never hand out the collection at all: cheapest and safest, but it pushes more methods onto the type. All three are better than a mutable reference, and the choice between them depends on how often callers genuinely need to iterate.

The same leak appears at a larger scale with entities that reference other entities directly. Holding an object reference implies holding a live view of another aggregate’s state, which crosses a transactional boundary the model may not intend to cross. Article Aggregate Boundaries and Transaction Scope argues for holding identifiers instead, and for exactly this reason.

Choosing identifier schemes

Three families are in common use, and the choice is a trade between coordination, opacity, and sort order.

Database-assigned sequential integers are compact (four or eight bytes), sort in insertion order, and require a round trip before the identifier is known. That round trip breaks the pattern of building a complete object graph in memory and then persisting it, and it makes identifiers guessable, which matters when an identifier appears in a URL.

Random identifiers — UUID version 4 carries 122 random bits, conventionally written as 32 hex digits in five groups — can be generated anywhere with no coordination, which is their purpose. Their cost is that they are random: as a primary key they scatter B-tree inserts across the whole index and they inflate secondary indexes that store the key. A variant that embeds a millisecond timestamp in the high-order bits (UUID version 7, standardized in RFC 9562) restores locality while keeping distributed generation.

Natural keys — an ISO country code, a tax identifier, a SKU — are readable and require no generation, but they change, and when they change every reference must be rewritten. The usual resolution is to keep the natural key as an attribute with a uniqueness constraint and use a surrogate identifier for references.

Uniqueness scope matters as much as the format. An identifier unique within a tenant is not unique globally, and a model that assumes the latter will silently merge two tenants’ records the first time two sequences both reach 1. The scope belongs in the type: OrderId and CustomerId should not be interchangeable, and in a language with nominal typing they are not.

Where the distinction breaks down

The clean taxonomy has awkward cases. A bank account number looks like a natural key but the account it denotes can be closed and a new account opened under the same number. A version number identifies a revision of a document, so it behaves like a value in comparisons and like an entity in that each revision is a separate stored thing. A date range is a value, but “the billing period” is an entity when the business reasons about it as a thing with a lifecycle.

The working rule is to ask what the system must be able to say. If two instances must be distinguishable even when every attribute matches, the type is an entity. If the system must be able to merge two records because they denote the same thing, the type is a value. The answer can differ between bounded contexts for the same real-world noun, which is the subject of Bounded Contexts and Model Translation.

A last verification step is worth stating plainly: search the codebase for the entity type’s equals method. If it compares fields, either the type is a value that has been placed in an entity-shaped role, or there is a latent identity bug waiting for the first attribute edit to detach an object from its row.

References

  1. Domain-Driven Design: Tackling Complexity in the Heart of Software — Eric Evans, Addison-Wesley, 2003
  2. ValueObject — Martin Fowler, bliki
  3. Domain Modeling Made Functional — Scott Wlaschin, Pragmatic Bookshelf, 2018