Skip to content
academia.sh

Lesson 04 / 19

Value Objects

The selection criterion for immutable objects defined by value: counting how many wrong amounts the bare-number model produces from twelve inputs arriving in grams, measuring how many lines the equality rule is written by hand, working out how many shipments break when a shared measurement is corrected in place, and showing that the two models produce the same grouping.

Contents

The previous lesson’s last section counted the concepts that do not need identity, but still kept them as bare numbers: weight was a number, zone was a string. That leaves passing a kilogram and a gram into the same parameter, adding them, and comparing them as operations the code allows.

An object with no identity, whose equality is defined by the value it carries, and which does not change once constructed, is called a value object. The definition was given in the Introduction to Object-Oriented Programming course. The work here is again the selection criterion, and it splits into three questions: is identity needed, what is equality built on, and what does immutability make cheaper.

Criterion

If the previous lesson’s two questions both come back no for a concept, that concept is not an entity: there is no distinction between two instances of it, and it carries no life story. Weight is like this. 2.4 kilograms and another 2.4 kilograms are the same thing; correcting the weight is not the weight changing, it is the shipment coming to have a different weight.

The second question is what equality is built on, and its answer includes the measurement’s unit. 2400 grams and 2.4 kilograms are equal; 2400 and 2.4 are not. The difference is where the unit is kept.

The Bare-Number Model

In the first version, the unit is written only in the parameter name.

mkdir -p bare value
// bare/fee.mjs — weight is a bare number, the unit lives only in the parameter name
const TIERS = [[1, 3900], [5, 6400], [Infinity, 11800]];
const ZONE_COEFFICIENTS = { "34": 1, "06": 1.35, "65": 1.8 };
const MINIMUM_FEE = 3990;

export function netFee(weightKg, zone, rate) {
  const base = TIERS.find(([cap]) => weightKg <= cap)[1];
  const raw = base * (ZONE_COEFFICIENTS[zone] ?? 1.8);
  return Math.max(Math.round(raw * (1 - rate)), MINIMUM_FEE);
}
// bare/reconciliation.mjs — the equality rule is rewritten at every call site
export const weighInMatches = (declaredKg, measuredKg) => Math.abs(declaredKg - measuredKg) < 0.001;
export const sameTier = (aKg, bKg) => Math.abs(aKg - bKg) < 0.001;
export const addWeight = (aKg, bKg) => aKg + bKg;
// bare/report.mjs — grouping by weight; the equality rule is written here too
export function weightGroups(weightsKg) {
  const groups = [];
  for (const w of weightsKg) {
    const group = groups.find((g) => Math.abs(g[0] - w) < 0.001);
    if (group) group.push(w);
    else groups.push([w]);
  }
  return groups;
}

Comparing floating-point numbers requires a tolerance margin, and that margin is written three times across three files. The size of the margin is a domain decision — how many grams apart can two weights still count as the same — but in the code it does not look like a domain decision.

The Value-Object Model

In the second version, weight gets its own type. The unit is resolved in the constructor, the internal representation is grams, and the object is frozen.

// value/weight.mjs — immutable value object: the unit lives inside the type
const GRAMS = { kg: 1000, g: 1 };

export class Weight {
  constructor(amount, unit) {
    if (!(unit in GRAMS)) throw new RangeError(`unknown weight unit: ${unit}`);
    this.grams = Math.round(amount * GRAMS[unit]);
    Object.freeze(this);
  }
  static kg(amount) { return new Weight(amount, "kg"); }
  static g(amount) { return new Weight(amount, "g"); }
  kilogram() { return this.grams / 1000; }
  equals(o) { return o instanceof Weight && o.grams === this.grams; }
  plus(o) { return new Weight(this.grams + o.grams, "g"); }
}

The tolerance margin disappears because the internal representation is an integer: rounding happens once, in the constructor, and comparison becomes integer equality. plus returns a new object; it does not change its caller.

// value/fee.mjs — expects a Weight instead of a bare number, rejects a bare number
import { Weight } from "./weight.mjs";

const TIERS = [[1, 3900], [5, 6400], [Infinity, 11800]];
const ZONE_COEFFICIENTS = { "34": 1, "06": 1.35, "65": 1.8 };
const MINIMUM_FEE = 3990;

