Lesson 21 / 30
Mediator and Memento
Comparing four fields in an operator form that know each other directly against gathering the routing into a single object: the number of links between fields, the number of update calls, and how the link count grows with the field count; also measuring the cost of storing state by the number of internal fields accessed from outside and the number of fields the token carries.
Contents
Visitor applied an operation to every node in the tree; the nodes did not know each other. On the operator screen, though, the objects know each other directly. The carrier selector, the tariff summary, the discount box, and the delivery-date field keep each other informed: when the carrier changes, the tariff refreshes; when the tariff changes, the discount is recomputed; when the discount changes, the delivery date updates. Each field knows the fields affected after it and calls them directly.
This lesson’s two patterns target two separate problems. Mediator gathers the routing between fields into a single object; the fields do not know each other. Memento turns an object’s state into a storable token without exposing its internal fields. The numbers to measure are the number of links between fields, the number of update calls, the number of internal fields accessed from outside, and the number of fields the token carries.
Fields That Know Each Other
mkdir -p direct mediator memento
// direct/form.mjs — each field directly knows the fields affected after it const TARIFF = { domestic: 8400, express: 12600, international: 21000 }; export function form() { const value = { carrier: "domestic", tariff: 0, discount: 0, deliveryDays: 0 }; let callCount = 0; const deliveryDate = { update() { callCount += 1; value.deliveryDays = (value.carrier === "express" ? 1 : 3) + (value.discount >= 10 ? 1 : 0); }, }; const discount = { update() { callCount += 1; value.discount = value.tariff > 10000 ? 10 : 5; deliveryDate.update(); }, }; const tariff = { update() { callCount += 1; value.tariff = TARIFF[value.carrier]; discount.update(); deliveryDate.update(); }, }; const carrier = { select(name) { callCount += 1; value.carrier = name; tariff.update(); discount.update(); deliveryDate.update(); }, }; return { value, select: (name) => carrier.select(name), calls: () => callCount }; }
Each field calls every field that can be affected after it. This differs from the observer lesson: there, the notifier did not know its listeners; here, each field both notifies and is notified, and the links run in two directions.
Gathering the Routing into a Single Object
In the mediator version, the fields never see each other; each one only does its own computation.
// mediator/fields.mjs — no field knows another, each only does its own work const TARIFF = { domestic: 8400, express: 12600, international: 21000 }; export const fields = (count) => ({ tariff: { update(value) { count(); value.tariff = TARIFF[value.carrier]; }, }, discount: { update(value) { count(); value.discount = value.tariff > 10000 ? 10 : 5; }, }, deliveryDate: { update(value) { count(); value.deliveryDays = (value.carrier === "express" ? 1 : 3) + (value.discount >= 10 ? 1 : 0); }, }, });
// mediator/form.mjs — the routing between fields lives only here import { fields } from "./fields.mjs"; const ORDER = ["tariff", "discount", "deliveryDate"]; export function form() { const value = { carrier: "domestic", tariff: 0, discount: 0, deliveryDays: 0 }; let callCount = 0; const count = () => { callCount += 1; }; const f = fields(count); const notify = (source) => { const start = source === "carrier" ? 0 : ORDER.indexOf(source) + 1; for (const name of ORDER.slice(start)) f[name].update(value); }; return { value, select(name) { count(); value.carrier = name; notify("carrier"); }, calls: () => callCount, }; }
Value Equality and Call Count
// run.mjs — runs both versions with the same selections, compares value and call count import { form as directForm } from "./direct/form.mjs"; import { form as mediatorForm } from "./mediator/form.mjs"; let deviation = 0; for (const choice of ["express", "international", "domestic"]) { const a = directForm(); const b = mediatorForm(); a.select(choice); b.select(choice); const summary = (f) => Object.entries(f.value).map(([k, v]) => `${k}=${v}`).join(" "); if (summary(a) !== summary(b)) deviation += 1; console.log(`${choice.padEnd(14)} ${summary(a)} | update calls direct=${a.calls()} mediator=${b.calls()}`); } console.log(`value deviation between the two versions = ${deviation}`);
express carrier=express tariff=12600 discount=10 deliveryDays=2 | update calls direct=8 mediator=4 international carrier=international tariff=21000 discount=10 deliveryDays=4 | update calls direct=8 mediator=4 domestic carrier=domestic tariff=8400 discount=5 deliveryDays=3 | update calls direct=8 mediator=4 value deviation between the two versions = 0
The values are identical, and the update call count is eight against four. The extra four calls are repeated triggers: the delivery date is computed three times per selection, because three separate fields call it. The value comes out correct because the first two computations are overwritten by the third — this correctness is down to luck alone. As the field count grows, the number of repeated triggers grows with it, and the ordering guarantee disappears.
Growth of the Link Count
// links.mjs — number of links between fields and how it grows with field count import { readFileSync } from "node:fs"; const directText = readFileSync("direct/form.mjs", "utf8"); const mediatorText = readFileSync("mediator/form.mjs", "utf8"); const siblingLinks = (directText.match(/\b(tariff|discount|deliveryDate)\.update\(\)/g) ?? []).length; const mediatorLinks = (mediatorText.match(/^const ORDER = \[(.*)\];$/m)[1].split(",")).length; console.log(`direct links between fields=${siblingLinks} mediator object=0`); console.log(`mediator links between fields=0 fields the mediator knows=${mediatorLinks}`); console.log(`formula check for N=4: N(N-1)/2=${(4 * 3) / 2} N-1=${4 - 1}`); for (const n of [4, 6, 8]) { console.log(`N=${n} direct links=${(n * (n - 1)) / 2} mediator links=${n - 1}`); }
direct links between fields=6 mediator object=0 mediator links between fields=0 fields the mediator knows=3 formula check for N=4: N(N-1)/2=6 N-1=3 N=4 direct links=6 mediator links=3 N=6 direct links=15 mediator links=5 N=8 direct links=28 mediator links=7
The measured six links equal the number the formula gives for a fully connected chain
of four fields; the measured three links in the mediator version confirm the formula. The
two formulas grow at different rates: at eight fields, twenty-eight against seven. This drop in
link count is not free. The mediator adds an object and a file; more importantly, information
moves out of the fields and accumulates in the mediator. The ORDER array is now the single place
carrying the form’s entire dependency order, and as the field count grows, the number of reasons
this one file changes grows with it. The mediator’s growth is the pattern’s known consequence:
links shrink, and the central object swells.
Storing State
During an editing session, the operator wants to try a few changes and, if unsatisfied, return to the starting point. The first way is for the caller to read the fields itself and write them back.
// direct/session.mjs — the caller reads and writes back the internal fields itself export const takeCopy = (f) => ({ carrier: f.value.carrier, tariff: f.value.tariff, discount: f.value.discount, deliveryDays: f.value.deliveryDays, }); export const writeCopy = (f, copy) => { f.value.carrier = copy.carrier; f.value.tariff = copy.tariff; f.value.discount = copy.discount; f.value.deliveryDays = copy.deliveryDays; };
In the memento version, state never leaves the object. It hands out a token; the token carries only a sequence number, and its contents cannot be read from outside.
// memento/session.mjs — state never leaves the object; the token carries only a sequence number export function session(initial) { let state = { ...initial }; const records = []; return { write(name, value) { state[name] = value; }, save() { records.push({ ...state }); return { id: records.length - 1 }; }, restore(token) { if (token.id in records === false) return false; state = { ...records[token.id] }; return true; }, summary: () => Object.entries(state).map(([k, v]) => `${k}=${v}`).join(" "), }; }
// storage.mjs — compares the two storage methods import { readFileSync } from "node:fs"; import { form } from "./mediator/form.mjs"; import { takeCopy, writeCopy } from "./direct/session.mjs"; import { session } from "./memento/session.mjs"; const summary = (d) => Object.entries(d).map(([k, v]) => `${k}=${v}`).join(" "); const f = form(); f.select("domestic"); const token1 = takeCopy(f); f.select("international"); const corrupted = summary(f.value); writeCopy(f, token1); console.log(`direct token field count=${Object.keys(token1).length} after restore: ${summary(f.value)}`); const s = session({ carrier: "domestic", tariff: 8400, discount: 5, deliveryDays: 3 }); const token2 = s.save(); s.write("carrier", "international"); s.write("tariff", 21000); s.write("discount", 10); s.write("deliveryDays", 4); const restored = s.restore(token2); console.log(`memento token field count=${Object.keys(token2).length} restore=${restored ? 1 : 0} after: ${s.summary()}`); console.log(`corrupted intermediate state: ${corrupted}`); const outsideAccess = (path) => (readFileSync(path, "utf8").match(/f\.value\.\w+/g) ?? []).length; console.log(`internal field accessed from outside: direct=${outsideAccess("direct/session.mjs")} memento=${outsideAccess("memento/session.mjs")}`); console.log(`invalid token attempt: memento restore({ id: 9 })=${s.restore({ id: 9 }) ? 1 : 0}`);
direct token field count=4 after restore: carrier=domestic tariff=8400 discount=5 deliveryDays=3
memento token field count=1 restore=1 after: carrier=domestic tariff=8400 discount=5 deliveryDays=3
corrupted intermediate state: carrier=international tariff=21000 discount=10 deliveryDays=4
internal field accessed from outside: direct=8 memento=0
invalid token attempt: memento restore({ id: 9 })=0
Both methods brought back the same starting state; the third line shows the intermediate state
actually changing. The difference is in the measure: internal fields accessed from outside is
eight against zero, and fields carried by the token is four against one. The first method requires
one read and one write per field, so adding a fifth field grows it by two more lines; in the
memento version, { ...state } does not depend on the field count and is not edited.
The cost is in two places. The first is memory: every token stores a full copy of the state, copies accumulate one per record, and there is no rule to clear them. The second is the token’s validity: the last line shows that a restore attempt with a nonexistent id fails silently. Because it hides the token’s contents, the caller cannot check whether the token is still meaningful; the pattern leaves that check to the object itself.
Summary
- Mediator gathers the routing between fields into a single object; the fields do not know each other, each only doing its own computation.
- The values came out identical in both versions; the update call count dropped from 8 to 4, and the extra four calls were repeated triggers.
- The number of links between fields dropped from 6 to 3; the measurement confirmed the and formulas, with the difference at eight fields at 28 against 7. The cost is a mediator object that alone carries the dependency order.
- Memento stores state without exposing it: internal fields accessed from outside dropped from 8 to 0, the fields carried by the token dropped from 4 to 1, and both methods brought back the same starting state.
- Memento’s cost is that every token holds a full copy of the state, and an invalid token fails silently.
Next Step
Mediator stores an order, memento stores a copy; both hold data, but the data is still written
into the code. On the tariff-rule side, this has reached its limit: every new conditional rule
means a source change, and the person writing the rules is not a developer but the tariff
department. The rule needs to be written as text and evaluated at run time. The same section
raises a second question: what is the difference between a shipment with no discount applied
resulting in null and resulting in a discount object that does nothing. The next lesson builds
an object tree that interprets a small rule language, measures the number of files edited when a
new rule is added, and compares the number of null checks against the null object.
To keep your progress and take notes, Log in
My notes
Log in to take notes.