DomainGENERALLANGUAGE-SPECIFICCONTESTED

Value Objects

Things defined entirely by their value — Money, EmailAddress, Coordinates. The highest-value, lowest-cost idea in this module, and the one worth adopting even if you take nothing else.

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 have no identity at all, and what do I get by giving them a name instead of passing a number around?

The requirement

A bug report: a customer in Japan was charged 4,500 instead of 45.00. Somewhere a value in minor units was added to a value in major units, and every function in the path took a plain number.

The obvious build

Money is a number. Email is a string. Coordinates are two numbers. The language has those types already, they are fast, they serialize for free, and everyone knows how to use them.

Why it breaks

The type says number and the meaning says "euros, or maybe cents, depending which function you are in". The compiler cannot help, and every new call site is a fresh chance to get the unit wrong (Units in Names and Types).

How it breaks as requirements change
  • The type says number and the meaning says "euros, or maybe cents, depending which function you are in". The compiler cannot help, and every new call site is a fresh chance to get the unit wrong (Units in Names and Types).
  • Validation gets repeated at every entry point — six regexes for an email address, five of them slightly different — and then omitted at the seventh, which is the one the bug comes through.
  • Currency has nowhere to live, so it travels beside the amount as a second parameter, and a (amount, currency) pair passed in the wrong order compiles cleanly.
  • When the rounding rule changes — "round half up per line, not per order" — it has to be found in every place a division happens, and the places are unmarked.
  • Adding a fifth currency requires reading every arithmetic expression in the codebase, because none of them declared what they assumed.
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 system handles four currencies, and JPY has no minor unit while EUR and USD have two.
  • Prices arrive from three sources — the catalogue, a promotions engine and a partner feed — each with its own convention.
  • Floating point is not acceptable for money, and the existing code uses it in about forty places.
Invariants
  • Two amounts in different currencies are never added, subtracted or compared.
  • A monetary amount never loses precision through a rounding nobody chose.
  • An email address that exists in the system has been validated exactly once, at the point it entered.

Who owns what, and where the seams fall

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

Responsibilities
  • The value object owns its own validity: an instance that exists is valid, and there is no other way to make one (Parse, Do Not Validate is the Backend statement of the same idea).
  • It owns its operations — add, allocate, format — so that the rules about combining values live with the values.
  • It owns its formatting only if formatting is domain behaviour; locale-specific display usually belongs at the presentation edge, not in the model.
  • It owns nothing about persistence, identity or lifetime, because it has none of those.
Boundaries
  • The boundary is the constructor. Everything outside it may hold invalid data; everything inside it may not, and that line is what makes the type worth having.
  • The seam between value object and entity is the identity question: if it never matters which one this is, it is a value (Entities).
  • Serialization sits outside: the value object exposes a primitive representation for the wire, and the mapping is explicit rather than automatic.

Fifteen lines that make the bug unwriteable

The Japan bug had one cause: two numbers were added and neither of them said what it was. No amount of review discipline reliably catches that, because the wrong code looks exactly like the right code.

Note what the type is doing beyond holding data. It refuses invalid construction, it refuses invalid combination, and it owns the one rounding decision the domain has. That is three rules with one home, and none of them can be forgotten at a call site.

Money — the whole thing
1const MINOR_UNITS = { EUR: 2, USD: 2, JPY: 0, KWD: 3 } as const
2type Currency = keyof typeof MINOR_UNITS
3
4export class Money {
5 private constructor(readonly minor: bigint, readonly currency: Currency) {}
6
7 static of(minor: bigint, currency: Currency): Money {
8 if (minor < 0n) throw new RangeError('negative amount')
9 return new Money(minor, currency)
10 }
11
12 add(other: Money): Money {
13 if (other.currency !== this.currency) throw new CurrencyMismatch(this.currency, other.currency)
14 return new Money(this.minor + other.minor, this.currency)
15 }
16
17 /** split across n lines losing nothing: the remainder goes to the first lines */
18 allocate(n: number): Money[] {
19 const base = this.minor / BigInt(n)
20 let rest = this.minor - base * BigInt(n)
21 return Array.from({ length: n }, () => {
22 const extra = rest > 0n ? 1n : 0n
23 rest -= extra
24 return new Money(base + extra, this.currency)
25 })
26 }
27}