export function netFee(weight, zone, rate) {
  if (!(weight instanceof Weight)) throw new TypeError("weight must be a Weight");
  const base = TIERS.find(([cap]) => weight.kilogram() <= cap)[1];
  const raw = base * (ZONE_COEFFICIENTS[zone] ?? 1.8);
  return Math.max(Math.round(raw * (1 - rate)), MINIMUM_FEE);
}
// value/reconciliation.mjs — the equality rule lives inside the value object, the call site invokes it
export const weighInMatches = (declared, measured) => declared.equals(measured);
export const sameTier = (a, b) => a.equals(b);
export const addWeight = (a, b) => a.plus(b);
// value/report.mjs — the same grouping; equality is the value object's own method
export function weightGroups(weights) {
  const groups = [];
  for (const w of weights) {
    const group = groups.find((g) => g[0].equals(w));
    if (group) group.push(w);
    else groups.push([w]);
  }
  return groups;
}

Measurement

Three measures are taken. Half of twenty-four inputs arrive in grams; how many of them produce a silently wrong amount is counted. How many lines the equality rule is written on is scanned. While five shipments share the same measurement, how many break when one is corrected is measured.

// value-measure.mjs — unit mix-ups, where the equality rule lives, and a shared instance breaking
import { readFileSync } from "node:fs";
import { netFee as bareFee } from "./bare/fee.mjs";
import { netFee as valueFee } from "./value/fee.mjs";
import { Weight } from "./value/weight.mjs";
import { weightGroups as bareGroups } from "./bare/report.mjs";
import { weightGroups as valueGroups } from "./value/report.mjs";

const ZONES = ["34", "06", "65"];
const INPUTS = [];
for (let i = 0; i < 24; i += 1) {
  const kg = [0.4, 0.9, 2.4, 3.1, 7, 12][i % 6];
  INPUTS.push({ source: i % 2 === 0 ? "kg" : "g", kg, zone: ZONES[i % 3] });
}
// correct fee: the rule applied to the true kilogram value
const correctFee = (g) => bareFee(g.kg, g.zone, 0);

let bareWrong = 0;
for (const g of INPUTS) {
  const raw = g.source === "kg" ? g.kg : g.kg * 1000;
  if (bareFee(raw, g.zone, 0) !== correctFee(g)) bareWrong += 1;
}
let valueWrong = 0;
let valueError = 0;
for (const g of INPUTS) {
  const raw = g.source === "kg" ? g.kg : g.kg * 1000;
  if (valueFee(new Weight(raw, g.source), g.zone, 0) !== correctFee(g)) valueWrong += 1;
  try { valueFee(raw, g.zone, 0); } catch { valueError += 1; }
}
console.log(`24 inputs, 12 arrive in grams`);
console.log(`  bare : silently wrong fee ${bareWrong}/24, thrown error 0/24`);
console.log(`  value: silently wrong fee ${valueWrong}/24, ` +
  `error when a bare number is passed ${valueError}/24`);

