Lesson 19 / 19
Model-Driven Design
The model's evolution together with the implementation: counting how many of the seven names in the domain expert's sentence are found in the model's vocabulary, measuring the drop in parallel-array indexing and rule writing sites once the implicit route-leg concept is exposed, the improvement's line cost, and testing the behavioral equality of three reports.
Contents
This topic’s four lessons proceeded the same way: a model was built, a measure was chosen, two arrangements were compared by that measure. None of the decisions were made once and left alone. The number of decisions in the use case, the ports’ names, the read model’s rows, and the event types change as the domain expert is consulted further, and every change asks for a cost in code. Whether a model improvement is truly an improvement is known only once that cost is counted.
Model-driven design makes this link mandatory: a one-to-one correspondence is sought between the model’s concepts and the implementation’s names, code changes with the model, and an improvement whose counterpart cannot be built cheaply does not count as one. The link runs both ways; a difficulty surfacing in the implementation signals a concept missing from the model.
A Concept Left Implicit
In a delay discussion, the operations expert forms this sentence: every leg of the route belongs to a carrier between a departure point and an arrival point; if a leg is actualized after its planned day, the delay is charged to that leg’s carrier. The sentence contains seven names: leg, departure, arrival, carrier, planned, actual, delay.
Some of these names are missing from the library’s model. The route is an array of points; carriers, planned days, and actual days are separate parallel arrays. There is no such thing as a leg; every report re-derives it from indexes. Such a concept is an implicit concept, and exposing it is a breakthrough.
// shared/record.mjs — the implicit model's domain vocabulary: a record, four parallel arrays export function deliveryRecord(id, route, carriers, planned, actual) { return { id, route, carriers, planned, actual }; }
The measurement needs to use the same data in both versions; the generator is fixed.
// shared/generate.mjs — the fixed record set both versions read import { deliveryRecord } from "./record.mjs"; const POINTS = ["34", "06", "35", "01", "16"]; const CARRIERS = ["AN", "KG", "MT"]; export function generate(count) { let x = 29; const s = () => (x = (x * 48271 + 11) % 2147483647); return Array.from({ length: count }, (_, i) => { const pointCount = 2 + (s() % 3); const start = s() % POINTS.length; const route = Array.from({ length: pointCount }, (_, j) => POINTS[(start + j) % POINTS.length]); const carriers = [], planned = [], actual = []; let day = 0, done = false; for (let a = 0; a < pointCount - 1; a += 1) { carriers.push(CARRIERS[s() % CARRIERS.length]); day += 1 + (s() % 3); planned.push(day); if (done || s() % 6 === 0) { done = true; actual.push(null); continue; } const delay = s() % 3 === 0 ? 1 + (s() % 3) : 0; actual.push(day + delay); } return deliveryRecord(`T${i + 1}`, route, carriers, planned, actual); }); }
While the Concept Is Implicit
Three reports do the same work: the delay report lists delayed legs, the carrier scorecard charges delay to the carrier, the dashboard shows the next leg and the delivery’s state. All three derive the leg from indexes and write the delay rule inside themselves.
// implicit/reports.mjs — three reports, three derivations of the leg, and three writings of the delay rule export function delayReport(records) { const rows = []; for (const k of records) { for (let i = 0; i < k.route.length - 1; i += 1) { if (k.actual[i] === null) continue; if (k.actual[i] > k.planned[i]) { rows.push({ id: k.id, leg: `${k.route[i]}-${k.route[i + 1]}`, carrier: k.carriers[i], delayDays: k.actual[i] - k.planned[i], }); } } } return rows; } export function carrierScorecard(records) { const scorecard = new Map(); for (const k of records) { for (let i = 0; i < k.route.length - 1; i += 1) { const c = k.carriers[i]; const s = scorecard.get(c) ?? { carrier: c, legs: 0, delayed: 0, totalDelay: 0 }; s.legs += 1; if (k.actual[i] !== null && k.actual[i] > k.planned[i]) { s.delayed += 1; s.totalDelay += k.actual[i] - k.planned[i]; } scorecard.set(c, s); } } return [...scorecard.values()].sort((a, b) => a.carrier.localeCompare(b.carrier)); } export function dashboard(records) { return records.map((k) => { let next = null, delayed = false; for (let i = 0; i < k.route.length - 1; i += 1) { if (k.actual[i] === null) { next = `${k.route[i]}-${k.route[i + 1]}`; break; } if (k.actual[i] > k.planned[i]) delayed = true; } const state = next === null ? "completed" : delayed ? "delayed" : "in-transit"; return { id: k.id, nextLeg: next, state }; }); }
The expression k.route.length - 1 appears in three places, carrying the same piece of
information each time: the number of legs is one less than the number of points. This is not
written down in one place in the model; it is repeated across three report bodies.
Exposing the Concept
The leg becomes a value object: it has no identity and is defined by its departure, arrival, carrier, planned day, and actual day. The sentence’s rules become its methods.
// explicit/domain/route-leg.mjs — leg: a value object carrying departure, arrival, carrier, planned and actual day export class RouteLeg { #departure; #arrival; #carrier; #planned; #actual; constructor({ departure, arrival, carrier, planned, actual }) { this.#departure = departure; this.#arrival = arrival; this.#carrier = carrier; this.#planned = planned; this.#actual = actual; } get name() { return `${this.#departure}-${this.#arrival}`; } get carrier() { return this.#carrier; } completed() { return this.#actual !== null; } isDelayed() { return this.completed() && this.#actual > this.#planned; } delayDays() { return this.isDelayed() ? this.#actual - this.#planned : 0; } }
// explicit/domain/route.mjs — the one place that turns parallel arrays into an array of legs import { RouteLeg } from "./route-leg.mjs"; export class Route { #legs; static fromRecord(k) { return new Route(k.route.slice(0, -1).map((departure, i) => new RouteLeg({ departure, arrival: k.route[i + 1], carrier: k.carriers[i], planned: k.planned[i], actual: k.actual[i], }))); } constructor(legs) { this.#legs = legs; } get legs() { return this.#legs; } nextLeg() { return this.#legs.find((a) => a.completed() === false) ?? null; } delayedLegs() { return this.#legs.filter((a) => a.isDelayed()); } }
The persistence form did not change: the record is still four parallel arrays. Only the model’s vocabulary changed.
// explicit/reports.mjs — the same three reports; derivation and the rule no longer live in the report file import { Route } from "./domain/route.mjs"; export function delayReport(records) { return records.flatMap((k) => Route.fromRecord(k).delayedLegs().map((a) => ({ id: k.id, leg: a.name, carrier: a.carrier, delayDays: a.delayDays(), }))); } export function carrierScorecard(records) { const scorecard = new Map(); for (const k of records) { for (const a of Route.fromRecord(k).legs) { const s = scorecard.get(a.carrier) ?? { carrier: a.carrier, legs: 0, delayed: 0, totalDelay: 0 }; s.legs += 1; if (a.isDelayed()) { s.delayed += 1; s.totalDelay += a.delayDays(); } scorecard.set(a.carrier, s); } } return [...scorecard.values()].sort((a, b) => a.carrier.localeCompare(b.carrier)); } export function dashboard(records) { return records.map((k) => { const route = Route.fromRecord(k), next = route.nextLeg(); const delayed = route.delayedLegs().length > 0; const state = next === null ? "completed" : delayed ? "delayed" : "in-transit"; return { id: k.id, nextLeg: next === null ? null : next.name, state }; }); }
Measurement
The measurement produces four numbers: alignment — how many of the sentence’s seven names are found in the model’s vocabulary; parallel-array indexing count; sites per rule; and line cost. The three reports are then compared across both versions with the same records.
// evolution-count.mjs — name overlap, parallel-array indexing, rule writing sites, line cost, and behavior equality import { readFileSync } from "node:fs"; import { generate } from "./shared/generate.mjs"; import * as implicit from "./implicit/reports.mjs"; import * as explicit from "./explicit/reports.mjs"; const SENTENCE_NAMES = ["leg", "departure", "arrival", "carrier", "planned", "actual", "delay"]; const RULE = { "delay": /actual[^\n]*>[^\n]*planned/g, "completion": /actual[^\n]*(===|!==)[^\n]*null/g, "delay days": /actual[^\n]*-[^\n]*planned/g, }; const INDEX = /(route|carriers|planned|actual)\[/g; const VERSION = { "implicit": { vocabulary: ["shared/record.mjs"], body: ["implicit/reports.mjs"] }, "explicit": { vocabulary: ["explicit/domain/route-leg.mjs", "explicit/domain/route.mjs"], body: ["explicit/reports.mjs"] }, }; const read = (d) => readFileSync(d, "utf8"); const lineCount = (d) => read(d).split("\n").filter((s) => s.trim() !== "").length; for (const [name, { vocabulary, body }] of Object.entries(VERSION)) { const vocabularyText = vocabulary.map(read).join("\n").toLowerCase(); const token = new Set(vocabularyText.match(/[a-z_][a-z0-9_]*/g)); const found = SENTENCE_NAMES.filter((a) => [...token].some((b) => b.startsWith(a))); const all = [...vocabulary, ...body].map(read).join("\n"); console.log(`${name} version`); console.log(` domain vocabulary = ${vocabulary.join(", ")} (${vocabulary.reduce((s, d) => s + lineCount(d), 0)} lines)`); console.log(` found in vocabulary, of 7 sentence names = ${found.length} (${found.join(", ")})`); console.log(` parallel-array indexing = ${(all.match(INDEX) ?? []).length}`); let totalRules = 0; for (const [k, pattern] of Object.entries(RULE)) { const n = (all.match(pattern) ?? []).length; totalRules += n; console.log(` rule "${k}" writing sites = ${n}`); } console.log(` rule writing total = ${totalRules}, report body = ${body.reduce((s, d) => s + lineCount(d), 0)} lines`); } const RECORDS = generate(60); const REPORTS = ["delayReport", "carrierScorecard", "dashboard"]; let diverged = 0; for (const r of REPORTS) { const a = JSON.stringify(implicit[r](RECORDS)), b = JSON.stringify(explicit[r](RECORDS)); if (a !== b) diverged += 1; console.log(`${r.padEnd(16)} equal = ${a === b}, rows = ${JSON.parse(a).length}`); } console.log(`diverged reports = ${diverged} / ${REPORTS.length}`); console.log(`carrier scorecard = ${JSON.stringify(explicit.carrierScorecard(RECORDS))}`); console.log(`sample dashboard row = ${JSON.stringify(explicit.dashboard(RECORDS)[0])}`);
node evolution-count.mjs
implicit version
domain vocabulary = shared/record.mjs (4 lines)
found in vocabulary, of 7 sentence names = 3 (carrier, planned, actual)
parallel-array indexing = 19
rule "delay" writing sites = 3
rule "completion" writing sites = 3
rule "delay days" writing sites = 2
rule writing total = 8, report body = 43 lines
explicit version
domain vocabulary = explicit/domain/route-leg.mjs, explicit/domain/route.mjs (28 lines)
found in vocabulary, of 7 sentence names = 7 (leg, departure, arrival, carrier, planned, actual, delay)
parallel-array indexing = 4
rule "delay" writing sites = 1
rule "completion" writing sites = 1
rule "delay days" writing sites = 1
rule writing total = 3, report body = 27 lines
delayReport equal = true, rows = 27
carrierScorecard equal = true, rows = 3
dashboard equal = true, rows = 60
diverged reports = 0 / 3
carrier scorecard = [{"carrier":"AN","legs":42,"delayed":13,"totalDelay":22},{"carrier":"KG","legs":40,"delayed":7,"totalDelay":16},{"carrier":"MT","legs":44,"delayed":7,"totalDelay":13}]
sample dashboard row = {"id":"T1","nextLeg":null,"state":"completed"}
Reading the Numbers
The alignment number rose from 3 to 7. The implicit vocabulary held only three of the seven names; leg, departure, arrival, and delay carried no name in the code. In the explicit version all seven are present, none buried inside a report.
Parallel-array indexing dropped from 19 to 4. The remaining 4 sit in one place: the
Route.fromRecord method, where the record’s arrays turn into leg objects. “The number of
legs is one less than the number of points” is now written down once.
The rule-writing total dropped from 8 to 3, and the distribution matters more: the delay and completion rules each dropped from three places to one. That is directly a cost of change — if delay’s definition changes, three report bodies are edited in the implicit version, one value-object method in the explicit one.
The cost is visible too: 2 files and 28 lines added; the report body dropped from 43 to 27, a net increase of 12 lines. The cost stayed small because the persistence form was untouched: an improvement needing no migration or schema change is cheap.
The last lines show behavior was preserved: on sixty records, all three reports produced the
same output, 0 diverged. In the carrier scorecard, AN carries 13 delays and 22 days over 42
legs, KG 7 delays and 16 days over 40.
The Test for an Improvement
Not every new class is a model improvement. The test is these four numbers: did alignment rise, did derivation collapse to one place, did rule writing drop, is the cost reasonable against these gains?
Adding a Point class wrapping a point code loses on this test: “point” was already covered
by the route array, indexing does not change, and no rule moves. The only effect is 1 file and
a step of indirection — a gain of 0, a cost above 0.
The reverse direction also holds: a derivation repeated in the implementation is the most
reliable sign of a concept missing from the model. This lesson’s improvement was found not by
reading code but by noticing k.route.length - 1 in three places. That is what makes
model-driven design two-directional: the model shapes the code, and repetition in the code
asks the model a question back.
Summary
- Model-driven design seeks a one-to-one correspondence between the model’s concepts and the implementation’s names; an improvement that cannot be built cheaply in code does not count.
- Of the seven names in the domain expert’s sentence, the implicit vocabulary held 3; after the leg concept was exposed, it held 7.
- Parallel-array indexing dropped from 19 to 4, collecting into a single conversion method; the rule-writing total dropped from 8 to 3, with the delay rule dropping from three places to one.
- The cost is 2 files and 28 lines; the report body dropped from 43 to 27, a net increase of 12 lines, with the persistence form untouched.
- On sixty records, all three reports gave the same output; the improvement did not change behavior.
- A class with zero gain is not an improvement; a repeated derivation in the implementation is a sign of a concept missing from the model.
Course Wrap-Up
The course opened with one question: can a codebase’s structure be derived from the domain’s own distinctions, and can this be shown with a measure? Three topics took up this question at three scales, and every lesson compared two models of the same domain by the same measure.
The Domain Model topic built the model’s interior. Ubiquitous Language counted five concepts carrying thirteen names; unifying them dropped this to five, the five translation points between two names for one concept dropped to 0, and quote and fee matched in 19 of thirty shipments. Entities and Value Objects separated the test for identity-defined from value-defined. Aggregates and the Root drew the consistency boundary: exposed, 4 of five call paths produced a shipment exceeding the discount cap; through the root, 0 did. Domain Services placed behavior belonging to no object, and Factories and Repositories fit creation and access to the domain’s language. In Domain Events, of the expert’s three questions the general log answered 1 and the domain log answered 3. The topic closed on the cost of moving a rule into a service: three services writing it diverged in 24 of 36 shipments; moved onto the object, divergence was 0.
The Bounded Contexts topic determined where a model holds. Bounded Context counted the cost of merging two contexts into one class: 68 of 105 field pairs never appeared together in any scenario; drawing the boundary dropped the pair count to 56 and never-together to 19, and ten invariants rejecting all six real shipments in one class passed all six split across two. Context Map covered relationship types, and Anti-Corruption Layer covered the outside model’s leakage — leaking, it appeared in both core files; protected, in 0; cost 2 files, 19 lines. Shared Kernel and Conformist established the collaboration patterns, Subdomains the core, supporting, and generic distinction.
Connecting to the Application Architecture placed the model inside an application. Pushing decisions from the use case to the aggregate root dropped the application layer’s decision points from 13 to 2 and the five domain sentences’ writing sites from 10 to 5. Naming the domain’s outside openings as ports dropped direct links to the outside world from 3 to 0, and the same domain file ran with two adapter teams. Separating the read model dropped the objects a dashboard query walked at 800 deliveries from 4621 to 5, at a cost of a five-command lag. Storing the event sequence raised answered questions from 1 of four to 4; lowering the snapshot interval to 5 dropped events read for a rebuild from 4699 to 819. This last lesson showed the model itself is not static: name overlap rose from 3 to 7, rule writing dropped from 8 to 3.
The common pattern: every lesson found a misalignment, gave it a number, corrected it, and wrote the cost down the same way. Nowhere was it said the model “became more expressive”; what was counted was names, call paths, field pairs, decision points, objects visited, events read.
The question left behind sits one level up. Every decision so far lived inside one application. The context map named more than one context, but all were part of the same running program. When contexts are distributed across separate programs, or kept in one, the measures change: calls crossing the network, units a request touches, parts that must release together for a version to be deployable. The next course, Architectural Styles, compares architectural arrangements by these measures: where the difference between a library’s monolithic, layered, and distributed arrangements comes down to a number.
To keep your progress and take notes, Log in
My notes
Log in to take notes.