The private constructor is what makes this work: Money.of is the only door, so there is no instance in the system that skipped the check. allocate exists because "split a total across lines" is a domain rule with a legal answer, and leaving it to whoever writes the division is how a cent goes missing per invoice for a year.

What the signature stops being able to say

The strongest argument for value objects is not in the class, it is in the signatures of every function that used to take primitives. Read the two versions below as a caller who has never seen either.

The second one has fewer parameters, and the ones it has cannot be swapped. That is not tidiness — it deletes an entire category of call-site bug, and it does so at compile time in a typed language and at construction time in an untyped one.

The same operation, two signatures
Primitives: every caller re-derives the meaning
function charge(
  amount: number,        // cents? euros? which currency?
  currency: string,      // 'EUR' or 'eur' or 'Eur'?
  taxRate: number,       // 0.19 or 19?
  email: string,         // validated where?
): void

// all of these compile:
charge(45, 'EUR', 19, 'not-an-email')
charge(4500, 'eur', 0.19, ' spaces@example.com ')
Values: the illegal call does not compile
function charge(
  amount: Money,
  taxRate: TaxRate,
  to: EmailAddress,
): void

// currency travels inside Money, so it cannot be mismatched
// TaxRate knows whether it is a fraction or a percentage
// EmailAddress cannot be constructed from 'not-an-email'
charge(Money.of(4500n, 'EUR'), TaxRate.percent(19), EmailAddress.parse(input))

The parameter count drops from four to three and, more importantly, the remaining three are mutually unswappable — the compiler rejects an argument order that the first version accepts silently. Validation happens once at parse instead of once per function that felt responsible, which is also why the second version has one place to change when the email rule changes (Parse, Do Not Validate).

How much of this to adopt

The failure mode is not under-adoption, it is uniform adoption. Wrapping every string produces a codebase where finding the actual rules is harder, because they are buried among a hundred wrappers that do nothing.

The scoring below is deliberately coarse. The number that matters is the last row: a partial adoption — value objects for the things with rules, primitives for the things without — is usually the best design and is rarely proposed, because it does not sound like a principle.

How far to take it
OptionSimplicityFlexibilityPerformanceTestabilityMigration costNote
Primitives everywhereNothing to learn and nothing to map. Every rule is enforced by convention and review, which works until the codebase outgrows the people who remember the conventions.
Value objects for values with rulesMoney, EmailAddress, Coordinates, TaxRate, Percentage, DateRange. Roughly a dozen types in a typical system, each of them small and heavily tested. This is the recommendation.
Wrap every primitiveA type per field. The rules that matter are now indistinguishable from the ceremony around them, and the mapping layer doubles in size for no additional safety.

caveat The scores say nothing about the thing that actually decides: whether values in your system get combined arithmetically or merely carried. A pipeline that reads a price and writes it unchanged gets almost nothing from Money; a billing engine that adds, splits, discounts and rounds it gets most of its correctness from exactly this type. The same table would score very differently for those two systems, which is why no table replaces looking at what your code does with the values.

How to build it

Most important first.

  • Make it immutable. Operations return new instances; a value that can be mutated in place gives every holder of a reference a way to change everyone else's value (Immutability).
  • Validate in the constructor and make the constructor the only door — a private constructor plus a static factory that returns a result is the version that composes with error handling (Result Types).
  • Define equality by value, which is the language default in most modern type systems and is exactly right here.
  • Put the operations on it. price.add(shipping) that throws on a currency mismatch is the entire fix for the bug above, and it is fifteen lines.
  • Include the unit in the type, not the name: Money carrying a currency beats amountInCents, because the name is advisory and the type is not.
  • Do this for values with rules. A FirstName wrapper around a string with no validation and no operations is ceremony, and it is the version of this idea that gives it a bad reputation (Over-Design and Under-Design).

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: "add JPY, which has no minor unit" costs an audit of every arithmetic expression touching money, because none of them says what it assumes. There is no bounded list.
  • After: it is one entry in a currency table inside Money, plus the tests for it. Nothing outside Money changes, because nothing outside Money does arithmetic on amounts.
  • The change that stays expensive: altering the wire representation of money — minor units to a decimal string — still touches every serializer and every consumer, because that boundary is a contract and the value object does not shield it.
  • One more that gets cheaper and is easy to miss: adding a rounding rule for allocation across order lines becomes a method with a property test, rather than a change to a division buried in a controller.