const RULE = /Math\.abs\(|\.grams ===/;
for (const [label, files] of Object.entries({
  bare: ["bare/fee.mjs", "bare/reconciliation.mjs", "bare/report.mjs"],
  value: ["value/fee.mjs", "value/reconciliation.mjs", "value/report.mjs", "value/weight.mjs"],
})) {
  const lines = files.flatMap((f) => readFileSync(f, "utf8").split("\n"));
  console.log(`${label}: equality-rule line ${lines.filter((s) => RULE.test(s)).length}`);
}

const MEASUREMENTS = [0.4, 2.4, 0.4, 7, 2.4, 12, 7, 0.9];
const bareResult = bareGroups(MEASUREMENTS).map((g) => g.length).join(",");
const valueResult = valueGroups(MEASUREMENTS.map((k) => Weight.kg(k))).map((g) => g.length).join(",");
console.log(`8 readings grouped -> bare [${bareResult}], value [${valueResult}], ` +
  `same ${bareResult === valueResult}`);

const shared = { amountKg: 2.4 };
const shipments = [0, 1, 2, 3, 4].map(() => ({ weight: shared }));
shared.amountKg = 3.1;
const broken = shipments.slice(1).filter((s) => s.weight.amountKg !== 2.4).length;

const firstWeight = Weight.kg(2.4);
const shipmentsValue = [0, 1, 2, 3, 4].map(() => ({ weight: firstWeight }));
shipmentsValue[0].weight = Weight.kg(3.1);
const brokenValue = shipmentsValue.slice(1).filter((s) => !s.weight.equals(Weight.kg(2.4))).length;
let inPlaceError = 0;
try { firstWeight.grams = 3100; } catch { inPlaceError = 1; }

console.log(`5 shipments share the same weight instance, one is corrected`);
console.log(`  mutable record: broken shipments ${broken}/4`);
console.log(`  value object  : broken shipments ${brokenValue}/4, ` +
  `in-place mutation attempt error ${inPlaceError}`);
node value-measure.mjs
24 inputs, 12 arrive in grams
  bare : silently wrong fee 8/24, thrown error 0/24
  value: silently wrong fee 0/24, error when a bare number is passed 24/24
bare: equality-rule line 3
value: equality-rule line 1
8 readings grouped -> bare [2,2,2,1,1], value [2,2,2,1,1], same true
5 shipments share the same weight instance, one is corrected
  mutable record: broken shipments 4/4
  value object  : broken shipments 0/4, in-place mutation attempt error 1

The first measure answers the unit question. In eight of the twelve inputs arriving in grams, the bare-number model produces a wrong amount, and it does so silently: the error count is zero, the caller gets no warning. In the remaining four inputs, the amount comes out right by coincidence, because the 7- and 12-kilogram shipments fall into the top tier even read as grams; a coincidence is not a guarantee. In the value-object model, the wrong-amount count is zero. In the same model, when a bare number is passed, twenty-four of twenty-four calls throw an error — a silent wrong has turned into a loud one.

The second measure answers the equality question. The tolerance margin is written on three lines in the bare model; in the value-object model, on one line, inside the equals method. The fact that the margin is a domain decision becomes visible in the second case: if it has to change, there is one place to touch.

The third measure shows what immutability makes cheaper. When a mutable record is shared by five shipments, one correction breaks four of them. In the value-object model, the same correction produces a new object, so the broken-shipment count is zero. On the frozen object, an in-place mutation attempt throws an error. The gain is not having to write a defensive copy: sharing becomes free.

The grouping line again says alignment is separate from correctness: eight readings sort into the same five groups in both models.

What a Value Object Can Carry

The real gain a value object carries is being able to carry its own rules. Weight cannot be negative, a zone code is two digits, a discount rate is between zero and one. In the bare-number model, these rules scatter across call sites; in the value object, they go into the constructor and are written once. The result is that a value violating the rule can never exist at all — this is exactly the kind of reachability the next lesson measures.

The library’s second candidate for the same treatment is the monetary amount: mixing up cents and the main currency unit produces the same error as weight, and the fix is the same — a type that keeps the amount as an integer number of cents and carries equality and addition on itself.

When It Does Not Pay Off

The cost has two line items: one type per concept, and one method call per access. This cost shows up in the measurement files — the value-object version holds four files, the bare version three.

Where a concept is read once at a single point and never used again, this cost goes unpaid for. In a raw record crossing a boundary, weight is a number; converting it to a type only pays off once that value starts circulating inside the library. Likewise, a measurement with a single unit that is never compared — a sequence number written to a log, for instance — does not earn a type of its own.

Summary

  • A value object has no identity, its equality is built on the value it carries, and it does not change once constructed; its criterion is three questions: identity, equality, immutability.
  • In 8 of twelve inputs arriving in grams, the bare-number model silently produced a wrong amount; in the value-object model the wrong-amount count was 0, and passing a bare number produced an error in 24 of 24 calls.
  • The equality tolerance margin is written on 3 lines in the bare model and on 1 line in the value-object model; the domain decision sits at a single point in the second case.
  • While five shipments shared the same measurement, one correction broke 4 shipments in the mutable record and 0 in the value object; on the frozen object, in-place mutation produced an error.
  • Eight readings sorted into the same groups in both models: the gain is not in correctness, it is in making the wrong value unwritable.
  • The cost is one type per concept and one call per access; for raw values read once and left alone, this cost goes unpaid for.

Next Step

So far, the shipment has been treated on its own. In the domain, it is not on its own: it has discounts, a contract, and declared items, and a rule stands between them — the sum of discount rates cannot exceed the cap. This rule does not belong to a single object; it belongs to the whole of the shipment and its discounts. The next lesson draws that whole’s boundary: it counts how many separate call paths in the code can produce a state that violates the rule, compares a model that exposes the boundary against one that only lets access pass through the root, and measures how many separate wholes a single transaction writes.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close