Lesson 03 / 30
Builder
Building a multi-field object step by step: comparing an eight-positional-parameter call against a builder made of named steps under the same mutation set, counting how many swapped arguments and missing fields each design silently accepts, the drop in the highest parameter count passed, and the pattern's cost in lines.
Contents
The abstract factory returned three pieces together, but each piece was still a constant buildable with a single expression. The object at the center of the library is not: a shipment has a weight, three dimensions, an origin and a destination province, a declared value, and sometimes a contract number. Once eight fields are squeezed into one call, which number goes to which field stops being visible at the call site.
The builder breaks construction into named steps and produces the object only at the final step. This lesson’s measure is not how readable the construction looks: the same mutation set is applied to both designs, and how many mutations are silently accepted is counted. The pattern’s cost is measured the same way, in lines.
Eight Positional Parameters
In the first version, a shipment is built with a single call. No field is checked; whatever the call is given goes straight into the object.
// direct/shipment.mjs — eight positional parameters; no field is checked export function makeShipment(weight, width, length, height, originProvince, destinationProvince, declaredValue, contractNumber) { return { weight, width, length, height, originProvince, destinationProvince, declaredValue, contractNumber }; } const ZONE = { "34": 100, "06": 115, "65": 140 }; const volumetricWeight = (s) => Math.ceil((s.width * s.length * s.height) / 5000); export function fee(s) { const baseFee = 3900 + Math.ceil(Math.max(s.weight, volumetricWeight(s))) * 900; const factor = Math.max(ZONE[s.originProvince] ?? 160, ZONE[s.destinationProvince] ?? 160); const insurance = Math.round(s.declaredValue * 0.004); const discount = typeof s.contractNumber === "string" ? 500 : 0; return Math.round((baseFee * factor) / 100) + insurance - discount; }
The fee uses seven of the eight fields: weight and the three dimensions feed the base fee, the two province codes feed the zone factor, the declared value feeds insurance, the contract number feeds the discount. A field landing in the wrong place changes the fee.
Named Steps
In the second version, each step is a method: it checks its own field and returns the builder
itself. The object is produced on the build call; completeness of the required fields is
checked only there, in one spot.
// builder/shipment.mjs — named steps; each step checks its own field const ZONE = { "34": 100, "06": 115, "65": 140 }; const PROVINCE = /^[0-9]{2}$/; const REQUIRED = ["weight", "width", "length", "height", "originProvince", "destinationProvince", "declaredValue"]; const isNumber = (x, max) => typeof x === "number" && x > 0 && x <= max; export class ShipmentBuilder { #fields = {}; weight(kg) { if (!isNumber(kg, 50)) throw new RangeError("weight must be a number between 0 and 50 kg"); this.#fields.weight = kg; return this; } dimensions(width, length, height) { if (![width, length, height].every((x) => isNumber(x, 200))) throw new RangeError("dimension must be a number between 0 and 200 cm"); Object.assign(this.#fields, { width, length, height }); return this; } address(originProvince, destinationProvince) { if (![originProvince, destinationProvince].every((p) => typeof p === "string" && PROVINCE.test(p))) throw new RangeError("province code must be a two-digit string"); Object.assign(this.#fields, { originProvince, destinationProvince }); return this; } declaredValue(cents) { if (!Number.isInteger(cents) || cents < 0) throw new RangeError("declared value must be a non-negative integer"); this.#fields.declaredValue = cents; return this; } contract(number) { if (typeof number !== "string" || !/^S[0-9]{4}$/.test(number)) throw new RangeError("contract number must be S followed by four digits"); this.#fields.contractNumber = number; return this; } build() { const missing = REQUIRED.filter((k) => this.#fields[k] === undefined); if (missing.length > 0) throw new TypeError(`missing field: ${missing.join(", ")}`); return Object.freeze({ contractNumber: null, ...this.#fields }); } } const volumetricWeight = (s) => Math.ceil((s.width * s.length * s.height) / 5000); export function fee(s) { const baseFee = 3900 + Math.ceil(Math.max(s.weight, volumetricWeight(s))) * 900; const factor = Math.max(ZONE[s.originProvince] ?? 160, ZONE[s.destinationProvince] ?? 160); const insurance = Math.round(s.declaredValue * 0.004); const discount = typeof s.contractNumber === "string" ? 500 : 0; return Math.round((baseFee * factor) / 100) + insurance - discount; }
Three details belong to the pattern. Each step returns this, so steps chain. The required-field
list sits in one place, so the completeness check does too. The produced object is frozen; no
field can change after construction finishes, and if no contract number was given, build sets
the default null value.
// main.mjs — the same shipment built two ways import { makeShipment, fee as directFee } from "./direct/shipment.mjs"; import { ShipmentBuilder, fee as builderFee } from "./builder/shipment.mjs"; const s1 = makeShipment(2.4, 30, 40, 50, "34", "06", 180000, "S1042"); const s2 = new ShipmentBuilder() .weight(2.4) .dimensions(30, 40, 50) .address("34", "06") .declaredValue(180000) .contract("S1042") .build(); console.log(`direct ${directFee(s1)} cents`); console.log(`builder ${builderFee(s2)} cents`);
node main.mjs
direct 17125 cents builder 17125 cents
The same fee. One call line grew to seven; whether the gain is worth those lines can be counted.
Applying the Mutation Set
The measurer below produces two mutation sets. The first swaps every argument pair against the correct value sequence: 28 pairs for eight fields. The second drops each field in turn: 8 calls. Every mutated call’s result is sorted into four classes — those that throw during construction, those that give a non-numeric result, those that give exactly the correct result, and those that throw no error yet produce a different fee. The last class is the silently accepted wrong construction.
// test.mjs — produces broken calls and classifies their results import { readFileSync } from "node:fs"; import { makeShipment, fee as directFee } from "./direct/shipment.mjs"; import { ShipmentBuilder, fee as builderFee } from "./builder/shipment.mjs"; const CORRECT = [2.4, 30, 40, 50, "34", "06", 180000, "S1042"]; const buildDirect = (v) => directFee(makeShipment(...v)); const buildBuilder = (v) => builderFee(new ShipmentBuilder().weight(v[0]).dimensions(v[1], v[2], v[3]) .address(v[4], v[5]).declaredValue(v[6]).contract(v[7]).build()); function classify(build, values, expected) { let result; try { result = build(values); } catch { return "error"; } if (!Number.isFinite(result)) return "invalid"; return result === expected ? "same" : "silent"; } function count(label, build, sets) { const expected = build(CORRECT); const tally = { error: 0, invalid: 0, same: 0, silent: 0 }; for (const v of sets) tally[classify(build, v, expected)] += 1; console.log(`${label} calls=${sets.length} error=${tally.error} ` + `invalid=${tally.invalid} same=${tally.same} silent-wrong=${tally.silent}`); } const swaps = []; for (let i = 0; i < CORRECT.length; i += 1) for (let j = i + 1; j < CORRECT.length; j += 1) { const v = [...CORRECT]; [v[i], v[j]] = [v[j], v[i]]; swaps.push(v); } const missingFields = CORRECT.map((_, i) => CORRECT.map((d, k) => (k === i ? undefined : d))); const highestArity = (path) => Math.max(...[...readFileSync(path, "utf8").matchAll(/\(([^)]*)\)\s*\{/g)] .map((m) => m[1].split(",").filter((s) => s.trim() !== "").length)); console.log(`direct highest-parameter-count=${highestArity("direct/shipment.mjs")}`); console.log(`builder highest-parameter-count=${highestArity("builder/shipment.mjs")}`); count("direct swap ", buildDirect, swaps); count("builder swap ", buildBuilder, swaps); count("direct missing field ", buildDirect, missingFields); count("builder missing field ", buildBuilder, missingFields);
node test.mjs
direct highest-parameter-count=8 builder highest-parameter-count=3 direct swap calls=28 error=0 invalid=5 same=4 silent-wrong=19 builder swap calls=28 error=21 invalid=0 same=4 silent-wrong=3 direct missing field calls=8 error=0 invalid=5 same=0 silent-wrong=3 builder missing field calls=8 error=8 invalid=0 same=0 silent-wrong=0
Reading the Numbers
The first two lines are the Types of Coupling lesson’s measure: the highest parameter count
passed in a single call. It fell from eight to three, and the remaining three parameters are
three measures of the same kind (dimensions), so together they carry one meaning.
In the swap set, the first version threw an error in none of the 28 mutations. Five gave a result that was not a number — a detectable break. Four left the result unchanged. The remaining 19 mutations threw no error and produced a different fee; each of these, unless caught in code review, reaches a customer as the wrong amount.
In the second version, of the same 28 mutations, 21 threw an error at construction time, because a step expecting a number received a string or an out-of-range value. The silently accepted count fell from 19 to 3. The remaining three are the swaps between weight and width, weight and length, weight and height — all numbers of the same type in the same range, and no type check tells them apart. This is no longer a problem the builder can solve; keeping kilograms and centimeters apart takes separate types, the value object introduced in the Data Access Layer and Business Logic course.
The four unchanged mutations are not “correct” for that. Three of them are the symmetry in the volumetric weight product: width, length, and height stay the same product when swapped with each other, so the fee does not change. The fourth is the origin and destination provinces swapping; the tariff takes the larger of the two zone factors, so the fee comes out the same either way. The measure misses that error because the fee is not sensitive to it.
The missing-field set gives the pattern’s second gain. The first version threw no error on any
of the 8 omissions: five produced a non-numeric fee, three a silently wrong one. In the second,
all eight stopped at the build call, and the error message named the missing field. This is
the measurable counterpart to checking an object’s invariant in one spot.
Cost
The pattern’s cost shows up in line count.
for d in direct/shipment.mjs builder/shipment.mjs; do echo "$d: $(grep -c '' $d) lines" done
direct/shipment.mjs: 15 lines builder/shipment.mjs: 60 lines
The module building the same object grew from 15 lines to 60, and the call site stretched from one line to seven. Part of the cost belongs to the checks, part to the step methods themselves. The builder is therefore not applied while field count is low: a two- or three-field object already has a small swap set, and 45 extra lines do not pay for themselves.
A second cost is that the chain can be left half finished. As long as build is not called, the
builder object can be held onto, carrying a half-constructed state. Keeping that state from
leaking depends on no step besides build ever returning the object; correctly applying the
pattern comes with that constraint attached.
Summary
- The builder breaks a multi-field object’s construction into named steps and produces the object only at the final step; the required-field check gathers into that one spot.
- The highest parameter count passed in one call fell from 8 to 3, and the remaining three parameters are three measures of the same kind.
- Of 28 swapped arguments, the silently accepted count fell from 19 to 3; the remaining three are same-type numbers swapped with each other and can only be prevented with separate types.
- Of 8 missing-field calls, the first version threw no error at all; the second stopped at all eight, naming the missing field.
- The cost is a module that grew from 15 lines to 60 and a call site that stretched from one line to seven; while field count stays low, the pattern does not pay for that cost.
Next Step
The builder constructs every object from nothing. But most objects built in the library are small departures from each other: a contracted customer’s tariff is the standard tariff with just two fields changed, a zonal tariff the same with one zone factor changed. Rerunning every step for these wastes both the call line and any new field added to the standard tariff, which then has to be carried by hand into each derived tariff. The next lesson replaces building the object with copying and modifying an existing one: it counts, when a field is added to the standard tariff, how many derived tariffs are missing it in the hand-written version versus the copying version, and measures how many child objects a shallow copy shares with the prototype.
To keep your progress and take notes, Log in
My notes
Log in to take notes.