What the recommended approach costs
  • More allocation and more objects. In a hot loop over millions of prices this is measurable, and the honest answer there is to use primitives inside the loop behind a value-object boundary (Allocation and Copies).
  • Serialization frameworks and ORMs need explicit mapping, and in some ecosystems that is genuinely irritating boilerplate.
  • It is easy to overshoot. The discipline that produces Money also produces CustomerFirstName, and the second one costs the same and buys nothing.

What can go wrong

Failure modes
  • The value object is created but the primitives survive alongside it, so half the codebase uses Money and half uses number, and conversions between them are where the bugs move to.
  • Validation is put in the constructor and then bypassed by a deserializer that constructs instances reflectively — a very common and completely silent hole.
  • Every primitive gets wrapped, including ones with no rules, and the codebase acquires two hundred single-field types that make the code longer and no safer.
  • The mitigation fails too: an exception-throwing constructor makes it impossible to parse a batch of user input without exception-driven control flow, so someone adds a tryParse that quietly returns null and the invalid values come back (Optional Values and Absence).
Dependencies, and their direction
  • Nothing depends on a value object except the code that uses it, and it depends on nothing at all — no clock, no database, no configuration. That is why it is the cheapest thing in the model to test.
  • Entities depend on value objects; the arrow never points back (Dependency Direction).
  • The wire format depends on the value object's primitive representation, which is the one place a change to it can leak outward (Backward Compatibility as a Constraint).
Misreads
  • "Wrap every primitive." No. Wrap the ones with rules, units, or invariants. The test is whether there is something the type can refuse (Primitive Obsession is a smell, not a mandate).
  • "Value objects are just structs." The struct is the easy half. The value is in the constructor that refuses invalid data and the operations that refuse invalid combinations — a struct with public fields and no rules gives you nothing.
  • "This is a DDD pattern, so it comes with aggregates and repositories." It does not. Value objects are separable from everything else in this module and are worth adopting on their own, in a codebase with no other DDD in it (When Domain-Driven Design Does Not Pay).
  • "Immutable means slow." Usually irrelevant, occasionally true, and always measurable. Measure before restructuring the model around it (Premature Optimization, Reclaimed).
Smells this explains
  • primitive-obsession
  • long-parameter-list

Testing it, and how it ages

What to test, and at which boundary
  • Unit-test the value object exhaustively; it has no dependencies, so this is the cheapest test in the system and the one with the highest return.
  • Property tests are unusually effective here: allocation must never lose or create a cent, and a.add(b).subtract(b) must equal a for all valid values (Property-Based Testing).
  • A test that the deserialization path goes through the same validation as the constructor, because that is the hole that actually appears in production.
  • Reject the mismatch explicitly: a test that adding EUR to USD fails loudly rather than producing a number.
How this design ages
  • Value objects are unusually stable. Money written on day one is typically recognisable five years later, because the concept does not change even as the business does.
  • They grow operations rather than fields, which is the healthy direction — a type that accumulates fields is turning into an entity and should be examined.
  • The pressure that eventually forces change is external: a new currency with different precision, a tax authority requiring a different rounding rule, or a partner whose wire format differs.

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 a type carrying its unit and its validity removes a class of bug is true anywhere a type system exists; only the syntax and the enforcement strength change.
  • LANGUAGE-SPECIFICIn Rust or Haskell a newtype with a private constructor makes the invalid value genuinely unconstructible outside the module, and the compiler enforces it with no runtime cost. In TypeScript the same shape is erased at runtime and a cast or a JSON parse walks straight past it, so the design needs a runtime guard at every entry point and a test to prove it is there. Python and Ruby rely entirely on convention, which makes the discipline weaker but the boilerplate cheaper.
  • CONTESTEDThe strongest opposing view: in data-heavy and analytical code, wrapping primitives is pure overhead — allocation, mapping code, framework friction — and the same errors are caught by schema validation at the boundary plus tests. That case is genuinely strong for pipelines where values are passed through rather than combined, and genuinely weak wherever arithmetic happens.

Where the depth lives

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

Concurrencyimmutability
Domains that do not exist yet
  • Programming Languages & Runtime Internals — whether a wrapper type costs an allocation or is erased at compile time is a language-implementation question, and it decides whether the performance objection to value objects is real in your runtime.