Primitive obsession & data clumps
Modelling money, email, or a date range as a raw string or number scatters validation and rules across the codebase. The same fields always travelling together signal a missing concept. The cure is a value object — applied where there's an invariant, not as reflex ceremony.
A number called amount is a lie of omission. It tells you the value is numeric; it tells you nothing about whether it is dollars or cents, whether it can be negative, what currency it carries, or what rounding rule applies when you split it three ways. So every place that touches money re-derives those answers — and they disagree. One module stores cents, another stores dollars, a third divides by 100 “to be safe,” and a refund is suddenly off by two orders of magnitude.
That bug is not a typo. It is the predictable result of representing a domain concept — money — as a raw primitive. The same disease shows up when fields refuse to travel alone: lat and lng, startDate and endDate, always passed side by side, always validated separately, always one parameter-swap away from a defect. These are two of the most common bloaters you will meet, and they share one cure.
After this lesson you can recognise primitive obsession (a domain concept modelled as string/number/boolean) and data clumps (the same group of values that always travel together); explain why both scatter validation and rules until a value object pulls them back to one place; introduce a value object that centralises the invariant and behaviour; and — the senior half — judge when wrapping a primitive earns its keep versus when it is ceremony that buys nothing.
Primitive obsession is using a language primitive to stand in for a domain concept, which forces every caller to know the concept’s rules. A money value has invariants (a currency, a scale, non-negativity for a price) and behaviour (add only same-currency, round on division). Modelled as number, none of that travels with the value, so each call site re-implements it — inconsistently.
// Money as a bare number: the rules live nowhere, so they live everywhere.
function priceWithTax(amount: number): number {
return amount + amount * 0.2; // dollars? cents? can amount be -5? nobody knows
}
const refund = order.total / 3; // float division → 33.333333, a fraction of a cent
const eur = usdAmount; // type system is fine with mixing currenciesEach of those lines is locally reasonable and globally wrong. The number carries no scale, no currency, no rounding policy — so the policy is duplicated, and duplicated rules drift.
A value object centralises the validation and behaviour the primitive scattered, so the rule has exactly one home. Wrap the concept in a small immutable type that owns its invariants and operations. Now there is one place the rule can be wrong, and one place to fix it.
class Money {
private constructor(readonly cents: number, readonly currency: string) {}
static of(cents: number, currency: string): Money {
if (!Number.isInteger(cents)) throw new Error("Money is whole cents");
if (cents < 0) throw new Error("Money cannot be negative");
return new Money(cents, currency);
}
add(other: Money): Money {
if (other.currency !== this.currency) throw new Error("currency mismatch");
return new Money(this.cents + other.cents, this.currency);
}
}Construction is the only door, so an invalid Money cannot exist. Mixing currencies is now a thrown error instead of a silent usd + eur. The validation that was copy-pasted across priceWithTax, refund, and convert collapses into the type — the change-amplification lever from unit 00, applied to a single value.
A data clump is the same group of values that always travel together; recurring togetherness is the runtime telling you a concept is missing. When lat and lng appear in the same parameter list everywhere, validated separately everywhere, the pair is a concept — Coordinate — that you simply haven’t named yet.
// Before: lat & lng are a clump — three params that are really one thing,
// plus a parameter-order bug waiting to happen.
function distance(lat1: number, lng1: number, lat2: number, lng2: number): number { /* … */ }
distance(lng1, lat1, lng2, lat2); // swapped, compiles, ships, wrong by a continent
// After: name the concept the clump was pointing at.
class Coordinate {
constructor(readonly lat: number, readonly lng: number) {
if (Math.abs(lat) > 90 || Math.abs(lng) > 180) throw new Error("out of range");
}
distanceTo(other: Coordinate): number { /* … */ }
}The clump didn’t just shorten the signature. It gave the range check one home, killed the order-swap class of bug, and gave behaviour (distanceTo) a natural place to live instead of floating as a free function with four loose arguments.
The failure mode is the opposite reflex: wrapping every primitive in a class even when it carries no invariant or behaviour — ceremony without payoff. A value object pays for itself when there is a rule to centralise: a validation, an arithmetic that must stay consistent, a unit that must not be confused. A primitive that has none of those gains nothing from a wrapper but indirection.
// Payoff: EmailAddress has a real invariant (must be a valid address) reused everywhere.
class EmailAddress { /* validates once, every caller trusts it */ }
// Ceremony: a wrapper with zero invariant and zero behaviour.
class FirstName {
constructor(readonly value: string) {} // any string is a valid first name
}
// Now every read is `name.value` and you've added a type that buys nothing.The senior test is not “is this a primitive?” but “does this concept have a rule or behaviour that is currently duplicated or unenforced?” If yes, the value object centralises it. If no, leave the primitive — a type alias (type UserId = string) often captures the intent at zero ceremony. Over-wrapping is its own smell; it just costs reading time instead of correctness.
A money-as-number bug, fixed by a Money type. A checkout stores prices in cents but a discount helper was written assuming dollars:
// Before — the unit lives in the programmer's head, not the value.
function applyDiscount(priceCents: number, percentOff: number): number {
const discount = priceCents * (percentOff / 100);
return priceCents - discount; // ok so far
}
// elsewhere, a teammate "helpfully" converts, assuming dollars:
const display = applyDiscount(1999, 10) / 100; // 17.991 → renders "$17.99"
const refundCents = applyDiscount(1999, 10); // 1799.1 → a fractional cent
chargeCard(refundCents); // gateway rejects / rounds unpredictably1799.1 cents is not a representable charge, and nothing in the signature stopped it. The fix is not “remember to round” sprinkled at each site — it is to make the invalid value unconstructible:
// After — Money owns whole-cents and currency; division must state its rounding.
class Money {
private constructor(readonly cents: number, readonly currency: string) {}
static of(cents: number, currency: string): Money {
if (!Number.isInteger(cents)) throw new Error("cents must be integer");
return new Money(cents, currency);
}
percentOff(pct: number): Money {
// explicit rounding policy, decided once, here.
const off = Math.round(this.cents * (pct / 100));
return new Money(this.cents - off, this.currency);
}
}
const price = Money.of(1999, "USD");
const discounted = price.percentOff(10); // Money { cents: 1799, "USD" } — always integer cents
chargeCard(discounted); // gateway always gets a valid amountThe bug class — fractional cents, dollar/cent confusion, unrounded division — cannot recur, because the only way to make a Money is through a door that enforces the invariant, and the rounding decision now has a single named home instead of being re-guessed per call site.
▸Why this works
Why does naming a recurring clump matter beyond a shorter signature? Because a clump is connascence of position turned into a defect generator: four loose arguments of compatible types let distance(lng, lat, …) compile and ship a continent-sized error. Bundling them into Coordinate converts a positional contract (callers must remember the order) into a named one (callers pass a Coordinate), and it gives the validation and the distanceTo behaviour a place to live. The smell — “these always appear together” — was design feedback all along: the runtime was describing a concept your type system hadn’t admitted yet.
▸Common mistake
The over-correction is treating “no naked primitives” as a law. Wrapping FirstName, MiddleName, Title — each a { value: string } with no invariant — adds a type, an unwrap (.value) at every read, and serialization friction, while preventing exactly zero bugs. A value object earns its place by centralising a rule; with no rule there is nothing to centralise. When you only want to stop mixing two same-typed ids, reach first for the cheapest tool that expresses intent — a type UserId = string alias or a branded type — and escalate to a full value object only when there is genuine validation or behaviour to own.
Across the codebase, prices are passed as bare `number`s and a discount helper produced a fractional-cent charge the payment gateway rejected. A teammate proposes wrapping every primitive field in the system — including `firstName` and `title` — in its own class. What is the senior call?
Primitive obsession models a domain concept — money, email, a date range — as a raw string or number, which forces every caller to re-derive the concept’s validation and rules until they drift and a refund lands two orders of magnitude off. Data clumps are the same fields always travelling together (lat+lng, start+end); their recurring togetherness is the runtime naming a concept your types haven’t admitted. The cure for both is a value object / whole-object: an immutable type whose constructor is the only door, pulling the invariant and behaviour into one home so callers depend on the concept, not the rule. The senior discipline is knowing when not to: a wrapper with no invariant and no behaviour is ceremony without payoff — prefer a type alias there, and reserve the value object for concepts that actually carry a rule worth centralising.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.