---
title: 'Model-Driven Design'
source: 'https://academia.sh/en/courses/domain-driven-design/model-driven-design'
course: 'Domain-Driven Design'
language: en
updated: '2026-08-23T07:01:18+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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**.

```js
// 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.

```js
// 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.

```js
// 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.

```js
// 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; }
}
```

```js
// 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.

```js
// 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.

```js
// 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])}`);
```

```sh
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.
