Lesson 02 / 14
Encapsulation
Encapsulation as the tool that determines an invariant's owner: applying the same sequence of operations to an open-field and a closed-field version of the same consignment, counting how many objects fall into a corrupted state, and where protection breaks when a return value is not encapsulated.
Contents
The previous lesson showed that the split fee-calculation flow left a gap: the rule
“every shipment a fee is calculated for has been validated” was protected nowhere,
living only in the calculateFee procedure’s call order. Procedures take data as an
argument and carry no guarantee about its history.
Encapsulation is the tool that closes this gap: it gathers data and the operations that change it into a single unit and limits access to that data to those operations. This is the definition introduced in the Programming Fundamentals course, and this lesson does not repeat it; the question asked is different — when encapsulation actually works, where it breaks, and what it costs.
What Encapsulation Closes
A common misreading is that encapsulation means “hiding all fields.” The measure is not privacy but the invariant. If a field enters an invariant’s equation, it must not be writable from outside; if it does not, hiding it gains nothing.
In this course’s library, a consignment is the set of shipments loaded onto a vehicle,
and it carries three invariants:
- The recorded total weight equals the sum of the loaded shipments’ weight.
- The total weight does not exceed the vehicle’s capacity.
- No shipment has been added to a sealed consignment.
capacityGrams is one side of the second invariant; a single outside assignment would
violate it. vehicleId, on the other hand, appears in no equation; hiding it only forces
writing an accessor. The decision is made field by field, not by closing the class
wholesale.
The invariants’ check is written in a single place so the same measure can be applied to both versions. The same file also holds a seeded number generator, so the runs are reproducible.
// checker.mjs — defines all three invariants in one place so both versions get the same check export function violations(d) { const broken = []; const total = d.shipments.reduce((t, g) => t + g.weightGrams, 0); if (total !== d.totalWeight) broken.push("total-mismatch"); if (d.totalWeight > d.capacityGrams) broken.push("capacity-exceeded"); if (d.sealed && d.shipments.length > d.sealedCount) broken.push("post-seal-addition"); return broken; } export function generator(seed) { let x = seed; return () => (x = (x * 1103515245 + 12345) % 2147483648) / 2147483648; }
The Open-Field Version
The first version keeps the consignment as a plain object and writes the rules into an
add procedure. The rules exist here; what is missing is that passing through them
is mandatory.
// consignment-open.mjs — consignment with open fields; rules exist in add() but are not mandatory export function createConsignment(vehicleId, capacityGrams) { return { vehicleId, capacityGrams, shipments: [], totalWeight: 0, sealed: false, sealedCount: 0 }; } export function add(consignment, shipment) { if (consignment.sealed) return false; if (consignment.totalWeight + shipment.weightGrams > consignment.capacityGrams) return false; consignment.shipments.push(shipment); consignment.totalWeight += shipment.weightGrams; return true; } export function seal(consignment) { consignment.sealed = true; consignment.sealedCount = consignment.shipments.length; }
Here, writing consignment.shipments.push(g) is shorter than writing
add(consignment, g) and looks like it produces the same result. In a growing
codebase, this shortcut gets used sooner or later — a transfer step, an import script, a
bug fix. Its result is two skipped checks.
The Closed-Field Version
In the second version, the four fields entering the invariant are private, and the only path visible from outside is two methods.
// consignment-closed.mjs — fields entering the invariant are private; every change goes through two methods export class Consignment { #capacityGrams; #shipments = []; #totalWeight = 0; #sealed = false; #sealedCount = 0; constructor(vehicleId, capacityGrams) { if (capacityGrams <= 0) throw new RangeError("capacity must be positive"); this.vehicleId = vehicleId; this.#capacityGrams = capacityGrams; } add(shipment) { if (this.#sealed) return false; if (this.#totalWeight + shipment.weightGrams > this.#capacityGrams) return false; this.#shipments.push({ ...shipment }); this.#totalWeight += shipment.weightGrams; return true; } seal() { this.#sealed = true; this.#sealedCount = this.#shipments.length; } state() { return { capacityGrams: this.#capacityGrams, shipments: this.#shipments.map((g) => ({ ...g })), totalWeight: this.#totalWeight, sealed: this.#sealed, sealedCount: this.#sealedCount, }; } }
vehicleId stayed open: it is not part of any invariant. The { ...shipment } inside
the add method is a defensive copy; even if the object left in the caller’s hands
is changed later, the consignment’s total is not corrupted. state() returns a copy for
the same reason — the cost of this decision will be measured later in the lesson.
Same Operation Sequence, Two Outcomes
The measurement: a seeded generator produces a sequence of 20 operations each for 200
consignments, and the same sequence is applied to both versions. Some of the operations
are shortcuts — a direct field write in the open version, an add call in the closed
version, since there is no other path there.
// run.mjs — the same sequence of operations is applied to both versions; corrupted objects are counted import { createConsignment, add, seal } from "./consignment-open.mjs"; import { Consignment } from "./consignment-closed.mjs"; import { violations, generator } from "./checker.mjs"; const OBJECTS = 200; const OPERATIONS = 20; function operations() { const random = generator(20250729); const sequence = []; for (let n = 0; n < OBJECTS; n += 1) { const single = []; for (let i = 0; i < OPERATIONS; i += 1) { const g = { id: `${n}-${i}`, weightGrams: 100 + Math.floor(random() * 3900) }; const r = random(); if (r < 0.10) single.push({ kind: "seal" }); else if (r < 0.45) single.push({ kind: random() < 0.5 ? "shortcut-full" : "shortcut-half", g }); else single.push({ kind: "add", g }); } sequence.push(single); } return sequence; } const sequence = operations(); let openBroken = 0; const openViolations = new Map(); for (const single of sequence) { const s = createConsignment("34ABC01", 20000); for (const op of single) { if (op.kind === "seal") seal(s); else if (op.kind === "add") add(s, op.g); else if (op.kind === "shortcut-full") { s.shipments.push(op.g); s.totalWeight += op.g.weightGrams; } else s.shipments.push(op.g); } const broken = violations(s); if (broken.length > 0) openBroken += 1; for (const name of broken) openViolations.set(name, (openViolations.get(name) ?? 0) + 1); } let closedBroken = 0; let rejected = 0; for (const single of sequence) { const s = new Consignment("34ABC01", 20000); for (const op of single) { if (op.kind === "seal") s.seal(); else if (s.add(op.g) === false) rejected += 1; } if (violations(s.state()).length > 0) closedBroken += 1; } console.log(`object count = ${OBJECTS}`); console.log(`open version broken = ${openBroken}`); console.log(`closed version broken = ${closedBroken}`); console.log(`closed version reject = ${rejected}`); for (const [name, count] of [...openViolations].sort()) console.log(` violation ${name.padEnd(21)} = ${count}`);
node run.mjs
object count = 200 open version broken = 198 closed version broken = 0 closed version reject = 2428 violation capacity-exceeded = 43 violation post-seal-addition = 137 violation total-mismatch = 195
198 of the two hundred consignments violated at least one invariant in the open version. The closed version has no corrupted objects — but the real information is on the last line: the closed version rejected 2428 operations. The same 2428 operations had been silently accepted in the open version.
This states exactly what encapsulation does and does not do: it does not eliminate the
demand, it turns silent corruption into a visible rejection. How the rejection is
reported to the caller is a separate design decision — here false was returned, though
a result object carrying the reason could equally work. What is measurable is that
corruption dropped from 198 to 0.
Where the Protection Breaks
A private field is not enough on its own. If a method returns internal data as-is, the
caller can corrupt the invariant from outside. The class below keeps its fields private,
but manifest() hands over the internal array directly.
// leaking-consignment.mjs — fields are private but manifest() returns the internal array as-is export class LeakingConsignment { #capacityGrams; #shipments = []; #totalWeight = 0; constructor(capacityGrams) { this.#capacityGrams = capacityGrams; } add(shipment) { if (this.#totalWeight + shipment.weightGrams > this.#capacityGrams) return false; this.#shipments.push({ ...shipment }); this.#totalWeight += shipment.weightGrams; return true; } manifest() { return this.#shipments; } copyManifest() { return this.#shipments.map((g) => ({ ...g })); } state() { return { capacityGrams: this.#capacityGrams, shipments: this.copyManifest(), totalWeight: this.#totalWeight, sealed: false, sealedCount: 0 }; } }
The caller side is an ordinary step that takes the manifest and appends a transfer piece to the list.
// leak.mjs — how far a private field's protection holds when a return value is not encapsulated import { LeakingConsignment } from "./leaking-consignment.mjs"; import { violations, generator } from "./checker.mjs"; function run(getManifest) { const random = generator(20250729); let broken = 0; for (let n = 0; n < 200; n += 1) { const s = new LeakingConsignment(20000); for (let i = 0; i < 8; i += 1) { s.add({ id: `${n}-${i}`, weightGrams: 100 + Math.floor(random() * 3900) }); } // transfer step: takes the manifest and appends a transfer piece to the list getManifest(s).push({ id: `${n}-transfer`, weightGrams: 2500 }); if (violations(s.state()).length > 0) broken += 1; } return broken; } console.log(`manifest returning internal array -> broken object = ${run((s) => s.manifest())}`); console.log(`manifest returning a copy -> broken object = ${run((s) => s.copyManifest())}`);
node leak.mjs
manifest returning internal array -> broken object = 200 manifest returning a copy -> broken object = 0
All two hundred objects were corrupted; the only difference was whether the returned
value was a copy. Encapsulation’s boundary is drawn not at the access modifier but at
every reference that leaves the object — the same holds for references entering the
constructor, which is why a copy was taken in the closed version’s add method.
Cost
Returning a copy is not free. The state() call does work proportional to the number of
shipments; in a hot loop, the cost becomes measurable. Three common ways out: an
immutable element (the copy becomes unnecessary), a read-only view, or asking the
object the question actually asked — “how many shipments,” “what total weight” —
instead of returning the list at all.
The second cost is applying encapsulation in the wrong place. In a data carrier that holds no invariant at all — an address record, a tariff row — writing an accessor for every field buys the same protection as an open field at zero gain, expensively. The measure has not changed: if what is closed protects a rule, it is encapsulation; if not, it is ceremony.
Summary
- Encapsulation’s measure is the invariant, not privacy; a field outside any invariant’s equation gains nothing from being hidden.
- Under the same 4000-operation sequence, 198 of the 200 consignments fell into a corrupted state in the open version, none in the closed version.
- Encapsulation does not eliminate the demand; it turns silent corruption into a visible rejection — the closed version rejected 2428 operations that the open version had silently accepted.
- A method that returns internal data as-is removes the protection entirely: 200 of 200 objects were corrupted when the internal array was returned, none when a copy was.
- The copy’s cost is proportional to the number of shipments; the alternatives are an immutable element, a read-only view, and asking the object the question instead of returning the list.
Next Step
Encapsulation blocked data from being written from outside. But how many names the consignment exposes, how much internal detail those names leak, and how many call sites break when a detail changes have not been asked yet. The next lesson takes up this question: the number of names a module exposes is measured against the number of details a client is forced to know, and how many files break in each design when the representation changes is counted.
To keep your progress and take notes, Log in
My notes
Log in to take notes.