---
title: 'Domain-Driven Design'
source: 'https://academia.sh/en/courses/domain-driven-design'
language: en
updated: '2026-08-23T07:01:17+00:00'
license: 'CC BY-SA 4.0'
lessons: 19
outcomes:
    - 'Puts the domain language at the center of the model'
    - 'Draws entity, value object, and aggregate boundaries correctly'
    - 'Builds a relationship map between bounded contexts'
---

# Domain-Driven Design

Deriving a codebase's structure from the domain's own distinctions: putting the domain language at the center of the model, separating objects defined by identity from those defined by value, drawing consistency boundaries with aggregate roots, determining the area in which a model holds with bounded contexts, and mapping the relationships between contexts; every modeling decision is tested by measuring its alignment with the domain.

## The Domain Model

Aligning complexity with the domain: merging code and spoken language into a single vocabulary, separating objects defined by identity from immutable objects defined by value, drawing the consistency boundary with aggregates and the root, giving behavior that does not belong to an object to domain services, fitting creation and access to the domain model, an explicit model of meaningful business events, and the cost of behaviorless data classes.

### Rationale for Domain-Driven Design

Source: https://academia.sh/en/courses/domain-driven-design/rationale-for-domain-driven-design

Turning the model's alignment with the domain into a measure: counting how many of the twelve names in the domain expert's single sentence are found verbatim in two models, measuring how many candidate lines three change requests phrased in domain language reduce to in each model, and showing that two models produce the same fee — establishing that alignment is a quality distinct from correctness.

The Design Patterns course closed by showing that a pattern is a pattern only to the extent
that it improves some measure. All thirty lessons were about the **shape** of the solution:
where a rule is written, how an object is created, where a boundary is drawn. None of them
asked what the rule **is**. What the words shipment, tariff, zone, route, and discount mean,
which of them change together, whether the same word names two different things on two
teams — all of that stayed outside that course.

This lesson takes up that question. **Domain-driven design** proposes deriving a codebase's
structure from the domain's own distinctions, where the domain is the business the software
is about. The approach's claim is not an aesthetic one, so it must have a measure. What gets
measured through this course is the **model's alignment with the domain**: whether the
distinctions used by the person who talks about the domain have a verbatim counterpart in the
code.

#### The Domain Expert's Sentence

The measure's basis is the words used by the person who knows the domain by trade — the
**domain expert** — when stating a rule. In the shipping-fee library, that person is the
pricing expert, and the rule comes out in a single sentence:

> A shipment's fee is the weight tier's base fee times the zone factor; if the volume exceeds
> the weight, the volume is used instead; the sum of the discount rates cannot exceed the cap;
> the net fee cannot fall below the minimum fee.

The sentence names twelve things: shipment, weight, volume, tier, base fee, zone, factor,
postal code, discount, rate, cap, minimum fee. These twelve names are the measure's fixed
list.

#### Two Models of the Same Rule

The library has two versions. Both implement the same rule; the only difference is the
names.

```sh
mkdir -p technical domain
```

```js
// technical/calc.mjs — same rule, with technical names
const T1 = [[1, 3900], [5, 6400], [Infinity, 11800]];
const T2 = { "34": 1, "06": 1.35, "65": 1.8 };
const LIM = 0.4;
const MIN = 3990;
const DIV = 5000;

export function process(d) {
  const v = Math.max(d.w, d.vol / DIV);
  const base = T1.find(([u]) => v <= u)[1];
  const f = T2[d.z.slice(0, 2)] ?? 1.8;
  const r = Math.min(d.ds.reduce((t, x) => t + x.r, 0), LIM);
  return Math.max(Math.round(base * f * (1 - r)), MIN);
}
```

The file is short, its branching is light, and it runs correctly. Judged by the Clean Code
course's measures, its only flaw is naming. What domain-driven design adds is that this is
not a naming flaw: the distinction between `T1` and `T2` has a counterpart in the domain, and
that distinction is missing from the code.

```js
// domain/fee.mjs — same rule, with domain names
const TIERS = [[1, 3900], [5, 6400], [Infinity, 11800]];
const ZONE_COEFFICIENTS = { "34": 1, "06": 1.35, "65": 1.8 };
const DISCOUNT_CAP = 0.4;
const MINIMUM_FEE = 3990;
const VOLUME_DIVISOR = 5000;

const chargeableWeight = (shipment) => Math.max(shipment.weight, shipment.volume / VOLUME_DIVISOR);
const baseFee = (weight) => TIERS.find(([cap]) => weight <= cap)[1];
const zoneFactor = (postalCode) => ZONE_COEFFICIENTS[postalCode.slice(0, 2)] ?? 1.8;
const discountRate = (discounts) =>
  Math.min(discounts.reduce((t, d) => t + d.rate, 0), DISCOUNT_CAP);

export function netFee(shipment) {
  const raw = baseFee(chargeableWeight(shipment)) * zoneFactor(shipment.postalCode);
  return Math.max(Math.round(raw * (1 - discountRate(shipment.discounts))), MINIMUM_FEE);
}
```

#### Measuring Alignment

Two measures are taken. The first is **name alignment**: how many of the twelve names in the
expert's sentence are found verbatim inside an identifier in the source. The second is
**candidate line count**: how many lines the words of an incoming change request phrased in
domain language appear together on. The second measure gives the number of lines the person
receiving the request has to look at in the file.

```js
// alignment-measure.mjs — are the expert's names in the code, how many lines does a request touch
import { readFileSync } from "node:fs";
import { process as technicalFee } from "./technical/calc.mjs";
import { netFee } from "./domain/fee.mjs";

const NAMES = ["shipment", "weight", "volume", "tier", "basefee", "zone",
  "factor", "postalcode", "discount", "rate", "cap", "minimumfee"];
const REQUESTS = {
  "discount cap will drop": ["discount", "cap"],
  "minimum fee will rise": ["minimum", "fee"],
  "volume divisor will change": ["volume", "divisor"],
};
const MODEL = { technical: "technical/calc.mjs", domain: "domain/fee.mjs" };

for (const [model, file] of Object.entries(MODEL)) {
  const text = readFileSync(file, "utf8");
  const raw = text.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? [];
  const identifiers = raw.map((s) => s.toLowerCase().replaceAll("_", ""));
  const found = NAMES.filter((name) => identifiers.some((k) => k.includes(name)));
  const lines = text.split("\n").filter((s) => s.trim() !== "" && !s.startsWith("//"));
  console.log(`${model}: domain name ${found.length}/${NAMES.length}, line ${lines.length}`);
  console.log(`  missing name: ${NAMES.filter((n) => !found.includes(n)).join(" ") || "none"}`);
  for (const [request, words] of Object.entries(REQUESTS)) {
    const candidates = lines.filter((s) => words.every((w) => s.toLowerCase().includes(w)));
    console.log(`  "${request}" -> candidate line ${candidates.length}/${lines.length}`);
  }
}

const SHIPMENTS = [
  { weight: 0.6, volume: 2000, postalCode: "34710", discounts: [] },
  { weight: 3, volume: 24000, postalCode: "06800", discounts: [{ name: "contract", rate: 0.15 }] },
  { weight: 12, volume: 40000, postalCode: "65100", discounts: [{ name: "volume", rate: 0.5 }] },
  { weight: 0.4, volume: 9000, postalCode: "81600", discounts: [{ name: "campaign", rate: 0.1 }] },
];
const convert = (s) => ({ w: s.weight, vol: s.volume, z: s.postalCode,
  ds: s.discounts.map((d) => ({ r: d.rate })) });

let diverged = 0;
for (const s of SHIPMENTS) if (technicalFee(convert(s)) !== netFee(s)) diverged += 1;
console.log(`diverged result = ${diverged} / ${SHIPMENTS.length}`);
```

```sh
node alignment-measure.mjs
```

```
technical: domain name 0/12, line 12
  missing name: shipment weight volume tier basefee zone factor postalcode discount rate cap minimumfee
  "discount cap will drop" -> candidate line 0/12
  "minimum fee will rise" -> candidate line 0/12
  "volume divisor will change" -> candidate line 0/12
domain: domain name 12/12, line 14
  missing name: none
  "discount cap will drop" -> candidate line 2/14
  "minimum fee will rise" -> candidate line 2/14
  "volume divisor will change" -> candidate line 2/14
diverged result = 0 / 4
```

The first number is alignment itself: of the expert's twelve names, 0 are found in the
technical version, 12 in the domain version. The second group gives the price of that gap.
All three change requests come back with 0 candidate lines in the technical version; with no
candidate, the person receiving the request must read all twelve lines, infer what each name
holds, and only then find the right line. In the domain version, the same three requests
narrow to 2 of the file's 14 lines.

#### Alignment Is Not Correctness

The last line of the output says what the measure is not: on all four of four shipments, the
two versions produce the same fee. The technical version is not wrong, nor is it slow.
Misalignment is not paid at run time — it is paid at **change time**.

Where the cost is paid also shows up in the measurement script, in the `convert` function. To
call both versions with the same shipments, a mapping had to be written that knows `weight`
maps to `w`, `volume` maps to `vol`, and `postalCode` maps to `z`. That mapping is four lines
of code, but it is also kept somewhere outside the code: in the head of whoever handles the
request. Domain-driven design's core proposal is to eliminate this mapping — no translation is
needed once the domain and the code use the same words.

The gap is more than a naming habit, because what is lost is not names but
**distinctions**. In the technical version, `LIM` and `MIN` are just two numbers; in the
domain, one is a rate cap and the other is a monetary amount, and the two change on separate
decisions, at separate frequencies. The Design Principles course's policy–detail separation
questioned whether these two numbers belonged in the same file by asking about their rate of
change; domain-driven design asks the same question one step earlier: are these two the same
kind of thing in the domain?

#### Where the Complexity Sits

The approach has a limit, and it follows from its own rationale. Its payoff depends on the
domain itself being complex. On the fee side of the library, tier, zone, volume, cap, and
minimum amount are independent distinctions; the expert's sentence is long and changes often.
That is why carrying twelve names pays off.

Where complexity sits in the technical side rather than the domain, the calculation reverses.
In a component that writes incoming bytes somewhere else, the domain vocabulary is two words;
building a domain layer there does not pay for itself, since there is no name alignment worth
measuring. The dividing line is not the codebase's size — it is how many separate names the
expert needs to state the rule.

The second limit is access to the domain expert. The name-alignment measure's input is the
expert's sentence; without that sentence, the measure has no fixed list either. In that case,
the names in the code belong not to the domain but to the assumptions of whoever wrote it.

#### Summary

- Domain-driven design proposes deriving a codebase's structure from the domain's own
  distinctions; the quality measured throughout the course is the model's alignment with the
  domain.
- The measure's input is the sentence in which the domain expert states a rule; the pricing
  rule's sentence carries twelve names.
- In the version written with technical names, 0 of those names were found verbatim in the
  source; in the version written with domain names, 12 were.
- Three change requests phrased in domain language came back with 0 candidate lines in the
  technical version and narrowed to 2 of 14 lines in the domain version; with no candidate,
  the entire file has to be read.
- On all four of four shipments, the two versions produced the same fee: alignment is not a
  correctness quality, it is a cost paid at change time.
- The payoff depends on the domain's complexity; in work where the expert states the rule
  with two names, there is no alignment worth measuring.

#### Next Step

In this lesson, alignment was measured in a single file, and the expert's twelve names were
written once. In a real codebase, the same concept is named in more than one place: a database
column uses one name, the application layer a second, an exposed interface a third. Then the
question is no longer whether the names come from the domain, but **how many** of them name
the same thing. The next lesson turns this into a measure: it counts the number of synonyms
per concept and the number of points that translate between two names, measures the
inconsistency produced when two synonyms are each subject to the rule separately, and shows
where a single vocabulary drives these numbers down to.

### Ubiquitous Language

Source: https://academia.sh/en/courses/domain-driven-design/ubiquitous-language

Merging code and spoken language into a single vocabulary: counting the number of names per domain concept across two versions, measuring the number of translation points and unit conversions between two names for the same concept, working out how many bodies the same rule is written in and how many of them apply the cap, and measuring how often the quote and the fee diverge across thirty shipments.

The previous lesson measured alignment in a single file: the expert's twelve names were
written once, and all twelve were found in the code. In a real codebase, the same concept is
named in more than one place. The module that takes the input uses one name, the module that
calculates the fee a second, the module that produces the quote a third. Then the question is
no longer whether the names come from the domain, but how many of them name the same thing.

This lesson's subject is the **ubiquitous language**: a domain concept being referred to by a
single name everywhere the domain is spoken and in every layer of the code. The word
"ubiquitous" is not a style suggestion, it is a measurable constraint — the number of names
per concept will be one.

#### Five Concepts, Thirteen Names

The library's pricing side has five concepts: shipment, weight, zone, discount, minimum fee.
The version below is spread across three files, and each file has chosen its own names.

```sh
mkdir -p scattered unified
```

```js
// scattered/entry.mjs — the record's entry point: cargo
export const cargoRecord = (weightGrams, zoneCode, rebates) =>
  ({ weightGrams, zoneCode, rebates });
```

```js
// scattered/pricing.mjs — works with the parcel name, applies the cap
const TIER = [[1, 3900], [5, 6400], [Infinity, 11800]];
const COEFFICIENT = { "34": 1, "06": 1.35, "65": 1.8 };
const BASE_PRICE = 3990;
const CAP = 0.4;

export function fee(cargo) {
  const parcel = {
    weight: cargo.weightGrams / 1000,
    zone: cargo.zoneCode,
    discounts: cargo.rebates,
  };
  const raw = TIER.find(([u]) => parcel.weight <= u)[1] * (COEFFICIENT[parcel.zone] ?? 1.8);
  const rate = Math.min(parcel.discounts.reduce((t, d) => t + d.rate, 0), CAP);
  return Math.max(Math.round(raw * (1 - rate)), BASE_PRICE);
}
```

```js
// scattered/quote.mjs — works with the shipment name, does not know about the cap
const TIER = [[1, 3900], [5, 6400], [Infinity, 11800]];
const COEFFICIENT = { "34": 1, "06": 1.35, "65": 1.8 };
const MINIMUM_FEE = 3990;

export function quote(cargo) {
  const shipment = {
    kg: cargo.weightGrams / 1000,
    zoneId: cargo.zoneCode,
    rebates: cargo.rebates,
  };
  const raw = TIER.find(([u]) => shipment.kg <= u)[1] * (COEFFICIENT[shipment.zoneId] ?? 1.8);
  const rate = shipment.rebates.reduce((t, d) => t + d.rate, 0);
  return Math.max(Math.round(raw * (1 - rate)), MINIMUM_FEE);
}
```

Each of the three files is internally consistent, and every name comes from the domain:
cargo, parcel, and shipment are all words heard in the room where the library is discussed.
The previous lesson's name-alignment measure comes out high for this version. The problem is
not in any single name, it is in the same concept having more than one name.

#### A Single Vocabulary

The unified version picks one name per concept and carries that name from entry to
calculation. Weight comes in as kilograms, so no unit conversion remains inside the file.

```js
// unified/entry.mjs — the record's entry point: shipment, weight in kilograms
export const shipment = (weight, zone, discounts) => ({ weight, zone, discounts });
```

```js
// unified/pricing.mjs — one name set, one rule body
const TIERS = [[1, 3900], [5, 6400], [Infinity, 11800]];
const ZONE_COEFFICIENTS = { "34": 1, "06": 1.35, "65": 1.8 };
const MINIMUM_FEE = 3990;
const DISCOUNT_CAP = 0.4;

const baseFee = (weight) => TIERS.find(([cap]) => weight <= cap)[1];
const discountRate = (discounts) =>
  Math.min(discounts.reduce((t, d) => t + d.rate, 0), DISCOUNT_CAP);

function netFee(shipment) {
  const raw = baseFee(shipment.weight) * (ZONE_COEFFICIENTS[shipment.zone] ?? 1.8);
  return Math.max(Math.round(raw * (1 - discountRate(shipment.discounts))), MINIMUM_FEE);
}

export const fee = (shipment) => netFee(shipment);
export const quote = (shipment) => netFee(shipment);
```

The domain states that the quote and the invoiced fee are subject to the same rule; in the
unified version, that sentence's counterpart is two exported names calling the same body.

#### Measurement

Four measures are taken: the number of names per concept, the number of points that
translate between two names for the same concept, how many bodies the rule is written in,
and how many of those bodies apply the cap. A fifth is added at the end: how often the quote
and the fee diverge across thirty shipments.

```js
// language-measure.mjs — names per concept, translation points, rule bodies, and the two paths diverging
import { readFileSync } from "node:fs";
import { fee as scatteredFee } from "./scattered/pricing.mjs";
import { quote as scatteredQuote } from "./scattered/quote.mjs";
import { cargoRecord } from "./scattered/entry.mjs";
import { fee as unifiedFee, quote as unifiedQuote } from "./unified/pricing.mjs";
import { shipment as unifiedShipment } from "./unified/entry.mjs";

const CONCEPTS = {
  shipment: ["cargo", "parcel", "shipment"],
  weight: ["weightGrams", "kg", "weight"],
  zone: ["zoneCode", "zoneId", "zone"],
  discount: ["rebates", "discounts"],
  minimumFee: ["BASE_PRICE", "MINIMUM_FEE"],
};
const VERSION = {
  scattered: ["scattered/entry.mjs", "scattered/pricing.mjs", "scattered/quote.mjs"],
  unified: ["unified/entry.mjs", "unified/pricing.mjs"],
};
const findConcept = (name) => Object.keys(CONCEPTS).find((k) => CONCEPTS[k].includes(name)) ?? null;

for (const [version, files] of Object.entries(VERSION)) {
  const text = files.map((f) => readFileSync(f, "utf8")).join("\n");
  const tokens = new Set(text.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []);
  const breakdown = [];
  let totalNames = 0;
  for (const [concept, names] of Object.entries(CONCEPTS)) {
    const present = names.filter((n) => tokens.has(n));
    totalNames += present.length;
    breakdown.push(`${concept}=${present.length}`);
  }
  const flat = text.replace(/\s+/g, " ");
  const translationPoints = [...flat.matchAll(/(\w+):\s*\w+\.(\w+)/g)].filter(
    ([, left, right]) => left !== right && findConcept(left) && findConcept(left) === findConcept(right));
  const unitConversions = [...flat.matchAll(/\/ 1000/g)].length;
  const ruleBodies = files.map((f) => readFileSync(f, "utf8"))
    .filter((m) => /Math\.max\(Math\.round/.test(m));
  const capApplying = ruleBodies.filter((m) => /CAP/.test(m)).length;
  console.log(`${version}: file ${files.length}, 5 concepts ${totalNames} name`);
  console.log(`  name distribution: ${breakdown.join("  ")}`);
  console.log(`  translation point ${translationPoints.length}, unit conversion ${unitConversions}`);
  console.log(`  rule body ${ruleBodies.length}, cap-applying body ${capApplying}`);
}

const SAMPLES = [];
for (let i = 0; i < 30; i += 1) {
  const volumeRatio = [0.1, 0.2, 0.3][i % 3] + (i % 5) * 0.05;
  SAMPLES.push([600 + i * 400, ["34", "06", "65"][i % 3],
    [{ name: "contract", rate: 0.2 }, { name: "volume", rate: Math.round(volumeRatio * 100) / 100 }]]);
}
const diverge = (a, b) => SAMPLES.filter((s) => a(s) !== b(s)).length;

console.log(`scattered fee != quote : ${diverge(
  (s) => scatteredFee(cargoRecord(...s)), (s) => scatteredQuote(cargoRecord(...s)))} / ${SAMPLES.length}`);
console.log(`unified   fee != quote : ${diverge(
  (s) => unifiedFee(unifiedShipment(s[0] / 1000, s[1], s[2])),
  (s) => unifiedQuote(unifiedShipment(s[0] / 1000, s[1], s[2])))} / ${SAMPLES.length}`);
```

```sh
node language-measure.mjs
```

```
scattered: file 3, 5 concepts 13 name
  name distribution: shipment=3  weight=3  zone=3  discount=2  minimumFee=2
  translation point 5, unit conversion 2
  rule body 2, cap-applying body 1
unified: file 2, 5 concepts 5 name
  name distribution: shipment=1  weight=1  zone=1  discount=1  minimumFee=1
  translation point 0, unit conversion 0
  rule body 1, cap-applying body 1
scattered fee != quote : 19 / 30
unified   fee != quote : 0 / 30
```

The five concepts carry thirteen names in the scattered version, five in the unified one.
The cost of the eight extra names shows up in the next line: five translation points between
two names for the same concept, and two unit conversions. A translation point is where one
file has to know another file's vocabulary; each is written as a mapping line and has to be
reviewed one by one whenever a name changes.

#### Where the Divergence Comes From

The last two lines give misalignment's cost in terms of behavior: in nineteen of thirty
shipments, the quote and the fee come out different, even though the domain expert says the
two are the same. The direct structural cause of the divergence is in the fourth line: the
scattered version has two rule bodies, and only one of them applies the cap.

The central observation here is that nothing in the code shows the two bodies are the same
rule. Because `parcel.discounts` and `shipment.rebates` are two different names, a reading
that searches for the cap does not find the word it is looking for in the second body. Once
the names diverge, the rules diverging stops being a lapse in attention and becomes an
outcome the structure permits. In the unified version, the same divergence cannot be written:
there is a single body, two exported names call it, and the divergence count drops to 0.

#### Maintaining the Vocabulary

A single vocabulary is not a one-time choice; as the domain expert's language changes, the
code has to change with it. This has two sides. First, when a name changes in the code, its
domain counterpart must also have changed: if the word `rebate` is no longer used, it should
not remain in the source either. Second, and more important, a new distinction born in the
domain has to open up a new name in the code. The moment the contracted customer's discount
and the campaign discount start being subject to separate rules, grouping both under the name
`discount` stops being a single vocabulary — naming two domain concepts with one concept in
the code is the same misalignment in reverse.

This is why the measure runs both ways: the number of names per concept will be one, and the
number of concepts per name will also be one. A violation of the second half starts to appear
later in the course; the situation where the same word `shipment` names two different things
falls outside what this topic measures.

#### When It Does Not Pay Off

A single vocabulary's cost is the work of renaming, measured by the number of files touched.
Removing eight extra names across the three files above is a small job; in a codebase where
the vocabulary is spread across a hundred files, the same job requires a series of mechanical
changes and cannot be done without the behavior protection built in the Software Quality and
Testing course.

The second case concerns the measure's input. The name count can only be taken if the concept
list is fixed. If the domain itself has not settled — if it is not known which distinctions
will last — the names will not settle either, and an early merge can squeeze something that
is really two things in the domain into one name. The measure then drops to zero, but
alignment breaks.

#### Summary

- Ubiquitous language means a domain concept is referred to by a single name everywhere in the
  code; its measure is the number of names per concept.
- The scattered version carried thirteen names across five concepts; the unified version left
  five.
- The cost of the extra names was counted: 5 translation points between two names for the same
  concept and 2 unit conversions, versus 0 and 0 in the unified version.
- The scattered version had the rule split across two bodies, and only one applied the cap;
  the quote and the fee diverged in 19 of thirty shipments, versus 0 in the unified version.
- The measure runs both ways: one name per concept, one concept per name. Violating the second
  means naming something that is two things in the domain with one name in the code.

#### Next Step

In the unified version, a shipment was a flat record carrying three fields, and two shipments
could only be considered the same if their fields were equal. That is not true in the domain:
the same shipment's weight changes with a reweighing correction, and the shipment is still the
same shipment; two separate shipments can have every field equal by coincidence and still be
two shipments. The next lesson turns this distinction into a measure: it counts how many
separate instances the same identity can be represented by and how many separate shipments
collapse into a single instance once fields are equalized, and compares this against a model
that carries identity explicitly.

### Entities

Source: https://academia.sh/en/courses/domain-driven-design/entities

The selection criterion for objects defined by identity: counting how many separate instances twelve shipments reduce to under value equality, measuring how many invoice reads come out correct, working out how many keys point to the same identity after a reweighing correction, and comparing what the two equality definitions answer to the domain's two questions.

In the previous lesson's unified version, a shipment was a flat record carrying three fields,
and two shipments could only be considered the same if their fields were equal. That is not
true in the domain. The same shipment's weight changes with a reweighing correction, and the
shipment is still the same shipment; two separate shipments can have every field equal and
still be two shipments.

The object that carries this distinction is called an **entity**: an object that stays the
same even as its fields change, and whose equality is therefore defined not by its fields but
by an **identity**. The definition was given in the Introduction to Object-Oriented
Programming course; the work here is not the definition but the **selection criterion** —
which domain concept gets an identity, and what is lost when it does not.

#### Two Questions

The criterion reduces to two questions. First: is this thing still the same thing after its
fields change? Second: do two instances whose fields are all equal have to be tracked
separately?

In the shipping-fee library, the answer to both is yes. A customer sends five parcels of the
same weight to the same zone on the same day; each of the five gets its own invoice. When the
same parcel is weighed, its declared weight is corrected; the invoice keeps being issued to
that parcel.

#### Two Models

The first version defines a shipment by its fields. Equality is a key produced by combining
the fields.

```sh
mkdir -p value identity
```

```js
// value/shipment.mjs — shipment without identity: equality is equality of fields
export const shipment = (customer, weight, zone) => ({ customer, weight, zone });
export const key = (s) => `${s.customer}|${s.weight}|${s.zone}`;
export const equals = (a, b) => key(a) === key(b);
export const correctWeight = (s, newWeight) => ({ ...s, weight: newWeight });
```

The second version gives the shipment a number. Equality looks only at that number; the
weight correction changes the object but does not touch its identity.

```js
// identity/shipment.mjs — entity: equality is equality of the shipment number
export class Shipment {
  constructor(shipmentNo, customer, weight, zone) {
    this.shipmentNo = shipmentNo;
    this.customer = customer;
    this.weight = weight;
    this.zone = zone;
  }
  key() { return this.shipmentNo; }
  sameAs(o) { return o instanceof Shipment && o.shipmentNo === this.shipmentNo; }
  correctWeight(newWeight) { this.weight = newWeight; return this; }
}
```

#### Measurement

The measure's input is twelve shipments and the invoice cut for each one. Five shipments
belong to the same customer, the same weight, and the same zone; two belong to a second
customer, and two are repeated among a third customer's heavy parcels.

```js
// records.mjs — 12 shipments in the pricing context and the invoice cut for each
export const RECORDS = [
  ["G-1041", "ARC", 2.4, "34", "F-9001"],
  ["G-1042", "ARC", 2.4, "34", "F-9002"],
  ["G-1043", "ARC", 2.4, "34", "F-9003"],
  ["G-1044", "ARC", 2.4, "34", "F-9004"],
  ["G-1045", "ARC", 2.4, "34", "F-9005"],
  ["G-1046", "ARC", 5, "34", "F-9006"],
  ["G-1047", "BKM", 0.8, "06", "F-9007"],
  ["G-1048", "BKM", 0.8, "06", "F-9008"],
  ["G-1049", "BKM", 12, "65", "F-9009"],
  ["G-1050", "CTS", 0.8, "06", "F-9010"],
  ["G-1051", "CTS", 18, "65", "F-9011"],
  ["G-1052", "CTS", 18, "65", "F-9012"],
];
```

For each version, the invoice table is built with the shipment's key in that version. Then
the first shipment's weight is corrected from 2.4 to 3.1 kilograms and the table is read
again.

```js
// identity-measure.mjs — how many instances do 12 shipments reduce to, how many invoice reads are correct
import { RECORDS } from "./records.mjs";
import * as value from "./value/shipment.mjs";
import { Shipment } from "./identity/shipment.mjs";

function measure(label, build, key, correct) {
  const objects = RECORDS.map(([no, c, w, z]) => build(no, c, w, z));
  const table = new Map();
  objects.forEach((o, i) => table.set(key(o), RECORDS[i][4]));
  const right = objects.filter((o, i) => table.get(key(o)) === RECORDS[i][4]).length;
  console.log(`${label}: 12 shipments -> ${table.size} separate instances`);
  console.log(`  invoice reads correct ${right}/12`);

  const old = key(objects[0]);
  const updated = correct(objects[0], 3.1);
  table.set(key(updated), table.get(old));
  objects[0] = updated;
  const rightAfter = objects.filter((o, i) => table.get(key(o)) === RECORDS[i][4]).length;
  const pointer = [...table.keys()].filter((k) => k === old || k === key(updated)).length;
  console.log(`  after the reweighing correction: table entries ${table.size}, ` +
    `keys pointing to G-1041 ${pointer}`);
  console.log(`  invoice reads correct after the correction ${rightAfter}/12`);
}

measure("value", (no, c, w, z) => value.shipment(c, w, z), value.key,
  (s, w) => value.correctWeight(s, w));
measure("identity", (no, c, w, z) => new Shipment(no, c, w, z), (s) => s.key(),
  (s, w) => s.correctWeight(w));

const [first, second] = [value.shipment("ARC", 2.4, "34"), value.shipment("ARC", 2.4, "34")];
const [s1, s2] = [new Shipment("G-1041", "ARC", 2.4, "34"), new Shipment("G-1042", "ARC", 2.4, "34")];
console.log(`are two separate shipments equal -> value ${value.equals(first, second)}, ` +
  `identity ${s1.sameAs(s2)}`);
const s1b = new Shipment("G-1041", "ARC", 3.1, "34");
console.log(`is the corrected shipment the same -> value ` +
  `${value.equals(first, value.correctWeight(first, 3.1))}, identity ${s1.sameAs(s1b)}`);
```

```sh
node identity-measure.mjs
```

```
value: 12 shipments -> 6 separate instances
  invoice reads correct 6/12
  after the reweighing correction: table entries 7, keys pointing to G-1041 2
  invoice reads correct after the correction 6/12
identity: 12 shipments -> 12 separate instances
  invoice reads correct 12/12
  after the reweighing correction: table entries 12, keys pointing to G-1041 1
  invoice reads correct after the correction 12/12
are two separate shipments equal -> value true, identity false
is the corrected shipment the same -> value false, identity true
```

The first number gives the size of the loss: under value equality, twelve shipments reduce
to six instances. Six shipments collapse onto another shipment's key, so half of the invoice
reads come out wrong — six shipments show the invoice that was not cut for them.

The second number shows the reverse corruption. The weight correction produces a new key in
the value version; the old key cannot be dropped, because other shipments still depend on it.
As a result, the table grows to seven entries, and a single shipment ends up shown under two
keys. In the identity version, the same correction does not change the table's size: the key
`G-1041` stays put, and all twelve reads stay correct.

The last two lines reduce the criterion to a single sentence. The domain's first question is
whether two shipments are the same, and the answer is no; value equality answers yes. The
second question is whether the corrected shipment is still the same shipment, and the answer
is yes; value equality answers no. Of the two definitions, one answers both of the domain's
questions wrong, and the other answers both right.

#### Where Identity Comes From

The only requirement identity carries is that it does not change over the object's lifetime
and is not repeated in any other object. If the domain already has a ready-made identity, that
one is used: a shipment number, a contract number, a tax ID. If not, one is generated, and the
rule for generating it is a decision for the application, not the domain.

The measure breaks when a field from the domain is used as if it were identity. In the pricing
context, the combination of customer and zone often produces a single shipment; "often" is not
an identity criterion, and the measurement above shows the cost of that assumption. For the
same reason, correctable fields such as weight, postal code, and declared value cannot be part
of an identity.

Identity's second consequence is uniqueness: if two instances share the same identity exist in
memory at once, which one is correct becomes unclear. This problem was addressed in the
Identity Map lesson of the Design Patterns course; the entity decision is that pattern's
precondition, because without an identity to preserve uniqueness, there is no key for the map
either.

#### When Identity Is Not Needed

If the criterion's two questions both come back no, the object is not an entity. The library
has a clear example of this: a zone coefficient. There is no distinction between one 1.35
coefficient and another 1.35 coefficient, and the coefficient has no life story. The same is
true of weight, monetary amount, and discount rate.

Giving these concepts identity corrupts the measure in the opposite direction. Once a
coefficient is assigned a number, equality comparison looks at that identity, and two values
that are equal in the domain no longer come out equal in the code. Identity produces a gain
where it belongs; where it does not, it adds a rule to every comparison.

#### Summary

- An entity is an object that stays the same as its fields change and whose equality is
  defined by an identity; the selection criterion is two questions — does it stay the same
  when its fields change, and do two instances with equal fields have to be tracked
  separately.
- Under value equality, twelve shipments reduced to six instances; 6 of the invoice reads
  came out wrong.
- The reweighing correction grew the value version's table to seven entries and left one
  shipment shown under two keys; the identity version's table stayed at twelve entries, with
  all twelve reads correct.
- Value equality answered both of the domain's questions wrong; identity equality answered
  both right.
- Identity has to be a name that does not change over the object's lifetime and is not
  repeated; correctable fields cannot be part of identity.
- If both questions come back no, identity is not needed; giving identity to concepts such as
  coefficients, weights, and rates adds an unnecessary rule to every comparison.

#### Next Step

This lesson's last section counted the concepts that do not need identity, but still kept
them as bare numbers and strings: weight is a `number`, zone is a `string`. That leaves the
code free to add a weight to a volume, compare a kilogram with a gram, and multiply a cent by
a unit of currency. The next lesson turns these concepts into their own types, counts how
many places the equality rule is written by hand, and measures how many cases a unit mix-up
produces a silently wrong amount.

### Value Objects

Source: https://academia.sh/en/courses/domain-driven-design/value-objects

The selection criterion for immutable objects defined by value: counting how many wrong amounts the bare-number model produces from twelve inputs arriving in grams, measuring how many lines the equality rule is written by hand, working out how many shipments break when a shared measurement is corrected in place, and showing that the two models produce the same grouping.

The previous lesson's last section counted the concepts that do not need identity, but still
kept them as bare numbers: weight was a number, zone was a string. That leaves passing a
kilogram and a gram into the same parameter, adding them, and comparing them as operations
the code allows.

An object with no identity, whose equality is defined by the value it carries, and which does
not change once constructed, is called a **value object**. The definition was given in the
Introduction to Object-Oriented Programming course. The work here is again the selection
criterion, and it splits into three questions: is identity needed, what is equality built on,
and what does immutability make cheaper.

#### Criterion

If the previous lesson's two questions both come back no for a concept, that concept is not
an entity: there is no distinction between two instances of it, and it carries no life story.
Weight is like this. 2.4 kilograms and another 2.4 kilograms are the same thing; correcting
the weight is not the weight changing, it is the shipment coming to have a different weight.

The second question is what equality is built on, and its answer includes the measurement's
unit. 2400 grams and 2.4 kilograms are equal; 2400 and 2.4 are not. The difference is where
the unit is kept.

#### The Bare-Number Model

In the first version, the unit is written only in the parameter name.

```sh
mkdir -p bare value
```

```js
// bare/fee.mjs — weight is a bare number, the unit lives only in the parameter name
const TIERS = [[1, 3900], [5, 6400], [Infinity, 11800]];
const ZONE_COEFFICIENTS = { "34": 1, "06": 1.35, "65": 1.8 };
const MINIMUM_FEE = 3990;

export function netFee(weightKg, zone, rate) {
  const base = TIERS.find(([cap]) => weightKg <= cap)[1];
  const raw = base * (ZONE_COEFFICIENTS[zone] ?? 1.8);
  return Math.max(Math.round(raw * (1 - rate)), MINIMUM_FEE);
}
```

```js
// bare/reconciliation.mjs — the equality rule is rewritten at every call site
export const weighInMatches = (declaredKg, measuredKg) => Math.abs(declaredKg - measuredKg) < 0.001;
export const sameTier = (aKg, bKg) => Math.abs(aKg - bKg) < 0.001;
export const addWeight = (aKg, bKg) => aKg + bKg;
```

```js
// bare/report.mjs — grouping by weight; the equality rule is written here too
export function weightGroups(weightsKg) {
  const groups = [];
  for (const w of weightsKg) {
    const group = groups.find((g) => Math.abs(g[0] - w) < 0.001);
    if (group) group.push(w);
    else groups.push([w]);
  }
  return groups;
}
```

Comparing floating-point numbers requires a tolerance margin, and that margin is written
three times across three files. The size of the margin is a domain decision — how many grams
apart can two weights still count as the same — but in the code it does not look like a
domain decision.

#### The Value-Object Model

In the second version, weight gets its own type. The unit is resolved in the constructor,
the internal representation is grams, and the object is frozen.

```js
// value/weight.mjs — immutable value object: the unit lives inside the type
const GRAMS = { kg: 1000, g: 1 };

export class Weight {
  constructor(amount, unit) {
    if (!(unit in GRAMS)) throw new RangeError(`unknown weight unit: ${unit}`);
    this.grams = Math.round(amount * GRAMS[unit]);
    Object.freeze(this);
  }
  static kg(amount) { return new Weight(amount, "kg"); }
  static g(amount) { return new Weight(amount, "g"); }
  kilogram() { return this.grams / 1000; }
  equals(o) { return o instanceof Weight && o.grams === this.grams; }
  plus(o) { return new Weight(this.grams + o.grams, "g"); }
}
```

The tolerance margin disappears because the internal representation is an integer: rounding
happens once, in the constructor, and comparison becomes integer equality. `plus` returns a
new object; it does not change its caller.

```js
// value/fee.mjs — expects a Weight instead of a bare number, rejects a bare number
import { Weight } from "./weight.mjs";

const TIERS = [[1, 3900], [5, 6400], [Infinity, 11800]];
const ZONE_COEFFICIENTS = { "34": 1, "06": 1.35, "65": 1.8 };
const MINIMUM_FEE = 3990;

export function netFee(weight, zone, rate) {
  if (!(weight instanceof Weight)) throw new TypeError("weight must be a Weight");
  const base = TIERS.find(([cap]) => weight.kilogram() <= cap)[1];
  const raw = base * (ZONE_COEFFICIENTS[zone] ?? 1.8);
  return Math.max(Math.round(raw * (1 - rate)), MINIMUM_FEE);
}
```

```js
// value/reconciliation.mjs — the equality rule lives inside the value object, the call site invokes it
export const weighInMatches = (declared, measured) => declared.equals(measured);
export const sameTier = (a, b) => a.equals(b);
export const addWeight = (a, b) => a.plus(b);
```

```js
// value/report.mjs — the same grouping; equality is the value object's own method
export function weightGroups(weights) {
  const groups = [];
  for (const w of weights) {
    const group = groups.find((g) => g[0].equals(w));
    if (group) group.push(w);
    else groups.push([w]);
  }
  return groups;
}
```

#### Measurement

Three measures are taken. Half of twenty-four inputs arrive in grams; how many of them
produce a silently wrong amount is counted. How many lines the equality rule is written on is
scanned. While five shipments share the same measurement, how many break when one is
corrected is measured.

```js
// value-measure.mjs — unit mix-ups, where the equality rule lives, and a shared instance breaking
import { readFileSync } from "node:fs";
import { netFee as bareFee } from "./bare/fee.mjs";
import { netFee as valueFee } from "./value/fee.mjs";
import { Weight } from "./value/weight.mjs";
import { weightGroups as bareGroups } from "./bare/report.mjs";
import { weightGroups as valueGroups } from "./value/report.mjs";

const ZONES = ["34", "06", "65"];
const INPUTS = [];
for (let i = 0; i < 24; i += 1) {
  const kg = [0.4, 0.9, 2.4, 3.1, 7, 12][i % 6];
  INPUTS.push({ source: i % 2 === 0 ? "kg" : "g", kg, zone: ZONES[i % 3] });
}
// correct fee: the rule applied to the true kilogram value
const correctFee = (g) => bareFee(g.kg, g.zone, 0);

let bareWrong = 0;
for (const g of INPUTS) {
  const raw = g.source === "kg" ? g.kg : g.kg * 1000;
  if (bareFee(raw, g.zone, 0) !== correctFee(g)) bareWrong += 1;
}
let valueWrong = 0;
let valueError = 0;
for (const g of INPUTS) {
  const raw = g.source === "kg" ? g.kg : g.kg * 1000;
  if (valueFee(new Weight(raw, g.source), g.zone, 0) !== correctFee(g)) valueWrong += 1;
  try { valueFee(raw, g.zone, 0); } catch { valueError += 1; }
}
console.log(`24 inputs, 12 arrive in grams`);
console.log(`  bare : silently wrong fee ${bareWrong}/24, thrown error 0/24`);
console.log(`  value: silently wrong fee ${valueWrong}/24, ` +
  `error when a bare number is passed ${valueError}/24`);

const RULE = /Math\.abs\(|\.grams ===/;
for (const [label, files] of Object.entries({
  bare: ["bare/fee.mjs", "bare/reconciliation.mjs", "bare/report.mjs"],
  value: ["value/fee.mjs", "value/reconciliation.mjs", "value/report.mjs", "value/weight.mjs"],
})) {
  const lines = files.flatMap((f) => readFileSync(f, "utf8").split("\n"));
  console.log(`${label}: equality-rule line ${lines.filter((s) => RULE.test(s)).length}`);
}

const MEASUREMENTS = [0.4, 2.4, 0.4, 7, 2.4, 12, 7, 0.9];
const bareResult = bareGroups(MEASUREMENTS).map((g) => g.length).join(",");
const valueResult = valueGroups(MEASUREMENTS.map((k) => Weight.kg(k))).map((g) => g.length).join(",");
console.log(`8 readings grouped -> bare [${bareResult}], value [${valueResult}], ` +
  `same ${bareResult === valueResult}`);

const shared = { amountKg: 2.4 };
const shipments = [0, 1, 2, 3, 4].map(() => ({ weight: shared }));
shared.amountKg = 3.1;
const broken = shipments.slice(1).filter((s) => s.weight.amountKg !== 2.4).length;

const firstWeight = Weight.kg(2.4);
const shipmentsValue = [0, 1, 2, 3, 4].map(() => ({ weight: firstWeight }));
shipmentsValue[0].weight = Weight.kg(3.1);
const brokenValue = shipmentsValue.slice(1).filter((s) => !s.weight.equals(Weight.kg(2.4))).length;
let inPlaceError = 0;
try { firstWeight.grams = 3100; } catch { inPlaceError = 1; }

console.log(`5 shipments share the same weight instance, one is corrected`);
console.log(`  mutable record: broken shipments ${broken}/4`);
console.log(`  value object  : broken shipments ${brokenValue}/4, ` +
  `in-place mutation attempt error ${inPlaceError}`);
```

```sh
node value-measure.mjs
```

```
24 inputs, 12 arrive in grams
  bare : silently wrong fee 8/24, thrown error 0/24
  value: silently wrong fee 0/24, error when a bare number is passed 24/24
bare: equality-rule line 3
value: equality-rule line 1
8 readings grouped -> bare [2,2,2,1,1], value [2,2,2,1,1], same true
5 shipments share the same weight instance, one is corrected
  mutable record: broken shipments 4/4
  value object  : broken shipments 0/4, in-place mutation attempt error 1
```

The first measure answers the unit question. In eight of the twelve inputs arriving in grams, the
bare-number model produces a wrong amount, and it does so silently: the error count is zero, the
caller gets no warning. In the remaining four inputs, the amount comes out right by coincidence,
because the 12-kilogram shipments fall into the top tier even read as grams; a coincidence is not a
guarantee. In the value-object model, the wrong-amount count is zero. In the same model, when a
bare number is passed, twenty-four of twenty-four calls throw an error — a silent wrong has turned
into a loud one.

The second measure answers the equality question. The tolerance margin is written on three
lines in the bare model; in the value-object model, on one line, inside the `equals` method.
The fact that the margin is a domain decision becomes visible in the second case: if it has
to change, there is one place to touch.

The third measure shows what immutability makes cheaper. When a mutable record is shared by
five shipments, one correction breaks four of them. In the value-object model, the same
correction produces a new object, so the broken-shipment count is zero. On the frozen object,
an in-place mutation attempt throws an error. The gain is not having to write a defensive
copy: sharing becomes free.

The grouping line again says alignment is separate from correctness: eight readings sort into
the same five groups in both models.

#### What a Value Object Can Carry

The real gain a value object carries is being able to carry its own rules. Weight cannot be
negative, a zone code is two digits, a discount rate is between zero and one. In the
bare-number model, these rules scatter across call sites; in the value object, they go into
the constructor and are written once. The result is that a value violating the rule can never
exist at all — this is exactly the kind of reachability the next lesson measures.

The library's second candidate for the same treatment is the monetary amount: mixing up
cents and the main currency unit produces the same error as weight, and the fix is the same —
a type that keeps the amount as an integer number of cents and carries equality and addition
on itself.

#### When It Does Not Pay Off

The cost has two line items: one type per concept, and one method call per access. This cost
shows up in the measurement files — the value-object version holds four files, the bare
version three.

Where a concept is read once at a single point and never used again, this cost goes unpaid
for. In a raw record crossing a boundary, weight is a number; converting it to a type only
pays off once that value starts circulating inside the library. Likewise, a measurement with
a single unit that is never compared — a sequence number written to a log, for instance —
does not earn a type of its own.

#### Summary

- A value object has no identity, its equality is built on the value it carries, and it does
  not change once constructed; its criterion is three questions: identity, equality,
  immutability.
- In 8 of twelve inputs arriving in grams, the bare-number model silently produced a wrong
  amount; in the value-object model the wrong-amount count was 0, and passing a bare number
  produced an error in 24 of 24 calls.
- The equality tolerance margin is written on 3 lines in the bare model and on 1 line in the
  value-object model; the domain decision sits at a single point in the second case.
- While five shipments shared the same measurement, one correction broke 4 shipments in the
  mutable record and 0 in the value object; on the frozen object, in-place mutation produced
  an error.
- Eight readings sorted into the same groups in both models: the gain is not in correctness,
  it is in making the wrong value unwritable.
- The cost is one type per concept and one call per access; for raw values read once and left
  alone, this cost goes unpaid for.

#### Next Step

So far, the shipment has been treated on its own. In the domain, it is not on its own: it has
discounts, a contract, and declared items, and a rule stands between them — the sum of
discount rates cannot exceed the cap. This rule does not belong to a single object; it
belongs to the whole of the shipment and its discounts. The next lesson draws that whole's
boundary: it counts how many separate call paths in the code can produce a state that
violates the rule, compares a model that exposes the boundary against one that only lets
access pass through the root, and measures how many separate wholes a single transaction
writes.

### Aggregates and the Root

Source: https://academia.sh/en/courses/domain-driven-design/aggregates-and-root

Drawing the consistency boundary: counting, across five paths, how many separate call paths can break the discount-cap invariant, showing that this count drops to zero when access passes through the root, measuring how many aggregates a single transaction touches, and the cost of turning the link between aggregates into an identity reference.

So far, the shipment has been treated on its own. In the domain, it is not on its own: it has
discounts, a contract, and declared items, and a rule stands between them — the sum of the
discount rates cannot exceed the cap. This rule does not belong to the shipment alone, nor to
the discounts alone; it belongs to the whole of the shipment and its discounts.

A whole of this kind is called an **aggregate**, and the single object through which access
to that whole passes is called the **aggregate root**. The concept of an aggregate was
established as a unit of access and writing in the Repository and Unit of Work lesson of the
Design Patterns course. This lesson's question is different: where is the boundary drawn by
the criterion of consistency — which invariant falls inside the boundary, and is the boundary
held.

#### Whose Invariant Is It

The criterion is one sentence: an invariant is placed inside the smallest boundary that
contains all the data needed to check its correctness. The data the discount cap needs is a
single shipment's discount list; no other shipment, no other contract needs to be looked at.
The boundary therefore covers the shipment and its discounts, and does not cover the
contract.

The contract has its own invariant: the committed volume cannot be exceeded. That rule's
input is the count of every shipment tied to the contract, so a second boundary is born. The
link between the two boundaries is not an object reference but an **identity reference**: the
shipment carries the contract number, not the contract object.

#### The Model That Exposes the Boundary

In the first version, the discount list is directly accessible, and the cap rule lives only
inside a helper function.

```sh
mkdir -p open root
```

```js
// open/shipment.mjs — discounts are exposed; the cap rule lives only in the helper function
export const DISCOUNT_CAP = 0.4;

export const shipment = (shipmentNo, contractNo) =>
  ({ shipmentNo, contractNo, discounts: [] });
export const totalRate = (s) => s.discounts.reduce((t, d) => t + d.rate, 0);

export function addDiscount(s, discount) {
  if (totalRate(s) + discount.rate > DISCOUNT_CAP) throw new RangeError("cap exceeded");
  s.discounts.push(discount);
  return s;
}
```

```js
// open/scenario.mjs — contract discount: two aggregates are written in the same transaction
import { addDiscount } from "./shipment.mjs";

export function applyContractDiscount(s, contract, rate, write) {
  addDiscount(s, { name: "contract", rate });
  contract.usedVolume += 1;
  write("shipment", s.shipmentNo);
  write("contract", contract.contractNo);
}
```

#### The Model Where Access Passes Through the Root

In the second version, the discount list is a private field. The list handed out is a copy,
and its items are frozen; adding to it can only go through the root's method.

```js
// root/shipment.mjs — aggregate root: discounts are reachable only through the root
const DISCOUNT_CAP = 0.4;

export class Shipment {
  #discounts = [];
  constructor(shipmentNo, contractNo) {
    this.shipmentNo = shipmentNo;
    this.contractNo = contractNo;
  }
  totalRate() { return this.#discounts.reduce((t, d) => t + d.rate, 0); }
  discounts() { return this.#discounts.map((d) => Object.freeze({ ...d })); }
  addDiscount(discount) {
    if (this.totalRate() + discount.rate > DISCOUNT_CAP) throw new RangeError("cap exceeded");
    this.#discounts.push(Object.freeze({ ...discount }));
    return this;
  }
}
```

```js
// root/scenario.mjs — each transaction writes a single aggregate; the contract is referenced by identity
export function applyContractDiscount(shipment, rate, write) {
  shipment.addDiscount({ name: "contract", rate });
  write("shipment", shipment.shipmentNo);
}

export function processContractUsage(contract, write) {
  contract.usedVolume += 1;
  write("contract", contract.contractNo);
}
```

#### Measurement

Two measures are taken. The first is **reachability**: for a shipment carrying a discount at
a 0.30 rate, in how many of five separate call paths can a state exceeding the cap be
produced. The second is **aggregates per transaction**: how many separate aggregates a
scenario writes.

```js
// boundary-measure.mjs — how many paths can break the invariant, how many aggregates does one transaction write
import * as open from "./open/shipment.mjs";
import { Shipment } from "./root/shipment.mjs";
import { applyContractDiscount as openScenario } from "./open/scenario.mjs";
import * as rootScenario from "./root/scenario.mjs";

const CAP = 0.4;
const EXTRA = { name: "volume", rate: 0.5 };
const REPLACEMENT = [{ name: "volume", rate: 0.9 }];

const OPEN_PATHS = {
  "field method": (s) => open.addDiscount(s, EXTRA),
  "push to array": (s) => { s.discounts.push({ ...EXTRA }); },
  "item rate": (s) => { s.discounts[0].rate = 0.5; },
  "replacing the array": (s) => { s.discounts = REPLACEMENT.map((d) => ({ ...d })); },
  "bulk assign": (s) => { Object.assign(s, { discounts: REPLACEMENT.map((d) => ({ ...d })) }); },
};
const ROOT_PATHS = {
  "field method": (s) => s.addDiscount(EXTRA),
  "push to array": (s) => { s.discounts().push({ ...EXTRA }); },
  "item rate": (s) => { s.discounts()[0].rate = 0.5; },
  "replacing the array": (s) => { s.discounts = REPLACEMENT.map((d) => ({ ...d })); },
  "bulk assign": (s) => { Object.assign(s, { discounts: REPLACEMENT.map((d) => ({ ...d })) }); },
};

function scan(label, build, rate, paths) {
  let violations = 0;
  let errors = 0;
  for (const [path, attempt] of Object.entries(paths)) {
    const s = build();
    try { attempt(s); } catch { errors += 1; }
    const broken = rate(s) > CAP;
    if (broken) violations += 1;
    console.log(`  ${path.padEnd(24)} total rate ${rate(s).toFixed(2)}  ` +
      `${broken ? "VIOLATED" : "held"}`);
  }
  console.log(`${label}: ${violations} of 5 paths broke the invariant, ${errors} threw an error`);
}

console.log("open model");
scan("open", () => open.addDiscount(open.shipment("G-2001", "S-77"), { name: "contract", rate: 0.3 }),
  open.totalRate, OPEN_PATHS);
console.log("root model");
scan("root", () => new Shipment("G-2001", "S-77").addDiscount({ name: "contract", rate: 0.3 }),
  (s) => s.totalRate(), ROOT_PATHS);

function measureTransaction(label, run) {
  const written = [];
  run((kind) => written.push(kind));
  const distinct = new Set(written);
  console.log(`${label}: write call ${written.length}, aggregate touched ${distinct.size} ` +
    `[${[...distinct].join(" ")}]`);
}

measureTransaction("open transaction", (write) => {
  const s = open.shipment("G-2002", "S-77");
  openScenario(s, { contractNo: "S-77", usedVolume: 4 }, 0.25, write);
});
measureTransaction("root transaction", (write) => {
  const s = new Shipment("G-2002", "S-77");
  rootScenario.applyContractDiscount(s, 0.25, write);
});
measureTransaction("root second transaction", (write) => {
  rootScenario.processContractUsage({ contractNo: "S-77", usedVolume: 4 }, write);
});
```

```sh
node boundary-measure.mjs
```

```
open model
  field method             total rate 0.30  held
  push to array            total rate 0.80  VIOLATED
  item rate                total rate 0.50  VIOLATED
  replacing the array      total rate 0.90  VIOLATED
  bulk assign              total rate 0.90  VIOLATED
open: 4 of 5 paths broke the invariant, 1 threw an error
root model
  field method             total rate 0.30  held
  push to array            total rate 0.30  held
  item rate                total rate 0.30  held
  replacing the array      total rate 0.30  held
  bulk assign              total rate 0.30  held
root: 0 of 5 paths broke the invariant, 2 threw an error
open transaction: write call 2, aggregate touched 2 [shipment contract]
root transaction: write call 1, aggregate touched 1 [shipment]
root second transaction: write call 1, aggregate touched 1 [contract]
```

In the open model, four of the five paths produce a shipment that exceeds the cap. The one path
where the rule is written — the helper function — throws an error; the remaining four touch the
list directly and never see the rule. In the root model, zero of the same five attempts break the
invariant: two attempts throw an error, one is ineffective because it works on a copy, and two
cannot reach the root's private field. Preserving the invariant stops being a question of
attentiveness and becomes a guarantee the structure provides.

The last three lines give the boundary's width. In the open model, a single transaction
writes two aggregates: the shipment and the contract are finalized together. In the root
model, the same work splits into two transactions, and each one touches a single aggregate.

#### The Boundary's Cost

The cost of the one-aggregate-per-transaction rule is plain: the contract's used volume is
not updated the moment a discount is added to the shipment. Consistency between the two
aggregates is not **immediate**; the contract carries the old count until the second
transaction runs.

This is a decision separate from choosing the transaction boundary. Where the transaction
boundary is drawn, and whether two writes are finalized together, was measured in the
Transaction Boundaries lesson of the Data Access Layer and Business Logic course. The
consistency boundary comes before that: it says which rule needs to be held immediately. The
discount cap has to be held immediately, because a shipment that exceeds the cap must not
exist even for an instant. The contract's volume count does not have to be held immediately,
because the domain treats it as a reconciliation question.

The measure therefore runs both ways. Widening the boundary lowers the number of aggregates
per transaction, but grows the data being locked; narrowing it brings writes down to a single
aggregate, but raises the number of rules that are not held immediately. The choice is the
answer to the question of which rule cannot be broken even for an instant.

#### The Root's Rules

Three constraints produce the guarantee seen in the measurement. Objects inside the boundary
are not given out by reference from the outside; if they are given out, a copy is given. A
reference to an aggregate outside the boundary is made by identity, not by object. Every
operation that changes what is inside the boundary passes through a method on the root.

The third constraint shows up directly in the measurement above: because `discounts()`
returns a copy, the "push to array" path stays ineffective; because its items are frozen, the
"item rate" path throws an error. Without the copy, both paths would break the invariant.

#### When It Does Not Apply

The cost of access passing through the root is that every read wanting to look inside the
boundary has to go through the root, and every read produces a copy. In a whole with no
invariant, this cost goes unpaid for: drawing a boundary around objects that are merely read
together and share no rule produces nothing more than a level of indirection.

The second case concerns the boundary's size. An aggregate grows more expensive as the number
of objects inside it grows: every write loads the whole, every read produces a copy. Putting
every shipment tied to a contract into a single aggregate would hold both the discount cap and
the contract volume immediately, at the cost of preventing two shipments on the same contract
from being processed at the same time. The boundary's correct place is the smallest container
for the set of rules that must be held immediately.

#### Summary

- An aggregate is a whole of objects sharing an invariant; the aggregate root is the single
  object through which access to that whole passes.
- The boundary's criterion is consistency: an invariant is placed inside the smallest
  boundary that contains all the data needed to check it. The discount cap covers the
  shipment and its discounts, not the contract.
- In the model that exposes the boundary, 4 of five call paths produced a shipment that
  exceeded the cap; in the model where access passes through the root, 0 did.
- In the open model, one transaction touched 2 aggregates; in the root model, the same work
  split into two transactions, each touching 1 aggregate.
- The cost is consistency that is not immediate: the contract's volume count is not updated
  the moment the shipment is written.
- Three constraints produce the guarantee — no reference is given out to the inside,
  references outside the boundary are made by identity, and every change passes through the
  root.

#### Next Step

The root's methods decide using the data inside their own boundary. The library has one more
behavior that does not fit this pattern: choosing the best tariff to apply to a shipment.
That decision requires the shipment, the tariff catalog, and the customer's contract
together; none of the three owns the others. Loading the decision onto the shipment raises
the number of outside names the shipment has to know. The next lesson measures that number,
determines whether a behavior belongs to an object or to a separate domain service using the
count of collaborators, and establishes the measure that separates a domain service from an
application service.

### Domain Services

Source: https://academia.sh/en/courses/domain-driven-design/domain-services

Measuring, across two models where best-tariff selection is loaded onto the shipment and where it is handed to a separate domain service, the number of modules the shipment module imports, the number of objects reachable from a shipment, and the serialization length; counting the persistence trace that separates the domain service from the application service.

The aggregate root's methods decide with the data inside their own boundary. The library
holds a behavior that does not fit this pattern: selecting the best tariff to apply to a
shipment. The domain expert states the rule this way — among the eligible tariffs, the one
with the lowest net fee is selected, and a tariff that requires a contract applies only to a
customer whose contract authorizes that tariff.

The decision requires three things together: the shipment, the tariff catalog, and the
contract. None of the three owns the others. The place where a behavior is put that cannot
decide with its own data, that requires several domain objects together, and that belongs to
none of them, is called a **domain service**.

#### The Metric: Number of Collaborators

Where a behavior belongs is determined by two questions. How many domain objects does making
the decision require? Does one of these objects own the others?

Deciding whether a shipment counts as bulky requires only the shipment's weight; the number
of collaborators is zero, and the decision is the shipment's method. Tariff selection
requires three objects and has no owner; the decision belongs to a domain service. The cap
rule requires two objects — the shipment and its discounts — but one owns the other, so it
remains the root's method, as in the previous lesson.

#### The Model That Loads Behavior Onto the Object

Both versions use the same catalog and the same authorization rule.

```sh
mkdir -p common loaded service
```

```js
// common/tariff-catalog.mjs — tariff catalog and fee calculation for a tariff
export const TARIFFS = [
  { name: "standard", tier: [[1, 3900], [5, 6400], [Infinity, 11800]], minimum: 3990, contracted: false },
  { name: "high-volume", tier: [[1, 4200], [5, 5900], [Infinity, 9800]], minimum: 4500, contracted: true },
  { name: "regional", tier: [[1, 3600], [5, 6900], [Infinity, 13200]], minimum: 3600, contracted: false },
];
const ZONE_COEFFICIENTS = { "34": 1, "06": 1.35, "65": 1.8 };

export function tariffFee(weight, zone, tariff) {
  const base = tariff.tier.find(([cap]) => weight <= cap)[1];
  return Math.max(Math.round(base * (ZONE_COEFFICIENTS[zone] ?? 1.8)), tariff.minimum);
}
```

```js
// common/contract-rules.mjs — authorization rule for a tariff that requires a contract
export const isAuthorized = (contract, tariff) =>
  !tariff.contracted || (contract?.authorizedTariffs ?? []).includes(tariff.name);
```

The first version makes selection the shipment's method. The method imports the catalog and
the authorization rule in its own module, and keeps the contract inside the shipment as well.

```js
// loaded/shipment.mjs — selection is the shipment's method; catalog and contract live inside the shipment
import { TARIFFS, tariffFee } from "../common/tariff-catalog.mjs";
import { isAuthorized } from "../common/contract-rules.mjs";

export class Shipment {
  constructor(shipmentNo, weight, zone, contract) {
    this.shipmentNo = shipmentNo;
    this.weight = weight;
    this.zone = zone;
    this.contract = contract;
  }
  bestTariff() {
    const eligible = TARIFFS.filter((t) => isAuthorized(this.contract, t));
    return eligible.reduce((best, t) =>
      tariffFee(this.weight, this.zone, t) < tariffFee(this.weight, this.zone, best)
        ? t : best);
  }
  netFee() { return tariffFee(this.weight, this.zone, this.bestTariff()); }
}
```

This version also breaks the previous lesson's rule: the shipment carries an aggregate
outside its own consistency boundary — the contract — by object, not by identity.

#### The Model That Hands Behavior to the Service

In the second version, the shipment holds only its own data and refers to the contract by
its number. Selection becomes a function with three parameters, in a separate module.

```js
// service/shipment.mjs — the shipment carries only its own data, references the contract by its number
export class Shipment {
  constructor(shipmentNo, weight, zone, contractNo) {
    this.shipmentNo = shipmentNo;
    this.weight = weight;
    this.zone = zone;
    this.contractNo = contractNo;
  }
  isBulky() { return this.weight >= 5; }
}
```

```js
// service/tariff-selection.mjs — domain service: all three are parameters, none is owned
import { tariffFee } from "../common/tariff-catalog.mjs";
import { isAuthorized } from "../common/contract-rules.mjs";

export function bestTariff(shipment, tariffs, contract) {
  const eligible = tariffs.filter((t) => isAuthorized(contract, t));
  if (eligible.length === 0) throw new RangeError("no eligible tariff");
  return eligible.reduce((best, t) =>
    tariffFee(shipment.weight, shipment.zone, t)
      < tariffFee(shipment.weight, shipment.zone, best) ? t : best);
}

export const netFee = (shipment, tariffs, contract) =>
  tariffFee(shipment.weight, shipment.zone, bestTariff(shipment, tariffs, contract));
```

The line that separates the domain service from the application service appears in a third
file that does the same job. The application layer's responsibility was established in the
Layer Responsibilities lesson of the Data Access Layer and Business Logic course, and the
service layer pattern was established in the Design Patterns course; the only question here
is which knowledge sits in which file.

```js
// service/price.mjs — application service: this is where the repository, clock, and saving are known
import { netFee } from "./tariff-selection.mjs";

export function price(shipmentNo, repository, clock) {
  const shipment = repository.findShipment(shipmentNo);
  const contract = repository.findContract(shipment.contractNo);
  const amount = netFee(shipment, repository.tariffs(), contract);
  repository.save({ shipmentNo, amount, at: clock() });
  return amount;
}
```

#### Measurement

Four measurements are taken: the number of modules the shipment module imports, the number
of objects reachable from a shipment instance, the serialization length, and the persistence
trace of two files. At the end, whether the two models give the same tariff and the same
amount for six shipments is added.

```js
// service-measure.mjs — external names the shipment knows, object graph, persistence trace, and equivalence
import { readFileSync } from "node:fs";
import { TARIFFS } from "./common/tariff-catalog.mjs";
import { Shipment as LoadedShipment } from "./loaded/shipment.mjs";
import { Shipment as ServiceShipment } from "./service/shipment.mjs";
import { bestTariff, netFee } from "./service/tariff-selection.mjs";

const CONTRACTS = {
  "S-77": { contractNo: "S-77", authorizedTariffs: ["high-volume"] },
  "S-90": { contractNo: "S-90", authorizedTariffs: [] },
};
const INPUTS = [
  ["G-3001", 0.4, "34", "S-77"], ["G-3002", 3, "06", "S-77"],
  ["G-3003", 12, "65", "S-77"], ["G-3004", 0.9, "34", "S-90"],
  ["G-3005", 7, "06", "S-90"], ["G-3006", 18, "65", "S-90"],
];

const importCount = (d) => (readFileSync(d, "utf8").match(/^import /gm) ?? []).length;
console.log(`modules imported by the shipment module: loaded ${importCount("loaded/shipment.mjs")}, ` +
  `service ${importCount("service/shipment.mjs")}`);

function reachable(root) {
  const seen = new Set();
  const queue = [root];
  while (queue.length > 0) {
    const n = queue.pop();
    if (n === null || typeof n !== "object" || seen.has(n)) continue;
    seen.add(n);
    for (const v of Object.values(n)) queue.push(v);
  }
  return seen.size;
}

const l = new LoadedShipment("G-3001", 0.4, "34", CONTRACTS["S-77"]);
const s = new ServiceShipment("G-3001", 0.4, "34", "S-77");
console.log(`objects reachable from a shipment: loaded ${reachable(l)}, ` +
  `service ${reachable(s)}`);
console.log(`serialization length: loaded ${JSON.stringify(l).length}, ` +
  `service ${JSON.stringify(s).length}`);

const TRACE = /repository|save|clock/;
for (const d of ["service/tariff-selection.mjs", "service/price.mjs"]) {
  const lines = readFileSync(d, "utf8").split("\n").filter((line) => TRACE.test(line));
  console.log(`${d}: lines carrying a persistence trace ${lines.length}`);
}

let diverged = 0;
for (const [no, weight, zone, contractNo] of INPUTS) {
  const lg = new LoadedShipment(no, weight, zone, CONTRACTS[contractNo]);
  const sg = new ServiceShipment(no, weight, zone, contractNo);
  const contract = CONTRACTS[contractNo];
  const same = lg.bestTariff().name === bestTariff(sg, TARIFFS, contract).name
    && lg.netFee() === netFee(sg, TARIFFS, contract);
  if (!same) diverged += 1;
  console.log(`  ${no} ${String(weight).padStart(4)} kg ${zone} ${contractNo} -> ` +
    `${lg.bestTariff().name.padEnd(12)} ${String(lg.netFee()).padStart(6)}`);
}
console.log(`diverged results = ${diverged} / ${INPUTS.length}`);
```

```sh
node service-measure.mjs
```

```
modules imported by the shipment module: loaded 2, service 0
objects reachable from a shipment: loaded 3, service 1
serialization length: loaded 117, service 68
service/tariff-selection.mjs: lines carrying a persistence trace 0
service/price.mjs: lines carrying a persistence trace 6
  G-3001  0.4 kg 34 S-77 -> regional       3600
  G-3002    3 kg 06 S-77 -> high-volume    7965
  G-3003   12 kg 65 S-77 -> high-volume   17640
  G-3004  0.9 kg 34 S-90 -> regional       3600
  G-3005    7 kg 06 S-90 -> standard      15930
  G-3006   18 kg 65 S-90 -> standard      21240
diverged results = 0 / 6
```

The first line gives the direct cost of loading behavior onto the object: the shipment
module has to import two external modules. This means every place that uses the shipment
indirectly loads the tariff catalog and the contract rules as well.

The second and third lines are the object-level counterpart of the cost. In the loaded
model, three objects are reachable from a shipment — the shipment itself, the contract, and
the contract's authorization list — in the service model, one. The serialization length
drops from 117 to 68; when the shipment travels alone, the contract does not travel with it.

The fourth measurement gives the line between the domain service and the application
service. The tariff selection file has no line carrying the words repository, save, or
clock; the same words appear on six lines in the application service. The domain service
makes the decision; it does not know where the decision is read from or written to.

The last line again says what the measurement is not: in all six shipments, the two models
select the same tariff and give the same amount.

#### What a Domain Service Is Not

A domain service is a carrier of a rule, not an executor of a workflow. The measure of the
distinction is the fourth line above: a domain service must have zero persistence trace. If
a service reads from a repository, opens a transaction, checks the clock, or sends a
notification, it belongs to the application layer.

The second distinction concerns state. A domain service does not hold its own state; its
input arrives through parameters, its output is the return value. A "service" that holds
state is, whatever it is named, an entity with an unclear boundary, and the measures from
the previous two lessons apply to it.

#### When Not to Open a Domain Service

The cost of a service is one file, one exposed name, and the extra parameters passed on
every call. For behaviors with a single collaborator, this cost has no return: if the
decision of whether a shipment counts as bulky is moved to a service, the call site is
forced to pass the shipment as a parameter, and the gain is zero.

The second counter-case is a service name that conceals a concept. If a decision that
requires three objects turns out to actually be a fourth domain concept — the way tariff
selection is a "pricing policy" — that concept takes its own type, and the service becomes a
method again. Every module carrying a "service" suffix may be hiding a concept that has no
counterpart in the domain language; the measure is whether that module's name appears in the
domain expert's sentence.

#### Summary

- A domain service is the place where a behavior is put that requires several domain
  objects together and belongs to none of them; the measure is the number of collaborators
  and the ownership question.
- When tariff selection was loaded onto the shipment, the shipment module imported 2
  external modules; when it was handed to the service, 0.
- Objects reachable from a shipment dropped from 3 to 1, and the serialization length from
  117 to 68; the shipment now carries the contract by number, not by object.
- The measure that separates the domain service from the application service is the
  persistence trace: 0 lines in tariff selection, 6 lines in the application service.
- In all six shipments, the two models gave the same tariff and the same amount; the gain is
  not in correctness but in how dependencies are distributed.
- A behavior with a single collaborator is not moved to a service; if a service name
  conceals a domain concept, that concept takes its own type.

#### Next Step

The application service read the shipment from a repository and asked it for the tariffs.
Which words are written on that repository's interface was not asked in this lesson:
findShipment, or a query object carrying column names and comparison operators? The same
question holds for creation as well — is the shipment built directly through the
constructor, or does it pass through a factory that secures its invariant at the moment of
construction? The next lesson measures the number of non-domain names on the repository
interface and the number of call sites touched when a column name changes, and counts how
many ways an invalid shipment can be created.

### Factories and Repositories

Source: https://academia.sh/en/courses/domain-driven-design/factories-and-repositories

Fitting creation and access to the domain model: counting how many of an invalid shipment's four creation paths can produce it, this count dropping to zero in the model that passes through a factory, measuring the column name, operator, and threshold count that leaks into the call site when the repository interface is written in the query language, and showing that the two repositories give the same result.

The previous lesson's application service read the shipment from a repository and asked it
for the tariffs. Which words were written on that repository's interface was not asked. The
same question holds for creation as well: is the shipment built directly through the
constructor, or does it pass through something that secures its invariant at the moment of
construction?

The **factory** and **repository** patterns were established in the Design Patterns course;
one said where the responsibility for creation is placed, the other said where access to the
aggregate passes through. This lesson does not retell the patterns. It asks a single
question: is each of them written in the domain language? For the factory, this means the
invariant is secured at the moment of construction; for the repository, it means domain
words appear on the interface and column names do not.

#### The Validity Rule

Both models are subject to the same rule: the shipment number is a non-empty string, the
weight is a positive integer, and the zone code is two digits.

```sh
mkdir -p common query domain
```

```js
// common/rule.mjs — the validity rule both models are subject to
export const isValid = (g) =>
  typeof g.shipmentNo === "string" && g.shipmentNo.length > 0
  && Number.isInteger(g.weightGrams) && g.weightGrams > 0
  && /^\d{2}$/.test(g.zoneCode);
```

#### Creation: Where the Rule Is Enforced

In the first version, the constructor is public, the fields are writable, and the rule is
enforced nowhere.

```js
// query/shipment.mjs — constructor is public, fields are writable, the rule is enforced nowhere
export class Shipment {
  constructor(shipmentNo, weightGrams, zoneCode, contractNo = null) {
    this.shipmentNo = shipmentNo;
    this.weightGrams = weightGrams;
    this.zoneCode = zoneCode;
    this.contractNo = contractNo;
  }
}

export const fromRow = (row) =>
  new Shipment(row.shipment_no, row.weight_grams, row.zone_code, row.contract_no);
```

In the second version, the constructor can only be entered with a token that only the
factory knows. There are two factory methods — one for domain calls, one for building from a
raw row — and both enforce the same rule. The object is frozen at construction.

```js
// domain/shipment.mjs — factory: validity is secured at construction, the object is frozen
import { isValid } from "../common/rule.mjs";

const FACTORY = Symbol("factory");

export class Shipment {
  constructor(key, shipmentNo, weightGrams, zoneCode, contractNo) {
    if (key !== FACTORY) throw new TypeError("Shipment can only be built through the factory");
    this.shipmentNo = shipmentNo;
    this.weightGrams = weightGrams;
    this.zoneCode = zoneCode;
    this.contractNo = contractNo;
    Object.freeze(this);
  }
  static create(shipmentNo, weightGrams, zoneCode, contractNo = null) {
    const candidate = { shipmentNo, weightGrams, zoneCode, contractNo };
    if (!isValid(candidate)) throw new RangeError(`invalid shipment: ${shipmentNo}`);
    return new Shipment(FACTORY, shipmentNo, weightGrams, zoneCode, contractNo);
  }
  static fromRow(row) {
    return Shipment.create(row.shipment_no, row.weight_grams, row.zone_code,
      row.contract_no);
  }
}
```

The measurement is the creation-side counterpart of the reachability measure from the
previous lesson: in how many of four paths can an invalid shipment be produced?

```js
// factory-measure.mjs — how many of the four paths produce an invalid shipment
import { isValid } from "./common/rule.mjs";
import { Shipment as QueryShipment, fromRow } from "./query/shipment.mjs";
import { Shipment as DomainShipment } from "./domain/shipment.mjs";

const BROKEN_ROW = { shipment_no: "G-4099", weight_grams: -50, zone_code: "3",
  contract_no: null };

const QUERY_PATHS = {
  "directly through the constructor": () => new QueryShipment("", -3, "3"),
  "assigning the field afterward": () => {
    const g = new QueryShipment("G-4098", 2400, "34");
    g.weightGrams = -3;
    return g;
  },
  "bulk assignment": () => Object.assign(new QueryShipment("G-4098", 2400, "34"),
    { zoneCode: "abc" }),
  "building from a raw row": () => fromRow(BROKEN_ROW),
};
const DOMAIN_PATHS = {
  "directly through the constructor": () => new DomainShipment("", -3, "3"),
  "assigning the field afterward": () => {
    const g = DomainShipment.create("G-4098", 2400, "34");
    g.weightGrams = -3;
    return g;
  },
  "bulk assignment": () => Object.assign(DomainShipment.create("G-4098", 2400, "34"),
    { zoneCode: "abc" }),
  "building from a raw row": () => DomainShipment.fromRow(BROKEN_ROW),
};

for (const [name, paths] of Object.entries({ query: QUERY_PATHS, domain: DOMAIN_PATHS })) {
  let invalid = 0;
  let errors = 0;
  for (const [path, attempt] of Object.entries(paths)) {
    let result;
    try {
      const g = attempt();
      result = isValid(g) ? "valid" : "INVALID OBJECT";
      if (result !== "valid") invalid += 1;
    } catch {
      result = "error";
      errors += 1;
    }
    console.log(`  ${name.padEnd(6)} ${path.padEnd(34)} ${result}`);
  }
  console.log(`${name}: of 4 paths, ${invalid} produced an invalid object, ${errors} raised an error`);
}
```

```sh
node factory-measure.mjs
```

```
  query  directly through the constructor   INVALID OBJECT
  query  assigning the field afterward      INVALID OBJECT
  query  bulk assignment                    INVALID OBJECT
  query  building from a raw row            INVALID OBJECT
query: of 4 paths, 4 produced an invalid object, 0 raised an error
  domain directly through the constructor   error
  domain assigning the field afterward      error
  domain bulk assignment                    error
  domain building from a raw row            error
domain: of 4 paths, 0 produced an invalid object, 4 raised an error
```

All four paths produce an invalid shipment in the first model, and none of them raises a
warning. In the model that passes through the factory, all four raise an error. The gain is
not how many times the rule is written — the rule could have been written once in either
model — it is that no path is left that bypasses the rule.

#### Access: Which Language Does the Repository Speak

Raw rows coming from the persistence side carry column names.

```js
// common/rows.mjs — raw rows coming from the persistence side
export const ROWS = [
  { shipment_no: "G-4001", weight_grams: 2400, zone_code: "34", contract_no: "S-77" },
  { shipment_no: "G-4002", weight_grams: 7200, zone_code: "06", contract_no: null },
  { shipment_no: "G-4003", weight_grams: 400, zone_code: "65", contract_no: "S-77" },
  { shipment_no: "G-4004", weight_grams: 12000, zone_code: "34", contract_no: null },
  { shipment_no: "G-4005", weight_grams: 5600, zone_code: "06", contract_no: "S-77" },
];
```

The first repository gives a general query interface: the caller builds the condition and
receives raw rows.

```js
// query/repository.mjs — repository written in the query language: the caller builds the condition
const COMPARATORS = {
  "=": (a, b) => a === b,
  ">": (a, b) => a > b,
  "<": (a, b) => a < b,
};

export const repository = (rows) => ({
  find: (condition) => rows.filter((r) => condition.every(([field, op, v]) => COMPARATORS[op](r[field], v))),
});
```

```js
// query/calls.mjs — column names, operators, and thresholds are written at the call site
export const heavyShipments = (r) => r.find([["weight_grams", ">", 5000]]);
export const inZone = (r, zone) => r.find([["zone_code", "=", zone]]);
export const uncontractedHeavy = (r) =>
  r.find([["weight_grams", ">", 5000], ["contract_no", "=", null]]);
export const zoneWeights = (r, zone) =>
  r.find([["zone_code", "=", zone]]).map((row) => row.weight_grams);
export const lightContracted = (r) =>
  r.find([["weight_grams", "<", 1000], ["contract_no", "=", "S-77"]]);
```

The second repository gives domain words as method names, passes the raw row through the
factory, and returns a domain object. What the "heavy" and "light" thresholds are also stays
here.

```js
// domain/repository.mjs — repository written in the domain language: column name, threshold, and operator stay here
import { Shipment } from "./shipment.mjs";

const HEAVY_LIMIT_GRAMS = 5000;
const LIGHT_LIMIT_GRAMS = 1000;
const build = (rows) => rows.map((r) => Shipment.fromRow(r));

export const repository = (rows) => ({
  heavyShipments: () => build(rows.filter((r) => r.weight_grams > HEAVY_LIMIT_GRAMS)),
  inZone: (zone) => build(rows.filter((r) => r.zone_code === zone)),
  uncontractedHeavy: () =>
    build(rows.filter((r) => r.weight_grams > HEAVY_LIMIT_GRAMS && r.contract_no === null)),
  lightContracted: (contractNo) => build(rows.filter((r) =>
    r.weight_grams < LIGHT_LIMIT_GRAMS && r.contract_no === contractNo)),
});
```

```js
// domain/calls.mjs — only domain words appear at the call site
export const heavyShipments = (r) => r.heavyShipments();
export const inZone = (r, zone) => r.inZone(zone);
export const uncontractedHeavy = (r) => r.uncontractedHeavy();
export const zoneWeights = (r, zone) => r.inZone(zone).map((g) => g.weightGrams);
export const lightContracted = (r) => r.lightContracted("S-77");
```

```js
// repository-measure.mjs — does a column name or operator leak to the call site, do the two repositories agree
import { readFileSync } from "node:fs";
import { ROWS } from "./common/rows.mjs";
import { repository as queryRepository } from "./query/repository.mjs";
import { repository as domainRepository } from "./domain/repository.mjs";
import * as queryCalls from "./query/calls.mjs";
import * as domainCalls from "./domain/calls.mjs";

const COLUMN = /weight_grams|zone_code|contract_no/g;
const OPERATOR = /"[=<>]"/g;
const THRESHOLD = /5000|1000/g;
const MODELS = {
  query: { calls: "query/calls.mjs", boundary: ["query/repository.mjs"] },
  domain: { calls: "domain/calls.mjs", boundary: ["domain/repository.mjs", "domain/shipment.mjs"] },
};

for (const [name, { calls, boundary }] of Object.entries(MODELS)) {
  const text = readFileSync(calls, "utf8");
  const count = (r) => (text.match(r) ?? []).length;
  const atBoundary = boundary.map((d) => (readFileSync(d, "utf8").match(COLUMN) ?? []).length)
    .reduce((a, b) => a + b, 0);
  console.log(`${name}: at call site, column name ${count(COLUMN)}, operator ${count(OPERATOR)}, ` +
    `threshold ${count(THRESHOLD)}`);
  console.log(`  column name at boundary files ${atBoundary} [${boundary.join(" ")}]`);
}

const q = queryRepository(ROWS);
const d = domainRepository(ROWS);
const ids = (list) => list.map((x) => x.shipment_no ?? x.shipmentNo ?? x).join(",");
let diverged = 0;
for (const [operation, x, y] of [
  ["heavyShipments", queryCalls.heavyShipments(q), domainCalls.heavyShipments(d)],
  ["inZone", queryCalls.inZone(q, "34"), domainCalls.inZone(d, "34")],
  ["uncontractedHeavy", queryCalls.uncontractedHeavy(q), domainCalls.uncontractedHeavy(d)],
  ["zoneWeights", queryCalls.zoneWeights(q, "06"), domainCalls.zoneWeights(d, "06")],
  ["lightContracted", queryCalls.lightContracted(q), domainCalls.lightContracted(d)],
]) {
  if (ids(x) !== ids(y)) diverged += 1;
  console.log(`  ${operation.padEnd(20)} ${ids(x)}`);
}
console.log(`diverged results = ${diverged} / 5`);
```

```sh
node repository-measure.mjs
```

```
query: at call site, column name 8, operator 7, threshold 3
  column name at boundary files 0 [query/repository.mjs]
domain: at call site, column name 0, operator 0, threshold 0
  column name at boundary files 9 [domain/repository.mjs domain/shipment.mjs]
  heavyShipments       G-4002,G-4004,G-4005
  inZone               G-4001,G-4004
  uncontractedHeavy    G-4002,G-4004
  zoneWeights          7200,5600
  lightContracted      G-4003
diverged results = 0 / 5
```

In the repository written in the query language, the call site carries eight column names,
seven comparison operators, and three thresholds. In the repository written in the domain
language, all three are zero. The column names did not disappear; they were gathered into
two boundary files across nine occurrences.

What is gathered belongs to three separate kinds of decision. The column name is a
persistence decision; the operator is a query decision; the "heavy" threshold is a domain
decision. In the query model, all three sit on the same line, inside the application layer.
Lowering the threshold from 5000 grams to 4000 grams requires touching two call sites there,
and those two places cannot be found by searching, because the domain vocabulary has no
concept called "heavy." In the domain model, the same change touches the
`HEAVY_LIMIT_GRAMS` constant.

The last line again says what the measurement is not: in all five queries, the two
repositories return the same shipments.

#### The Limit of Writing the Repository in the Domain Language

The cost of a repository written in the domain language is the number of methods. Every new
domain question opens a new method, and the interface grows; in the general query interface,
the interface stays at a single method, and what grows is the conditions at the call sites.

The measurement therefore looks at how often a question repeats. If the same question is
asked from more than one place, turning it into a method produces a gain. For questions
asked from only one place, never to be asked again — a one-off reconciliation report, for
instance — the general interface is cheaper; the query objects and specifications pattern
was established in the Data Access Layer and Business Logic course for exactly this case.

The second limit is in the factory. The factory's gain comes from leaving no path unguarded;
this requires closing off access to the constructor and freezing the object. An object with
no invariant has no return for this cost: freezing means producing a new object on every
update, and an object with no rule has nothing to protect.

#### Summary

- Factory and repository were treated in this lesson not as patterns, but by whether they
  fit the domain language.
- In the model with a public constructor, an invalid shipment was produced on 4 of 4
  creation paths and none raised a warning; in the model that passes through the factory, it
  was produced on 0, and all four raised an error.
- The factory's gain is not how many times the rule is written, but that no path bypassing
  the rule is left.
- The call site of the repository written in the query language carried 8 column names, 7
  operators, and 3 thresholds; in the repository written in the domain language, all three
  were 0 at the call site, and the column names were gathered into two boundary files.
- In the query model, persistence, query, and domain decisions mix on the same line; the
  "heavy" threshold is a domain decision, yet its name is not found in the domain
  vocabulary.
- In all five queries, the two repositories gave the same result; the cost of the repository
  written in the domain language is that the interface grows with the number of questions.

#### Next Step

All the measurements up to this point were about the model's state: which name shows which
concept, which invariant lives inside which boundary, by which path the object is
constructed. The domain also has things that happen: a shipment was repriced, a contract
discount was removed, an amount was recalculated because the tariff changed. These facts may
have a name in the code, or they may all pass under the one label "record updated." The next lesson
tries to answer the domain expert's questions over an event log: when event names come from
the domain language, how many questions are answered with a single filter, and where they do
not, how many events' fields have to be compared.

### Domain Events

Source: https://academia.sh/en/courses/domain-driven-design/domain-events

The explicit model of meaningful business events: measuring, across two logs produced from the same sixty facts, how many of the domain expert's three questions are answered correctly with a single filter, counting how many events' fields have to be compared for the answer, and comparing the rate at which event names are found in the domain vocabulary and the log length.

All the measurements up to this point were about the model's state: which name shows which
concept, which invariant lives inside which boundary, by which path the object is
constructed. The domain also has things that happen — a shipment was priced, a contract
discount was removed, an amount was recalculated because the tariff changed. These facts may
have their own names in the code, or they may all pass under the label "record updated."

A fact that happens in the domain, is narrated in the past tense, and has a name in the
domain language is called a **domain event**. Publishing the event, delivering it to
listeners, and finalizing it together with the transaction were established and measured in
the Domain Events lesson of the Data Access Layer and Business Logic course. This lesson
does not enter that side. It asks a single question: where does the event's name come from?

#### Setting Up the Measurement

For the measurement, a day's pricing history is written as sixty facts. Each fact has a
type, a shipment number, and a before-and-after snapshot. Both logs are produced from this
single source; the only difference between them is what is stored.

```sh
mkdir -p general domain
```

```js
// facts.mjs — a day's pricing history: 60 facts, the source for both logs
const FACT = ["shipment-priced", "contract-discount-removed", "volume-discount-added",
  "weighing-correction-applied", "repriced-for-tariff-change",
  "minimum-fee-applied"];
export const MINIMUM_FEE = 3990;

export const FACTS = [];
for (let i = 0; i < 60; i += 1) {
  const fact = FACT[i % 6];
  const shipmentNo = `G-5${101 + (i % 20)}`;
  const t = 4200 + (i % 5) * 900;
  let before;
  let after;
  if (fact === "shipment-priced") {
    before = { amount: 0, tariff: null, discounts: [] };
    after = { amount: t, tariff: "standard", discounts: [] };
  } else if (fact === "contract-discount-removed") {
    before = { amount: t, tariff: "standard", discounts: ["contract", "volume"] };
    after = { amount: t + 600, tariff: "standard", discounts: ["volume"] };
  } else if (fact === "volume-discount-added") {
    before = { amount: t, tariff: "standard", discounts: ["contract"] };
    after = { amount: t - 400, tariff: "standard", discounts: ["contract", "volume"] };
  } else if (fact === "weighing-correction-applied") {
    before = { amount: t, tariff: "standard", discounts: ["volume"] };
    after = { amount: i % 4 === 3 ? MINIMUM_FEE : t + 250, tariff: "standard",
      discounts: ["volume"] };
  } else if (fact === "repriced-for-tariff-change") {
    before = { amount: t, tariff: "standard", discounts: ["volume"] };
    after = { amount: t - 700, tariff: "regional", discounts: ["volume"] };
  } else {
    before = { amount: t, tariff: "regional", discounts: ["volume"] };
    after = { amount: MINIMUM_FEE, tariff: "regional", discounts: ["volume"] };
  }
  FACTS.push({ sequence: i + 1, fact, shipmentNo, before, after });
}
```

The first log reduces the event's name to one of two general names and narrates what
happened through a before-and-after snapshot. Viewed from the persistence side, this is the
most natural choice: every write is an update.

```js
// general/log.mjs — every fact takes one of the same two types, the payload is a before/after snapshot
import { FACTS } from "../facts.mjs";

export const LOG = FACTS.map((f) => ({
  type: f.before.tariff === null ? "ShipmentCreated" : "ShipmentUpdated",
  shipmentNo: f.shipmentNo,
  before: f.before,
  after: f.after,
}));
```

The second log takes the event's name from the domain language. No snapshot is needed: the
name says what happened, and the payload is only as much as that name requires.

```js
// domain/log.mjs — the event's type comes from the domain language, the payload is only what that name requires
import { FACTS } from "../facts.mjs";

const NAME = {
  "shipment-priced": "ShipmentPriced",
  "contract-discount-removed": "ContractDiscountRemoved",
  "volume-discount-added": "VolumeDiscountAdded",
  "weighing-correction-applied": "WeighingCorrectionApplied",
  "repriced-for-tariff-change": "RepricedForTariffChange",
  "minimum-fee-applied": "MinimumFeeApplied",
};

export const LOG = FACTS.map((f) => ({
  type: NAME[f.fact],
  shipmentNo: f.shipmentNo,
  amount: f.after.amount,
}));
```

#### Measurement

The domain expert asks the log three questions: how many shipments later lost their
contract discount, how many times was a shipment repriced because the tariff changed, how
many times did the minimum fee take effect. Each question is answered, in both logs, with a
single filter, and the answer is compared against the actual count.

```js
// event-measure.mjs — how many correct answers and how many comparisons the expert's three questions need in each log
import { FACTS, MINIMUM_FEE } from "./facts.mjs";
import { LOG as GENERAL } from "./general/log.mjs";
import { LOG as DOMAIN } from "./domain/log.mjs";

const actual = (fact) => FACTS.filter((f) => f.fact === fact).length;
const discountsOf = (g) => g?.discounts ?? [];

const QUESTIONS = [
  ["shipment that lost its contract discount", "contract-discount-removed",
    (log) => log.filter((e) => discountsOf(e.before).includes("contract")
      && !discountsOf(e.after).includes("contract")).length,
    (log) => log.filter((e) => e.type === "ContractDiscountRemoved").length],
  ["repricing due to tariff change", "repriced-for-tariff-change",
    (log) => log.filter((e) => e.before.tariff !== e.after.tariff).length,
    (log) => log.filter((e) => e.type === "RepricedForTariffChange").length],
  ["minimum fee taking effect", "minimum-fee-applied",
    (log) => log.filter((e) => e.after.amount === MINIMUM_FEE).length,
    (log) => log.filter((e) => e.type === "MinimumFeeApplied").length],
];

let generalCorrect = 0;
let domainCorrect = 0;
for (const [question, fact, generalAnswer, domainAnswer] of QUESTIONS) {
  const a = actual(fact);
  const g = generalAnswer(GENERAL);
  const d = domainAnswer(DOMAIN);
  if (g === a) generalCorrect += 1;
  if (d === a) domainCorrect += 1;
  console.log(`${question}`);
  console.log(`  actual ${a}  general log ${g} ${g === a ? "correct" : "WRONG"}  ` +
    `domain log ${d} ${d === a ? "correct" : "WRONG"}`);
}
console.log(`correct with a single filter: general ${generalCorrect}/3, domain ${domainCorrect}/3`);

const withSnapshot = (log) => log.filter((e) => "before" in e).length;
console.log(`events with domain fields compared for 3 questions: general ${withSnapshot(GENERAL) * 3}, ` +
  `domain ${withSnapshot(DOMAIN) * 3}`);

// VOCABULARY: words used in the domain expert's sentences
const VOCABULARY = ["shipment", "tariff", "zone", "weight", "volume", "discount", "contract",
  "weighing", "correction", "minimum", "fee", "priced", "added", "removed",
  "applied", "repriced", "change", "for"];
const splitWords = (t) => t.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(" ");
for (const [name, log] of Object.entries({ general: GENERAL, domain: DOMAIN })) {
  const types = [...new Set(log.map((e) => e.type))];
  const inVocabulary = types.filter((t) =>
    splitWords(t).every((w) => VOCABULARY.some((v) => w.startsWith(v))));
  console.log(`${name}: event types ${types.length}, whose full name is in the domain vocabulary ` +
    `${inVocabulary.length}/${types.length}`);
  console.log(`  ${types.join(" ")}`);
}
console.log(`log length: general ${JSON.stringify(GENERAL).length}, ` +
  `domain ${JSON.stringify(DOMAIN).length}`);
```

```sh
node event-measure.mjs
```

```
shipment that lost its contract discount
  actual 10  general log 10 correct  domain log 10 correct
repricing due to tariff change
  actual 10  general log 20 WRONG  domain log 10 correct
minimum fee taking effect
  actual 10  general log 15 WRONG  domain log 10 correct
correct with a single filter: general 1/3, domain 3/3
events with domain fields compared for 3 questions: general 180, domain 0
general: event types 2, whose full name is in the domain vocabulary 0/2
  ShipmentCreated ShipmentUpdated
domain: event types 6, whose full name is in the domain vocabulary 6/6
  ShipmentPriced ContractDiscountRemoved VolumeDiscountAdded WeighingCorrectionApplied RepricedForTariffChange MinimumFeeApplied
log length: general 11031, domain 4091
```

The general log gives the correct number for only one of the three questions. For the
second question it says twenty, when repricing due to a tariff change actually happened ten
times; the difference comes from the fact that the initial pricing also turns the tariff
field from empty to filled. For the third question it says fifteen, when the minimum fee
actually took effect ten times; in five events, the amount was equalized with the minimum
fee as a result of a weighing correction, but the rule was not applied.

Both mistakes could be fixed by correcting the query: a condition that leaves out the
initial pricing, a condition that verifies the minimum-fee equality against another field.
What the measurement shows is where these corrections get written. Each correction buries,
inside the query, a piece of domain knowledge that is not in the log, and it cannot be
tested from the log. In the domain log, the same three questions are answered with a single
filter that looks at the type name, and all three come out correct.

The second line gives the cost in terms of operations: answering the three questions
compares the fields of one hundred eighty events in the general log, and none in the domain
log. The third measurement shows where the names come from: the names of the two types in
the general log are not in the domain vocabulary — "created" and "updated" are not words the
expert uses, they are the names of persistence operations. All six of the six types in the
domain log come from the domain vocabulary.

The last line is a side gain: the information carried by the name is shorter than the
information carried by the snapshot. The log length drops from 11031 characters to 4091.

#### What the Event's Name Says

A domain event's name carries three things together: what happened, in which concept it
happened, and that it is over and done with. The name `ContractDiscountRemoved` gives all
three. The name is in the past tense because an event is not a request; wanting it to happen
is a different concept and has a different name.

The measurable consequence of the name belonging to the domain language is that the expert
can verify the log. When the six type names of the domain log are put in front of the
expert, the expert can name what is missing: "computing the return fee is a fact too, and it
is not on the list." When the two names of the general log are put in front of the expert,
there is nothing to say, because those names are not in the expert's language.

The event's payload goes through the same measure. That the payload of the
`RepricedForTariffChange` event carries the new amount is relevant to the domain; that it
would carry the record's database row identifier is not. When a field is added to a log, the
question asked is whether that field appears in the expert's sentence.

#### When a General Name Is Enough

The cost of domain events is the number of types. Six types mean six names, six filter
conditions, and, over time, six separate versions to maintain; the general log has two
names, and a new fact type adds nothing to it.

The case in which this cost goes unpaid back is one where no domain question is asked of the
log. In a trace kept only to answer "when was this record last touched," a before-and-after
snapshot is enough, and separating types produces no gain. The measure of the distinction is
not the size of the log, either — it is the number of domain questions asked of the log.

The second case is one where the fact has no name in the domain. Inventing a domain event
name for changes the expert calls "not really anything, just a record correction" adds to
the vocabulary a concept with no domain counterpart; the measure from the second lesson
counts this as the number of concepts per name.

#### Summary

- A domain event is a fact that happens in the domain and has a name in the domain language;
  this lesson measured only where the name comes from, and did not enter the publishing and
  delivery side.
- Of the two logs produced from the same sixty facts, the general log answered 1 of the
  expert's three questions correctly with a single filter, the domain log answered 3.
- The general log's two wrong answers can be corrected, but each correction buries, inside
  the query, a piece of domain knowledge that is not in the log, making it untestable from
  the log.
- Answering the three questions required comparing the fields of 180 events in the general
  log, and 0 in the domain log.
- Of the general log's 2 type names, 0 come from the domain vocabulary; of the domain log's
  6 type names, all 6 do; the log length dropped from 11031 to 4091 characters.
- The cost is the number of types; if no domain question is asked of the log, or if the fact
  has no name in the domain, a general name is enough.

#### Next Step

The lessons in this topic measured identity, value, boundary, the location of behavior, creation,
and the event, one by one. Their common assumption was that a domain object has a behavior. A
common arrangement removes this assumption: domain classes carry only fields, every rule is written
into services, and the classes become nothing more than a data shape. The next lesson measures this
arrangement in the context of the domain model: it counts how many services the same rule is
repeated in, how many files are touched when a rule's text changes, and how many of the verbs in
the expert's sentence are found as a method on the domain object.

### Anemic Domain Model Critique

Source: https://academia.sh/en/courses/domain-driven-design/anemic-domain-model-critique

Measuring behaviorless data classes in the context of the domain model: counting how many separate services the same five rules are written in and how many file edits the five rules require, counting how many times, across 36 shipments, services writing the same rule give a different result, and comparing how many of the domain expert's verbs can be asked of the shipment.

The lessons in this topic measured identity, value, the consistency boundary, the location
of behavior, creation, and the event, one by one, sharing one assumption: that a domain
object has a behavior. A common arrangement removes it — domain classes carry only fields,
every rule is written into services, and the class becomes nothing more than a data shape.

This arrangement, which carries fields but no rules, is called the **anemic model**. Its
critique from the standpoint of object responsibility was made in the Object Responsibility
lesson of the Programming Paradigms course and is not repeated here. This lesson measures the
same arrangement in the domain model's context: how many services a rule is written in, how
many file edits five rules require, whether services writing the same rule reach the same
result for the same shipment, and how many verbs from the expert's sentence the domain
object answers.

#### Five Rules and Three Services

One sentence from the pricing expert carries five rules: the chargeable weight is the larger
of the actual weight and the weight derived from volume; a contracted customer gets the
contract discount; the contract and volume discounts together cannot exceed the discount
cap; the fee never drops below the minimum fee; a declared-value shipment gets an insurance
premium.

The library has three services that use these rules: the invoice line, the period-end
reconciliation, and the bulk pricing list. The measurement data is thirty-six shipment
records; in twelve, the contract number is empty yet a discount rate is written, and
observing the second rule depends on it.

```sh
mkdir -p anemic domain
```

```js
// data.mjs — the pricing context's tariff table and 36 shipment records
export const TARIFF = {
  standard: { near: 1800, mid: 2400, far: 3200 },
  heavy: { near: 1400, mid: 1900, far: 2600 },
};

export const RECORDS = [];
let i = 0;
for (const [weightGrams, volumeDm3] of [[700, 2], [2400, 9], [6000, 18], [18000, 60]])
  for (const [contractNo, contractDiscount] of [[null, 0.22], ["S-77", 0.22], ["S-12", 0.3]])
    for (const volumeDiscount of [0, 0.12, 0.2]) {
      i += 1;
      RECORDS.push({
        shipmentNo: `G-6${100 + i}`, weightGrams, volumeDm3,
        feeZone: ["near", "mid", "far"][i % 3],
        tariffCode: weightGrams >= 10000 ? "heavy" : "standard",
        declaredValueCents: i % 3 === 0 ? 0 : 60000 * (1 + (i % 4)),
        contractNo, contractDiscount, volumeDiscount,
      });
    }
```

#### The Anemic Arrangement

In the first arrangement, the shipment record is carried as is; no rule stays with it.

```js
// anemic/shipment.mjs — carries fields only; no rule lives here
export const shipment = (r) => ({ ...r });
```

The invoice service writes all five of the five rules within itself.

```js
// anemic/invoice.mjs — writes all five rules itself
export const invoiceLine = (g, tariff) => {
  const chargeableWeight = Math.max(g.weightGrams, g.volumeDm3 * 200);
  const contractRate = g.contractNo === null ? 0 : g.contractDiscount;
  const discount = Math.min(0.4, contractRate + g.volumeDiscount);
  const raw = Math.round((chargeableWeight / 1000) * tariff[g.tariffCode][g.feeZone] * (1 - discount));
  const fee = Math.max(3990, raw);
  const premium = Math.round(g.declaredValueCents * 0.002);
  return { chargeableWeight, contractRate, discount, minimumApplied: fee > raw, premium, amount: fee + premium };
};
```

The reconciliation service writes the same five rules, applying the cap only to the contract
discount.

```js
// anemic/reconciliation.mjs — same five rules; the cap applied to the contract discount only
export const reconciliationLine = (g, tariff) => {
  const chargeableWeight = Math.max(g.weightGrams, g.volumeDm3 * 200);
  const contractRate = g.contractNo === null ? 0 : g.contractDiscount;
  const discount = Math.min(0.4, contractRate) + g.volumeDiscount;
  const raw = Math.round((chargeableWeight / 1000) * tariff[g.tariffCode][g.feeZone] * (1 - discount));
  const fee = Math.max(3990, raw);
  const premium = Math.round(g.declaredValueCents * 0.002);
  return { chargeableWeight, contractRate, discount, minimumApplied: fee > raw, premium, amount: fee + premium };
};
```

The bulk pricing service does not compute the insurance premium and takes the discount rate
from the record as is; the contract condition is not in this file.

```js
// anemic/bulk-pricing.mjs — the same rules once more; the contract condition is not written here
export const listPrice = (g, tariff) => {
  const chargeableWeight = Math.max(g.weightGrams, g.volumeDm3 * 200);
  const contractRate = g.contractDiscount;
  const discount = Math.min(0.4, contractRate + g.volumeDiscount);
  const raw = Math.round((chargeableWeight / 1000) * tariff[g.tariffCode][g.feeZone] * (1 - discount));
  const fee = Math.max(3990, raw);
  return { chargeableWeight, contractRate, discount, minimumApplied: fee > raw,
    pricePerKg: Math.round(fee / (chargeableWeight / 1000)) };
};
```

#### The Model That Carries Behavior

In the second arrangement, the five rules are on the shipment itself; the fields are closed,
and only the rule results are exposed.

```js
// domain/shipment.mjs — the five rules are on the shipment; fields are closed, only rule results are exposed
const VOLUME_FACTOR = 200;
const DISCOUNT_CAP = 0.4;
const MINIMUM_FEE_CENTS = 3990;
const INSURANCE_RATE = 0.002;

export const shipment = (r) => {
  const chargeableWeightGrams = () => Math.max(r.weightGrams, r.volumeDm3 * VOLUME_FACTOR);
  const contractDiscountRate = () => (r.contractNo === null ? 0 : r.contractDiscount);
  const applicableDiscountRate = () =>
    Math.min(DISCOUNT_CAP, contractDiscountRate() + r.volumeDiscount);
  const raw = (tariff) => Math.round((chargeableWeightGrams() / 1000)
    * tariff[r.tariffCode][r.feeZone] * (1 - applicableDiscountRate()));
  const feeCents = (tariff) => Math.max(MINIMUM_FEE_CENTS, raw(tariff));
  const insurancePremiumCents = () => Math.round(r.declaredValueCents * INSURANCE_RATE);
  return Object.freeze({
    shipmentNo: r.shipmentNo,
    chargeableWeightGrams, contractDiscountRate, applicableDiscountRate, feeCents,
    insurancePremiumCents,
    minimumFeeApplied: (tariff) => feeCents(tariff) > raw(tariff),
    amountCents: (tariff) => feeCents(tariff) + insurancePremiumCents(),
  });
};
```

The same three services no longer compute; they ask.

```js
// domain/invoice.mjs — writes no rule, asks the shipment
export const invoiceLine = (g, tariff) => ({
  chargeableWeight: g.chargeableWeightGrams(), contractRate: g.contractDiscountRate(),
  discount: g.applicableDiscountRate(), minimumApplied: g.minimumFeeApplied(tariff),
  premium: g.insurancePremiumCents(), amount: g.amountCents(tariff),
});
```

```js
// domain/reconciliation.mjs — does not interpret the cap itself, takes the applicable rate
export const reconciliationLine = (g, tariff) => ({
  chargeableWeight: g.chargeableWeightGrams(), contractRate: g.contractDiscountRate(),
  discount: g.applicableDiscountRate(), minimumApplied: g.minimumFeeApplied(tariff),
  premium: g.insurancePremiumCents(), amount: g.amountCents(tariff),
});
```

```js
// domain/bulk-pricing.mjs — the price per kilogram is its own job, the rules are the shipment's
export const listPrice = (g, tariff) => ({
  chargeableWeight: g.chargeableWeightGrams(), contractRate: g.contractDiscountRate(),
  discount: g.applicableDiscountRate(), minimumApplied: g.minimumFeeApplied(tariff),
  pricePerKg: Math.round(g.feeCents(tariff) / (g.chargeableWeightGrams() / 1000)),
});
```

#### In How Many Files Is a Rule Written

The first measurement searches both directories for each rule and counts the files it
appears in. The patterns match the rule by both its numeric value and a named constant, so a
renamed constant does not escape the scan. The same script also counts how many of the
expert's five verbs the shipment answers with a single call.

```js
// rule-measure.mjs — how many files a rule is written in, how many edits five rules require, where a verb can be asked
import { readdirSync, readFileSync } from "node:fs";
import { RECORDS } from "./data.mjs";
import { shipment } from "./anemic/shipment.mjs";
import { shipment as domainShipment } from "./domain/shipment.mjs";

const RULE = [
  ["chargeable weight", /volumeDm3 \* (?:200|VOLUME_FACTOR)/],
  ["contract discount", /contractNo === null \? 0/],
  ["discount cap", /Math\.min\((?:0\.4|DISCOUNT_CAP)/],
  ["minimum fee", /Math\.max\((?:3990|MINIMUM_FEE_CENTS)/],
  ["insurance premium", /\* (?:0\.002|INSURANCE_RATE)/],
];

for (const dir of ["anemic", "domain"]) {
  const files = readdirSync(dir).sort().map((f) => [f, readFileSync(`${dir}/${f}`, "utf8")]);
  let total = 0;
  console.log(`${dir} (${files.length} files)`);
  for (const [name, pattern] of RULE) {
    const locations = files.filter(([, content]) => pattern.test(content)).map(([f]) => f);
    total += locations.length;
    console.log(`  ${name.padEnd(20)} written in ${locations.length} file(s)  ${locations.join(" ")}`);
  }
  console.log(`  total file edits for 5 rules = ${total}`);
}

const VERB = [
  ["finds the chargeable weight", "chargeableWeightGrams"],
  ["applies the contract discount", "contractDiscountRate"],
  ["does not exceed the discount cap", "applicableDiscountRate"],
  ["does not go below the minimum fee", "feeCents"],
  ["adds the insurance premium", "insurancePremiumCents"],
];
for (const [name, obj] of [["anemic", shipment(RECORDS[0])], ["domain", domainShipment(RECORDS[0])]]) {
  const askable = VERB.filter(([, method]) => typeof obj[method] === "function");
  console.log(`${name}: of the expert's ${VERB.length} verbs, ${askable.length} can be asked of the shipment`);
}
```

```sh
node rule-measure.mjs
```

```
anemic (4 files)
  chargeable weight    written in 3 file(s)  bulk-pricing.mjs invoice.mjs reconciliation.mjs
  contract discount    written in 2 file(s)  invoice.mjs reconciliation.mjs
  discount cap         written in 3 file(s)  bulk-pricing.mjs invoice.mjs reconciliation.mjs
  minimum fee          written in 3 file(s)  bulk-pricing.mjs invoice.mjs reconciliation.mjs
  insurance premium    written in 2 file(s)  invoice.mjs reconciliation.mjs
  total file edits for 5 rules = 13
domain (4 files)
  chargeable weight    written in 1 file(s)  shipment.mjs
  contract discount    written in 1 file(s)  shipment.mjs
  discount cap         written in 1 file(s)  shipment.mjs
  minimum fee          written in 1 file(s)  shipment.mjs
  insurance premium    written in 1 file(s)  shipment.mjs
  total file edits for 5 rules = 5
anemic: of the expert's 5 verbs, 0 can be asked of the shipment
domain: of the expert's 5 verbs, 5 can be asked of the shipment
```

In the anemic arrangement, five rules were written with thirteen file edits, in the domain
model with five. When a rule's text changes, two or three files are edited in the anemic
arrangement, one file in the domain model.

The two on the contract discount line is not a gain. The bulk pricing service also applies a
discount, so it too must apply the contract condition; the number needed is fourteen, not
thirteen. A count of how many places a rule is written in cannot distinguish an unwritten
place from low repetition: the missing place shows up not as an absence, but as a low number.
Only a measurement that compares the result tells the two apart.

The last two lines ask the same question from the language side. The expert's five verbs
cannot be asked of the anemic shipment, since that object has no methods at all. Their
counterpart is three lines inside the services, and those lines have no name in the domain
vocabulary.

#### When the Services Diverge

The second measurement looks at the result. Each rule has an observable output: the
chargeable weight, the applied contract rate, the applicable discount rate, whether the
minimum fee took effect, and the insurance premium. For each shipment, the values the
services writing that rule produce are compared, and shipments producing more than one value
are counted.

```js
// divergence-measure.mjs — how many times do services writing the same rule give a different result across 36 shipments
import { TARIFF, RECORDS } from "./data.mjs";
import { shipment } from "./anemic/shipment.mjs";
import { invoiceLine as aInvoice } from "./anemic/invoice.mjs";
import { reconciliationLine as aReconciliation } from "./anemic/reconciliation.mjs";
import { listPrice as aList } from "./anemic/bulk-pricing.mjs";
import { shipment as domainShipment } from "./domain/shipment.mjs";
import { invoiceLine as sInvoice } from "./domain/invoice.mjs";
import { reconciliationLine as sReconciliation } from "./domain/reconciliation.mjs";
import { listPrice as sList } from "./domain/bulk-pricing.mjs";

const RULE = [["chargeable weight", "chargeableWeight"], ["contract discount", "contractRate"],
  ["discount cap", "discount"], ["minimum fee", "minimumApplied"], ["insurance premium", "premium"]];
const N = RECORDS.length;

const measure = (name, services, build) => {
  const results = RECORDS.map((r) => services.map((f) => f(build(r), TARIFF)));
  const divergedShipments = new Set();
  console.log(name);
  for (const [ruleName, field] of RULE) {
    const writing = results[0].filter((res) => field in res).length;
    let diverged = 0;
    RECORDS.forEach((r, i) => {
      const values = new Set(results[i].filter((res) => field in res).map((res) => String(res[field])));
      if (values.size > 1) { diverged += 1; divergedShipments.add(r.shipmentNo); }
    });
    console.log(`  ${ruleName.padEnd(20)} services writing it ${writing}  diverged shipments ${diverged}/${N}`);
  }
  console.log(`  diverged in at least one rule = ${divergedShipments.size}/${N}`);
  return results;
};

const A = measure("anemic", [aInvoice, aReconciliation, aList], (r) => shipment(r));
measure("domain", [sInvoice, sReconciliation, sList], (r) => domainShipment(r));

const diverged = RECORDS.map((_, i) => i).filter((i) => A[i][0].discount !== A[i][1].discount);
console.log(`invoice and reconciliation diverged discount rate = ${diverged.length}/${N}, first 3:`);
for (const i of diverged.slice(0, 3)) {
  console.log(`  ${RECORDS[i].shipmentNo}  invoice ${A[i][0].discount.toFixed(2)} / ${A[i][0].amount}` +
    `   reconciliation ${A[i][1].discount.toFixed(2)} / ${A[i][1].amount}`);
}
const hidden = diverged.filter((i) => A[i][0].amount === A[i][1].amount).length;
console.log(`diverged discount rate with same amount = ${hidden}/${diverged.length}`);

let mismatch = 0;
for (const r of RECORDS) {
  if (aInvoice(shipment(r), TARIFF).amount !== sInvoice(domainShipment(r), TARIFF).amount) mismatch += 1;
}
console.log(`invoice amount diverged between the two models = ${mismatch}/${N}`);
```

```sh
node divergence-measure.mjs
```

```
anemic
  chargeable weight    services writing it 3  diverged shipments 0/36
  contract discount    services writing it 3  diverged shipments 12/36
  discount cap         services writing it 3  diverged shipments 24/36
  minimum fee          services writing it 3  diverged shipments 0/36
  insurance premium    services writing it 2  diverged shipments 0/36
  diverged in at least one rule = 24/36
domain
  chargeable weight    services writing it 3  diverged shipments 0/36
  contract discount    services writing it 3  diverged shipments 0/36
  discount cap         services writing it 3  diverged shipments 0/36
  minimum fee          services writing it 3  diverged shipments 0/36
  insurance premium    services writing it 2  diverged shipments 0/36
  diverged in at least one rule = 0/36
invoice and reconciliation diverged discount rate = 12/36, first 3:
  G-6106  invoice 0.40 / 3990   reconciliation 0.42 / 3990
  G-6108  invoice 0.40 / 4110   reconciliation 0.42 / 4110
  G-6109  invoice 0.40 / 3990   reconciliation 0.50 / 3990
diverged discount rate with same amount = 5/12
invoice amount diverged between the two models = 0/36
```

In twenty-four of the thirty-six shipments, the three services diverge on at least one rule,
from two sources. The twelve on the contract discount line is the cost of the place the scan
showed as missing: with an empty contract number, bulk pricing applies a twenty-two-point
contract discount while the other two count it as zero. The twenty-four on the discount cap
line includes those twelve — a diverging contract rate drags the dependent cap with it —
plus twelve more from reconciliation applying the cap only to the contract discount.

The sample rows show why the divergence went unnoticed. For shipment `G-6106`, the invoice
applies a forty percent discount, reconciliation forty-two, yet both amounts come out at
3990. In five of the twelve diverged shipments the amount matches, since the minimum fee
rule seats both computations on the same floor. The divergence did not disappear, it became
invisible: it surfaces in only seven shipments.

In the domain model, diverged shipments are zero for all five rules; the three services ask
the same method, leaving no second writing to diverge from. The last line says what the
measurement is not: a correctly written anemic service and the domain model give the same
invoice amount. The gain is not that the computation changes.

#### Extracting the Rule into a Shared Module

The known fix for the anemic arrangement is extracting the rules into a shared helper
module, equalizing the first measurement with the domain model's: each rule now lives in one
file. The second measurement is not solved by the module alone. The helper function takes
fields as parameters, so the calling service decides the value to hand it; the bulk pricing
service's divergence arose from exactly this decision — not a mistyped rule, but the wrong
rate handed to it. What follows is not how many files the rule is written in, but who
decides its input. Asked of the domain object, the input is its own field, and the service
has nothing left to hand it.

#### Where the Anemic Class Is in Its Place

The case where a behaviorless record carrying fields is in its place was measured in the
Object Responsibility lesson: records crossing a boundary preserve shape, not interpret
rules. This lesson adds two domain-model-specific distinctions on top of that boundary.

The first is who the rule belongs to. All five rules could be answered with a single
shipment's fields, which is why they were moved onto it. A rule requiring two aggregates
together — a contract's period volume affecting the discount, for instance — is not moved
onto the shipment; it goes to the domain service established in the Domain Services lesson.
The opposite of the anemic model is not "every rule inside the object."

The second is cost: the shipment gained five methods and must take the tariff as a
parameter. The measure lies in the number of rules — a record carrying an uninterpreted
field can stay behaviorless, but the rule for an interpreted field must stay with it.

#### Summary

- The anemic model carries fields but no rules; this lesson measured it in the domain
  model's context, without entering the object responsibility discussion.
- Five rules were written with 13 file edits in the anemic arrangement, with 5 in the domain
  model; changing one rule touches 2–3 files in the anemic arrangement, 1 file in the domain
  model.
- The 2 on the contract discount line was not a gain but a deficiency: the number needed was
  14, and the missing place showed up in the scan as a low number.
- The 3 services writing the same rule diverged on at least one rule in 24 of the 36
  shipments; in the domain model, diverged shipments were 0 for all five rules.
- The invoice's and reconciliation's discount rates diverged in 12 shipments, and in 5 of
  these the divergence did not surface because the minimum fee rule equalized the amounts.
- 0 of the expert's 5 verbs answered from the anemic shipment with a single call, 5 from the
  domain shipment; the two models' invoice amount diverged in 0 of 36 shipments.

#### Next Step

Throughout this topic, one model's internal arrangement was established: which object holds
identity, which invariant lives inside which boundary, where behavior belongs, by which path
the object is created, where the event's name comes from. Every decision was measured
against the model's alignment with the domain. One question was not asked: a model of which
domain? In the shipment pricing and routing library, the word `shipment` names two different
objects in the mouths of two teams — a priceable unit carrying weight, volume, zone, and
declared value for pricing; a physical package with a route and status timestamps for
delivery. Because the word is the same, one class gets written and is forced to do two jobs
at once. The next topic measures this cost with two numbers: the fields the class carries,
and the field pairs that never appear together in any use.

## Bounded Contexts

Drawing the boundary of the area where a model holds and mapping the relationships between contexts: one word naming two different objects in two contexts, the relationship types on the context map, stopping the outside model's leakage with an anti-corruption layer, the shared kernel and conformist collaboration patterns, and separating core, supporting, and generic subdomains.

### Bounded Context

Source: https://academia.sh/en/courses/domain-driven-design/bounded-context

The boundary of the area where a model holds: counting the fields a class carries and the field pairs that never appear together in any use case when one word's two meanings are combined into a single class, measuring how many objects two contexts' invariants reject when merged into that class, and showing that drawing the boundary does not change the fee.

The Domain Model topic closed with the anemic model critique. Across that topic, the internal
structure of a single model was built — which object holds identity, which boundary an
invariant sits inside, where behavior belongs — and every decision was measured by the
model's alignment with the domain. The one question left unasked was this: whose domain is
this a model of?

The question is not empty. In the shipment pricing and routing library, the word `shipment`
names two different objects in two teams' mouths. For the pricing team, a shipment is a
priceable unit carrying weight, volume, zone, and declared value. For the delivery operations
team, a shipment is a physical package with a route, transfer points, and state timestamps.
Because the word is the same, a single class gets written, and that class is forced to do two
jobs at once. This lesson measures the cost of that forcing with two numbers: the field count
the class carries, and the count of field pairs that never appear together in any use case.

#### A Bounded Context Is a Language Boundary

The area where a model and that model's language hold is called a **bounded context**.
Inside the boundary, every name denotes exactly one thing; outside it, the same name may
denote something else, and that is not an inconsistency.

The module, component, and policy–detail boundaries in the Design Principles course answered
where to draw a boundary with change frequency and change-together; a bounded context is a
**language boundary** instead, running through the place where a name's meaning changes, not
through files packaged together. Two contexts can even live in the same component; what makes
the distinction is not packaging but vocabulary.

The rule established in the Ubiquitous Language lesson picks up a condition here: the
language being the same everywhere means it is the same everywhere inside one context. When
the two teams' vocabularies are compared, the overlap is smaller than assumed.

| Word | Pricing context | Delivery operations context |
|---|---|---|
| `shipment` | a priceable unit carrying weight, volume, zone, and declared value | a physical package carrying a route, transfer count, and state history |
| `zone` | the tariff zone that gives the fee coefficient | no counterpart; a distribution-center code stands in its place |
| `tariff` | the tier-and-coefficient table | no counterpart |
| `route` | no counterpart | the sequence of departure, transfer, and delivery points |
| `delivery` | no counterpart | the state-and-timestamp record |

Only one of the five words appears in both contexts, and it names two different objects. Two
arrangements of the same domain follow from this table: one that combines the word into a
single class, and one that runs the boundary through the place where the word's meaning
changes.

#### The Arrangement Combined Into One Class

In the first arrangement, the two vocabularies are gathered into one class. The class carries
both sides' fields, both sides' behavior, and both sides' invariants. The invariants are
collected in the `validate` method; five from the pricing side are marked with a `U` prefix,
five from the delivery side with a `T` prefix.

```sh
mkdir -p combined pricing delivery
```

```js
// combined/shipment.mjs — a shipment carrying both contexts' fields and invariants in one class
const ZONES = ["near", "mid", "far"];
const STATES = ["accepted", "in-transfer", "out-for-delivery", "delivered"];
const EMPTY = {
  shipmentNo: null, weightGrams: null, volumeDm3: null, zone: null,
  declaredValueCents: null, tariffCode: null, contractNo: null, discountRate: null,
  carrierCode: null, routePoints: null, transferCount: null, lastState: null,
  stateHistory: null, recipientName: null, deliveryTime: null,
};

export class Shipment {
  constructor(v) { Object.assign(this, EMPTY, v); }
  fee(tariff) {
    const chargeableGrams = Math.max(this.weightGrams, this.volumeDm3 * 200);
    const rate = tariff[this.tariffCode][this.zone];
    return Math.round((chargeableGrams / 1000) * rate * (1 - this.discountRate));
  }
  markDelivered(name, time) {
    this.recipientName = name; this.deliveryTime = time; this.lastState = "delivered";
    this.stateHistory = [...this.stateHistory, ["delivered", time]];
  }
  validate() {
    const violations = [];
    if (!(Number.isInteger(this.weightGrams) && this.weightGrams > 0)) violations.push("U1-weight");
    if (!(this.volumeDm3 > 0)) violations.push("U2-volume");
    if (!ZONES.includes(this.zone)) violations.push("U3-zone");
    if (!Number.isInteger(this.declaredValueCents)) violations.push("U4-declared-value");
    if (!(this.discountRate >= 0 && (this.discountRate === 0 || this.contractNo !== null))) violations.push("U5-discount");
    if (!(this.routePoints?.length >= 2)) violations.push("T1-route");
    if (this.transferCount !== (this.routePoints?.length ?? 0) - 2) violations.push("T2-transfer");
    if (!STATES.includes(this.lastState)) violations.push("T3-state");
    if (this.stateHistory?.at(-1)?.[0] !== this.lastState) violations.push("T4-history");
    if (this.lastState === "delivered" && this.recipientName === null) violations.push("T5-delivery");
    return violations;
  }
}
```

The `EMPTY` object in the class's constructor is worth noting: every field's default is
`null`. This is not an implementation detail but the forced consequence of combining the two.
A pricing record has no route, a delivery record has no declared value; if a single class is
to accept both, it must leave every field open to absence.

#### The Arrangement With the Boundary Drawn

In the second arrangement, the boundary runs through the place where the word's meaning
changes. Two directories, two `Shipment` classes, two sets of invariants. The same name
appearing in two files is not a duplication; it is the name of two different objects in two
different contexts.

```js
// pricing/shipment.mjs — shipment in the pricing context: a priceable unit
const ZONES = ["near", "mid", "far"];

export class Shipment {
  constructor(v) {
    Object.assign(this, { contractNo: null, discountRate: 0 }, v);
  }
  fee(tariff) {
    const chargeableGrams = Math.max(this.weightGrams, this.volumeDm3 * 200);
    const rate = tariff[this.tariffCode][this.zone];
    return Math.round((chargeableGrams / 1000) * rate * (1 - this.discountRate));
  }
  validate() {
    const violations = [];
    if (!(Number.isInteger(this.weightGrams) && this.weightGrams > 0)) violations.push("U1-weight");
    if (!(this.volumeDm3 > 0)) violations.push("U2-volume");
    if (!ZONES.includes(this.zone)) violations.push("U3-zone");
    if (!Number.isInteger(this.declaredValueCents)) violations.push("U4-declared-value");
    if (!(this.discountRate >= 0 && (this.discountRate === 0 || this.contractNo !== null))) violations.push("U5-discount");
    return violations;
  }
}
```

```js
// delivery/shipment.mjs — shipment in the delivery operations context: a physical package
const STATES = ["accepted", "in-transfer", "out-for-delivery", "delivered"];

export class Shipment {
  constructor(v) {
    Object.assign(this, { recipientName: null, deliveryTime: null }, v);
  }
  markDelivered(name, time) {
    this.recipientName = name; this.deliveryTime = time; this.lastState = "delivered";
    this.stateHistory = [...this.stateHistory, ["delivered", time]];
  }
  validate() {
    const violations = [];
    if (!(this.routePoints?.length >= 2)) violations.push("T1-route");
    if (this.transferCount !== (this.routePoints?.length ?? 0) - 2) violations.push("T2-transfer");
    if (!STATES.includes(this.lastState)) violations.push("T3-state");
    if (this.stateHistory?.at(-1)?.[0] !== this.lastState) violations.push("T4-history");
    if (this.lastState === "delivered" && this.recipientName === null) violations.push("T5-delivery");
    return violations;
  }
}
```

Comparing the two classes needs measurement data: the same three shipments from each team's
own records, and pricing's tariff table.

```js
// data.mjs — the same three shipments from each team's own records, plus the shared tariff
export const TARIFF = {
  standard: { near: 1800, mid: 2400, far: 3200 },
  heavy: { near: 1400, mid: 1900, far: 2600 },
};

export const PRICING_ROWS = [
  { shipmentNo: "G-1041", weightGrams: 2400, volumeDm3: 9, zone: "mid",
    declaredValueCents: 180000, tariffCode: "standard", contractNo: "S-77", discountRate: 0.12 },
  { shipmentNo: "G-1042", weightGrams: 700, volumeDm3: 2, zone: "near",
    declaredValueCents: 0, tariffCode: "standard", contractNo: null, discountRate: 0 },
  { shipmentNo: "G-1043", weightGrams: 18000, volumeDm3: 60, zone: "far",
    declaredValueCents: 950000, tariffCode: "heavy", contractNo: "S-12", discountRate: 0.2 },
];

export const DELIVERY_ROWS = [
  { shipmentNo: "G-1041", carrierCode: "T-A", routePoints: ["34-ctr", "06-trf", "06-dlv"],
    transferCount: 1, lastState: "out-for-delivery",
    stateHistory: [["accepted", 1], ["in-transfer", 2], ["out-for-delivery", 3]] },
  { shipmentNo: "G-1042", carrierCode: "T-B", routePoints: ["34-ctr", "34-dlv"],
    transferCount: 0, lastState: "accepted", stateHistory: [["accepted", 1]] },
  { shipmentNo: "G-1043", carrierCode: "T-A", routePoints: ["34-ctr", "35-trf", "65-trf", "65-dlv"],
    transferCount: 2, lastState: "in-transfer", stateHistory: [["accepted", 1], ["in-transfer", 2]] },
];
```

#### Counting Fields and Field Pairs

The first measure is the field count the class carries. The second measure is more
discriminating: if two fields are never read together in any use case, there is no
justification for them standing in the same class. The script below carries six use-case
scenarios and the fields each one reads, then counts whether each field pair appears together
in at least one of them.

```js
// field-count.mjs — field count, unread fields per use case, and pairs never read together
import { Shipment } from "./combined/shipment.mjs";
import { Shipment as PricingShipment } from "./pricing/shipment.mjs";
import { Shipment as DeliveryShipment } from "./delivery/shipment.mjs";
import { PRICING_ROWS, DELIVERY_ROWS } from "./data.mjs";

const USE_CASES = [
  ["calculateFee", ["shipmentNo", "weightGrams", "volumeDm3", "zone", "tariffCode"]],
  ["applyDiscount", ["shipmentNo", "tariffCode", "contractNo", "discountRate"]],
  ["insurancePremium", ["shipmentNo", "declaredValueCents", "zone"]],
  ["assignRoute", ["shipmentNo", "carrierCode", "routePoints", "transferCount"]],
  ["recordTransfer", ["shipmentNo", "routePoints", "transferCount", "lastState", "stateHistory"]],
  ["markDelivered", ["shipmentNo", "lastState", "stateHistory", "recipientName", "deliveryTime"]],
];

const fieldsOf = (Class, row) => Object.keys(new Class(row));

function measure(name, fields, scenarios) {
  let pairs = 0, together = 0;
  for (let i = 0; i < fields.length; i += 1) {
    for (let j = i + 1; j < fields.length; j += 1) {
      pairs += 1;
      if (scenarios.some(([, o]) => o.includes(fields[i]) && o.includes(fields[j]))) together += 1;
    }
  }
  console.log(`${name}: ${fields.length} fields, ${pairs} field pairs, ${scenarios.length} scenarios`);
  for (const [scenario, read] of scenarios) {
    const present = read.filter((a) => fields.includes(a)).length;
    console.log(`  ${scenario.padEnd(18)} read ${String(present).padStart(2)}, unread ${String(fields.length - present).padStart(2)}`);
  }
  console.log(`  pairs never read together = ${pairs - together} / ${pairs}`);
  return fields;
}

const all = measure("combined", fieldsOf(Shipment, {}), USE_CASES);
const pricingFields = fieldsOf(PricingShipment, PRICING_ROWS[0]);
const deliveryFields = fieldsOf(DeliveryShipment, DELIVERY_ROWS[0]);
const pricingOnly = pricingFields.filter((a) => !deliveryFields.includes(a));
const deliveryOnly = deliveryFields.filter((a) => !pricingFields.includes(a));
console.log(`  ${pricingOnly.length * deliveryOnly.length} of these are cross-context pairs (${pricingOnly.length} x ${deliveryOnly.length})`);
measure("pricing", pricingFields, USE_CASES.filter(([, o]) => o.every((a) => pricingFields.includes(a))));
measure("delivery", deliveryFields, USE_CASES.filter(([, o]) => o.every((a) => deliveryFields.includes(a))));
console.log(`fields shared by both contexts = ${all.filter((a) => pricingFields.includes(a) && deliveryFields.includes(a)).join(", ")}`);
```

```sh
node field-count.mjs
```

```
combined: 15 fields, 105 field pairs, 6 scenarios
  calculateFee       read  5, unread 10
  applyDiscount      read  4, unread 11
  insurancePremium   read  3, unread 12
  assignRoute        read  4, unread 11
  recordTransfer     read  5, unread 10
  markDelivered      read  5, unread 10
  pairs never read together = 68 / 105
  49 of these are cross-context pairs (7 x 7)
pricing: 8 fields, 28 field pairs, 3 scenarios
  calculateFee       read  5, unread  3
  applyDiscount      read  4, unread  4
  insurancePremium   read  3, unread  5
  pairs never read together = 11 / 28
delivery: 8 fields, 28 field pairs, 3 scenarios
  assignRoute        read  4, unread  4
  recordTransfer     read  5, unread  3
  markDelivered      read  5, unread  3
  pairs never read together = 8 / 28
fields shared by both contexts = shipmentNo
```

The combined class carries 15 fields, and none of the six scenarios reads more than five: at
least ten fields sit unused in every use case. The real number is in the last line. Of the 105
field pairs, 68 never appear together in any scenario, and 49 of those are cross-context
pairs — one field belonging to pricing, the other to delivery. Once the boundary is drawn, the
pair count drops from 105 to 56 and the never-together count from 68 to 19; the 49
cross-context pairs disappear entirely. The two contexts' one shared field is `shipmentNo` —
identity itself.

#### Counting Invariants

The field count shows the looseness of the structure; the invariant count shows something
sharper. The combined class's `validate` method carries ten invariants, and all ten run for
every object. Because a pricing record has no route and a delivery record has no declared
value, the result is foregone, but it still needs counting.

```js
// violation-count.mjs — how many violations ten invariants raise on six shipments, and whether the fee changes
import { Shipment } from "./combined/shipment.mjs";
import { Shipment as PricingShipment } from "./pricing/shipment.mjs";
import { Shipment as DeliveryShipment } from "./delivery/shipment.mjs";
import { TARIFF, PRICING_ROWS, DELIVERY_ROWS } from "./data.mjs";

const combinedObjects = [...PRICING_ROWS, ...DELIVERY_ROWS].map((s) => new Shipment(s));
let total = 0, passing = 0;
console.log("combined class (10 invariants)");
for (const s of combinedObjects) {
  const violations = s.validate();
  total += violations.length;
  if (violations.length === 0) passing += 1;
  console.log(`  ${s.shipmentNo}  violations ${violations.length}  ${violations.join(" ") || "-"}`);
}
console.log(`  passing objects = ${passing} / ${combinedObjects.length}, total violations = ${total}`);

const separated = [...PRICING_ROWS.map((s) => new PricingShipment(s)),
                    ...DELIVERY_ROWS.map((s) => new DeliveryShipment(s))];
let total2 = 0, passing2 = 0;
console.log("separated classes (5 + 5 invariants)");
for (const s of separated) {
  const violations = s.validate();
  total2 += violations.length;
  if (violations.length === 0) passing2 += 1;
}
console.log(`  passing objects = ${passing2} / ${separated.length}, total violations = ${total2}`);

let differing = 0;
for (const s of PRICING_ROWS) {
  const a = new Shipment(s).fee(TARIFF), b = new PricingShipment(s).fee(TARIFF);
  if (a !== b) differing += 1;
  console.log(`  ${s.shipmentNo}  combined ${a}  separated ${b}`);
}
console.log(`differing fee = ${differing} / ${PRICING_ROWS.length}`);
```

```sh
node violation-count.mjs
```

```
combined class (10 invariants)
  G-1041  violations 4  T1-route T2-transfer T3-state T4-history
  G-1042  violations 4  T1-route T2-transfer T3-state T4-history
  G-1043  violations 4  T1-route T2-transfer T3-state T4-history
  G-1041  violations 5  U1-weight U2-volume U3-zone U4-declared-value U5-discount
  G-1042  violations 5  U1-weight U2-volume U3-zone U4-declared-value U5-discount
  G-1043  violations 5  U1-weight U2-volume U3-zone U4-declared-value U5-discount
  passing objects = 0 / 6, total violations = 27
separated classes (5 + 5 invariants)
  passing objects = 6 / 6, total violations = 0
  G-1041  combined 5069  separated 5069
  G-1042  combined 1260  separated 1260
  G-1043  combined 37440  separated 37440
differing fee = 0 / 3
```

None of the six real shipments passes the combined class's invariants: 0/6, 27 violations
total. The same six shipments, the same ten invariants, pass 6/6 once distributed across two
classes, and the violation count is zero. The invariants' text did not change, only the scope
in which they applied did.

The last three lines show that splitting did not break behavior: the fee for all three
shipments is the same in both arrangements. Drawing the boundary splits the model into two
parts; it does not change the arithmetic.

#### What a Conditional Invariant Hides

There is a known way to rescue the combined class: writing the invariants conditionally. Let
the route check run only when the route field is filled, and the weight check only when the
weight is filled. This drives the violation count to zero, but it loses something: the
condition puts back, as data inside the class, which context's object is being worked with.
The invariant no longer says "a shipment's route has at least two points"; it says "if this
object came from delivery, its route has at least two points." The first is the domain
expert's sentence; the second is not.

The procedure for finding where the boundary runs follows from this: look at which of a
class's invariants can only be written under a condition. If the condition is hiding a context
distinction, the boundary runs through that condition's place.

#### Summary

- A bounded context is the area where a model and its language hold; unlike a component
  boundary, it runs through the place where a name's meaning changes, not through files
  packaged together.
- Only one of five words in the two contexts' vocabulary was shared, and that word named two
  different objects; the two classes' one shared field turned out to be `shipmentNo`.
- The combined class carried 15 fields, and none of the six scenarios read more than five; of
  the 105 field pairs, 68 never appeared together in any scenario, and 49 of those were
  cross-context pairs.
- Once the boundary was drawn, the field-pair count dropped from 105 to 56 and the
  never-together count from 68 to 19; the 49 cross-context pairs disappeared entirely.
- Ten invariants in one class rejected all six real shipments (0/6, 27 violations); the same
  invariants, distributed across two classes, passed 6/6, and the fee for all three shipments
  stayed the same.
- Writing an invariant conditionally zeroes out the violations but puts the context
  distinction back inside the class as data; the condition's location shows where the
  boundary belongs.

#### Next Step

The boundary has been drawn, and each context now has its own vocabulary. Yet the two
contexts cannot work in ignorance of each other: while pricing computes a shipment's price,
delivery sends that same shipment out; once it is delivered, pricing needs to know. A
boundary is not a wall but a gate, and how many gates there are, which way each one faces,
and who conforms to whose vocabulary is a design decision. The next lesson writes the
relationships between contexts as a graph, distinguishes relationship types by direction and
dependency, then runs the same measure on two maps: how many contexts have a file edited when
one context changes, and where that number lands once the relationships are named.

### Context Map

Source: https://academia.sh/en/courses/domain-driven-design/context-map

Relationship types between contexts: separating the upstream and downstream direction, extracting the map from real imports, counting the mutually dependent context pair and the number of contexts affected when one context changes across two maps, and measuring how many core files the upstream's names appear in.

The boundary has been drawn, and each context now has its own vocabulary. Yet contexts
cannot work in ignorance of each other: pricing takes the discount rate from the contract
context, delivery takes the service level from the same place, and the contract summary shows
both results together. A boundary is not a wall but a gate; how many gates there are, which
way each one faces, and who conforms to whose vocabulary is a design decision.

The written record of these decisions is called a **context map**. A map is not a
box-and-arrow drawing; every arrow has a type, a direction, and a cost. This lesson extracts
the map from the code's real imports and compares two maps with the same three measures: the
count of mutually dependent context pairs, the number of contexts affected when one context
changes, and the count of core files where the upstream's names appear.

#### Upstream and Downstream

The direction of the arrow between two contexts says which one has to conform to the other's
changes. The side whose model others conform to is called **upstream**; the side that has to
conform is called **downstream**. Direction follows not from domain knowledge but from
decision rights: if the contract context decides what the discount rate means, it is
upstream; if pricing conforms to that decision, it is downstream.

This is a different question from the dependency direction in the Design Principles course.
There, the arrow pointed to the side with high stability; here, the arrow points to the side
that makes the domain decision. The two can coincide, but they do not have to: a context can
be technically unstable and still be the domain's decision-maker.

#### Relationship Types

More than one relationship can hold between the same two contexts; whichever one holds is
written into the map by name.

| Relationship | Direction | What it says |
|---|---|---|
| **partnership** | symmetric | The two teams succeed together or fail together; releases are planned together |
| **customer–supplier** | upstream → downstream | Downstream's needs enter upstream's plan; upstream makes commitments |
| **open host service** | upstream publishes | Upstream offers a defined access surface for multiple consumers |
| **published language** | upstream publishes | The names and types on the surface are documented separately; the published language holds even if the internal model changes |
| **separate ways** | none | The two contexts are not integrated; any need is met outside |
| **unnamed relationship** | unclear | A connection exists, but its direction and type are not written down; each side reads the other's model |

Beyond this list there are two more collaboration patterns and one defense pattern against the
outside model; all three are taken up in their own lessons. What this lesson measures is not
the types themselves but the numerical payoff of naming an unnamed relationship.

#### Three Contexts, Two Arrangements

The upstream context is contract and customer. Its own vocabulary carries five names.

```sh
mkdir -p contract unnamed named
```

```js
// contract/contract.mjs — upstream context: contract and customer records
const CONTRACTS = [
  { contractNo: "S-77", customerNo: "M-4", tariffDiscountPercent: 12, serviceLevelCode: "SS2", isPrepaid: true },
  { contractNo: "S-12", customerNo: "M-9", tariffDiscountPercent: 20, serviceLevelCode: "SS1", isPrepaid: false },
];

export const findContract = (no) => CONTRACTS.find((c) => c.contractNo === no) ?? null;
export const contractNumbers = () => CONTRACTS.map((c) => c.contractNo);
```

In the first arrangement, the relationships are unnamed. Both downstream contexts read the
contract context's own names directly; the report sits inside the contract context and calls
both downstream contexts back.

```js
// unnamed/fee.mjs — pricing reads the contract context's names directly
import { findContract } from "../contract/contract.mjs";

export function netFee(grossCents, contractNo) {
  const c = findContract(contractNo);
  if (c === null) return grossCents;
  const discounted = Math.round(grossCents * (1 - c.tariffDiscountPercent / 100));
  return c.isPrepaid ? Math.round(discounted * 0.98) : discounted;
}
```

```js
// unnamed/delivery.mjs — delivery reads the contract context's names directly
import { findContract } from "../contract/contract.mjs";

const WINDOW = { SS1: 24, SS2: 48, SS3: 72 };

export function deliveryWindowHours(contractNo) {
  const c = findContract(contractNo);
  return c === null ? 72 : WINDOW[c.serviceLevelCode];
}
```

```js
// unnamed/report.mjs — report sitting in the contract context, calling both downstream contexts back
import { findContract, contractNumbers } from "../contract/contract.mjs";
import { netFee } from "./fee.mjs";
import { deliveryWindowHours } from "./delivery.mjs";

export const contractSummary = (grossCents) => contractNumbers().map((no) => ({
  payer: findContract(no).customerNo,
  net: netFee(grossCents, no),
  window: deliveryWindowHours(no),
}));
```

In the second arrangement, each downstream context carries a boundary file that translates the
upstream's names into its own language; the core files speak only their own language. The
report also changes location: it is treated as belonging to the pricing context, moved there,
and uses only delivery's published window function.

```js
// named/pricing-translation.mjs — translates the upstream names into pricing's own language
import { findContract, contractNumbers } from "../contract/contract.mjs";

export const contractList = () => contractNumbers();

export function pricingTerms(contractNo) {
  const c = findContract(contractNo);
  if (c === null) return { payer: null, discountRate: 0, prepaidDiscount: 0 };
  return { payer: c.customerNo, discountRate: c.tariffDiscountPercent / 100,
           prepaidDiscount: c.isPrepaid ? 0.02 : 0 };
}
```

```js
// named/fee.mjs — pricing core: speaks only its own language
import { pricingTerms } from "./pricing-translation.mjs";

export function netFee(grossCents, contractNo) {
  const { discountRate, prepaidDiscount } = pricingTerms(contractNo);
  return Math.round(Math.round(grossCents * (1 - discountRate)) * (1 - prepaidDiscount));
}
```

```js
// named/delivery-translation.mjs — translates the upstream names into delivery's own language
import { findContract } from "../contract/contract.mjs";

const WINDOW = { SS1: 24, SS2: 48, SS3: 72 };

export function deliveryTerms(contractNo) {
  const c = findContract(contractNo);
  return { windowHours: c === null ? 72 : WINDOW[c.serviceLevelCode] };
}
```

```js
// named/delivery.mjs — delivery core: speaks only its own language
import { deliveryTerms } from "./delivery-translation.mjs";

export const deliveryWindowHours = (contractNo) => deliveryTerms(contractNo).windowHours;
```

```js
// named/report.mjs — report in the pricing context: its own language, plus delivery's published window
import { contractList, pricingTerms } from "./pricing-translation.mjs";
import { netFee } from "./fee.mjs";
import { deliveryWindowHours } from "./delivery.mjs";

export const contractSummary = (grossCents) => contractList().map((no) => ({
  payer: pricingTerms(no).payer,
  net: netFee(grossCents, no),
  window: deliveryWindowHours(no),
}));
```

What the translation does is more than renaming. `tariffDiscountPercent` is an integer
expressed as a percent, while pricing wants a rate; `isPrepaid` is a payment condition, and
pricing derives a discount rate from it; `customerNo` is the contract context's identity, and
pricing calls that same person `payer`. The boundary file is where these three
transformations stand.

#### Extracting the Map From Code

A hand-written map can drift from the code. The script below reads each file's imports and
extracts directed edges at the context level, then computes three measures: mutually
dependent context pairs, the number of contexts transitively affected when one context
changes, and how many of the files carrying a name from the upstream vocabulary are core
files.

```js
// map-extract.mjs — extracts the context map from real imports, measures spread and leakage
import { readFileSync } from "node:fs";
import { dirname, join, normalize } from "node:path";

const CONTEXT = {
  "contract/contract.mjs": "contract",
  "unnamed/report.mjs": "contract", "unnamed/fee.mjs": "pricing", "unnamed/delivery.mjs": "delivery",
  "named/pricing-translation.mjs": "pricing", "named/fee.mjs": "pricing",
  "named/report.mjs": "pricing",
  "named/delivery-translation.mjs": "delivery", "named/delivery.mjs": "delivery",
};
const VOCABULARY = ["tariffDiscountPercent", "serviceLevelCode", "isPrepaid", "customerNo"];
const BOUNDARY = (d) => d.includes("-translation.mjs");

function edges(files) {
  const set = new Set();
  for (const d of files) {
    for (const [, path] of readFileSync(d, "utf8").matchAll(/from "([^"]+)"/g)) {
      const a = CONTEXT[d], b = CONTEXT[normalize(join(dirname(d), path))];
      if (a !== b) set.add(`${a}->${b}`);
    }
  }
  return [...set].sort();
}

function affected(edgeList, target) {
  const stack = [target], seen = new Set();
  while (stack.length > 0) {
    const b = stack.pop();
    for (const k of edgeList) {
      const [a, v] = k.split("->");
      if (v === b && a !== target && !seen.has(a)) { seen.add(a); stack.push(a); }
    }
  }
  return seen;
}

for (const [name, files] of [
  ["unnamed", ["contract/contract.mjs", "unnamed/fee.mjs", "unnamed/delivery.mjs", "unnamed/report.mjs"]],
  ["named", ["contract/contract.mjs", "named/pricing-translation.mjs", "named/fee.mjs",
             "named/report.mjs", "named/delivery-translation.mjs", "named/delivery.mjs"]],
]) {
  const edgeList = edges(files);
  const mutual = edgeList.filter((k) => edgeList.includes(k.split("->").reverse().join("->"))).length / 2;
  console.log(`${name}: ${files.length} files, ${edgeList.length} directed edges, ${mutual} mutual context pairs`);
  for (const k of edgeList) console.log(`  ${k}`);
  let total = 0;
  for (const b of ["contract", "pricing", "delivery"]) {
    const a = affected(edgeList, b);
    total += a.size;
    const list = a.size > 0 ? ` (${[...a].sort().join(", ")})` : "";
    console.log(`  ${b.padEnd(14)} affected contexts if changed = ${a.size}${list}`);
  }
  console.log(`  total spread = ${total}`);
  const leaking = files.filter((d) => CONTEXT[d] !== "contract"
    && VOCABULARY.some((n) => new RegExp(`\\b${n}\\b`).test(readFileSync(d, "utf8"))));
  const core = files.filter((d) => CONTEXT[d] !== "contract" && !BOUNDARY(d));
  console.log(`  files with an upstream name = ${leaking.length} (${leaking.join(", ")})`);
  console.log(`  of those, core files = ${leaking.filter((d) => !BOUNDARY(d)).length} / ${core.length}`);
}
```

```sh
node map-extract.mjs
```

```
unnamed: 4 files, 4 directed edges, 2 mutual context pairs
  contract->delivery
  contract->pricing
  delivery->contract
  pricing->contract
  contract       affected contexts if changed = 2 (delivery, pricing)
  pricing        affected contexts if changed = 2 (contract, delivery)
  delivery       affected contexts if changed = 2 (contract, pricing)
  total spread = 6
  files with an upstream name = 2 (unnamed/fee.mjs, unnamed/delivery.mjs)
  of those, core files = 2 / 2
named: 6 files, 3 directed edges, 0 mutual context pairs
  delivery->contract
  pricing->contract
  pricing->delivery
  contract       affected contexts if changed = 2 (delivery, pricing)
  pricing        affected contexts if changed = 0
  delivery       affected contexts if changed = 1 (pricing)
  total spread = 3
  files with an upstream name = 2 (named/pricing-translation.mjs, named/delivery-translation.mjs)
  of those, core files = 0 / 3
```

In the unnamed arrangement, there are four directed edges among the three contexts, and two
of them are a mutual pair: contract depends on pricing and pricing depends on contract, both
ways. The only thing creating that mutuality is the report's location. Because the report sits
inside the contract context, that context is forced to call back the two contexts that depend
on it. The result is all three contexts wired to each other: whichever one changes, the other
two are affected, for a total spread of 6.

In the named arrangement, the edge count drops to three and the mutual-pair count to zero.
When the upstream contract context changes, two contexts are still affected — that is
unavoidable, since that context genuinely defines what the discount rate means. What changes
is the others: if pricing changes, no context is affected; if delivery changes, one context is
affected. Total spread dropped from 6 to 3.

The last two lines show the leakage. The upstream's four names appear in two files in both
arrangements; the count is the same. The difference is what those two files are. In the
unnamed arrangement, both are the downstream contexts' core files (2/2); in the named
arrangement, both are boundary files, and not a single upstream name appears in any core file
(0/3). If the contract context decides to say `discountRate` instead of
`tariffDiscountPercent`, two files get edited in both arrangements, but in the unnamed
arrangement, the files edited are the ones carrying the business rule itself.

#### Was Behavior Preserved?

A changed map is not a changed output; it is tested.

```js
// run.mjs — do the two arrangements give the same report and the same delivery window?
import { contractSummary as unnamedSummary } from "./unnamed/report.mjs";
import { deliveryWindowHours as unnamedWindow } from "./unnamed/delivery.mjs";
import { contractSummary as namedSummary } from "./named/report.mjs";
import { deliveryWindowHours as namedWindow } from "./named/delivery.mjs";

const a = JSON.stringify(unnamedSummary(10000)), b = JSON.stringify(namedSummary(10000));
console.log(`unnamed ${a}`);
console.log(`named   ${b}`);
console.log(`report identical = ${a === b}`);
let differing = 0;
for (const no of ["S-77", "S-12", "S-99"]) {
  if (unnamedWindow(no) !== namedWindow(no)) differing += 1;
}
console.log(`differing window = ${differing} / 3`);
```

```sh
node run.mjs
```

```
unnamed [{"payer":"M-4","net":8624,"window":48},{"payer":"M-9","net":8000,"window":24}]
named   [{"payer":"M-4","net":8624,"window":48},{"payer":"M-9","net":8000,"window":24}]
report identical = true
differing window = 0 / 3
```

The report is identical, and the delivery window for all three contract numbers is the same,
including the undefined contract number `S-99`. The map was redrawn; domain behavior stayed
in place.

#### The Cost of the Map

The named arrangement is two files larger and carries one extra layer of indirection on every
read. The second part of the cost is less visible: a boundary file passes through only what it
translates. When the contract context publishes a new field, downstream cannot use it
directly — it has to be added to the translation first. That means delay; in exchange, the
business-rule files never have to recognize the upstream's vocabulary at all.

The map itself has a cost too: there is no such thing as an unwritten map, only an unseen one.
The unnamed arrangement also produced a map — four edges, two mutual pairs. The difference is
that one of them was written down.

#### Summary

- A context map writes down the type and direction of relationships between contexts; the
  arrow points to the side that makes the domain decision, and that is a different question
  from the stability criterion behind dependency direction.
- Relationship types are named: partnership, customer–supplier, open host service, published
  language, separate ways; in an unnamed relationship a connection exists but its direction is
  not written down.
- When extracted from the code's real imports, the unnamed arrangement showed 4 directed
  edges and 2 mutual context pairs; what created the mutuality was the report sitting inside
  the upstream context.
- Once the relationships were named, the edge count dropped to 3 and the mutual-pair count to
  0; total spread fell from 6 to 3, and the number of contexts affected if pricing changes
  fell from 2 to 0.
- The upstream's four names appeared in two files in both arrangements, but in the unnamed
  arrangement those files were core files (2/2), while in the named arrangement all of them
  were boundary files (0/3 in core).
- The report and all three delivery windows came out identical in both arrangements; the cost
  is two extra files and a translation that passes through only what it maps.

#### Next Step

The translation files in this lesson worked under favorable conditions: the upstream contract
context was part of the same codebase, its names were tidy, its types were known. Some of the
upstream contexts the library talks to in the real world are not like that — carrier
providers have their own models, their own state sets, and their own identity formats; none
of them has to conform to the library's domain language, and none of them consults anyone
before changing. If such an upstream's concepts leak in, the internal model starts speaking
the outside's language: the delivery context's states turn into the carrier's state codes.
The next lesson counts how many files the outside model's names and types appear in, measures
where that count lands once a defense layer is placed in between, and works out the layer's
cost in files and lines.

### Anti-Corruption Layer

Source: https://academia.sh/en/courses/domain-driven-design/anticorruption-layer

Preventing the outside model from leaking in: counting how many files two carrier providers' names, state sets, and units appear in, measuring the number of external state values the business rule recognizes, showing that leakage in the core files drops to zero once a translation layer is added, and working out the layer's cost in files and lines.

The previous lesson's translation files worked under favorable conditions: the upstream
contract context was part of the same codebase, its names were tidy, its types were known.
Some of the upstream contexts the library talks to are not like that. Carrier providers have
their own models, their own state sets, their own identity formats, and their own weight
units; none of them has to conform to the library's domain language, and none of them
consults anyone before changing.

This lesson's question is not an interface question. Two providers calling in different
shapes is a problem already measured in the Design Principles and Design Patterns courses,
and its solution is an adapter. The problem here is one level deeper: if the provider's
concepts leak in, the internal model starts speaking the outside's language. The delivery
context's states turn into the carrier's movement codes, a shipment's weight gets tied to the
carrier's unit, and a transport error such as "record not found" behaves like a domain state.
This lesson counts that leakage.

#### Two Levels of Mismatch

An adapter reconciles the two sides' signatures: the call's name, the parameter order, the
return shape. What it reconciles is form, not concept. An adapter can gather two providers
under a single function name and still pass both of their state codes upward unchanged.

An **anti-corruption layer**, by contrast, reconciles the two sides' models: the outside's
state set is translated into the inside's, the outside's unit into the inside's, the
outside's error shape into the inside's, and that translation stands in one place. The name
says as much: what is protected is the internal model itself. Its measure follows from
this — how many times do the outside's names and values appear in the internal model's files?

The two providers are written as two external systems standing outside the library. Their
vocabularies are deliberately unlike each other.

```sh
mkdir -p provider leaky protected
```

```js
// provider/carrier-a.mjs — external system A: its own movement codes, epoch time, pound weight
const RECORDS = {
  "AA-1041": { movementCode: "H25", lastMovementEpoch: 1700000000, weightPounds: 5.3, errorCode: 0 },
  "AA-1042": { movementCode: "H30", lastMovementEpoch: 1700050000, weightPounds: 1.5, errorCode: 0 },
};

export function dispatchState(dispatchId) {
  const r = RECORDS[dispatchId];
  return r === undefined
    ? { dispatchId, movementCode: "H99", lastMovementEpoch: 0, weightPounds: 0, errorCode: 404 }
    : { dispatchId, ...r };
}
```

```js
// provider/carrier-b.mjs — external system B: its own state tags, ISO time, ounce weight
const RECORDS = {
  "BB/2210": { stateTag: "completed", lastMovementTime: "2023-11-14T22:13:20Z", weightOunces: 84 },
  "BB/2211": { stateTag: "en-route", lastMovementTime: "2023-11-15T20:00:00Z", weightOunces: 12 },
};

export function track(reference) {
  const r = RECORDS[reference];
  return r === undefined
    ? { reference, stateTag: "no-record", lastMovementTime: null, weightOunces: 0 }
    : { reference, ...r };
}
```

The differences can be counted: A gives state as six codes, B as five tags; A gives time as
epoch seconds, B as ISO text; A gives weight in pounds, B in ounces; A reports a record not
found with an error code, B with a state tag. The domain side's business rule, meanwhile,
asks a single question: is this shipment delayed?

#### The Leaky Arrangement

In the first arrangement, the business rule directly recognizes both providers' vocabularies.

```js
// leaky/delay.mjs — the business rule recognizes both providers' state dictionaries and units directly
import { dispatchState } from "../provider/carrier-a.mjs";
import { track } from "../provider/carrier-b.mjs";

export function isDelayed(carrier, reference, windowHours, nowEpoch) {
  if (carrier === "A") {
    const r = dispatchState(reference);
    if (r.errorCode !== 0 || r.movementCode === "H99") return null;
    if (r.movementCode === "H30" || r.movementCode === "H40") return false;
    return (nowEpoch - r.lastMovementEpoch) / 3600 > windowHours;
  }
  const r = track(reference);
  if (r.stateTag === "no-record") return null;
  if (r.stateTag === "completed" || r.stateTag === "returned-to-sender") return false;
  return (nowEpoch - Math.floor(Date.parse(r.lastMovementTime) / 1000)) / 3600 > windowHours;
}
```

```js
// leaky/report.mjs — the report is also written with both providers' units and state dictionaries
import { dispatchState } from "../provider/carrier-a.mjs";
import { track } from "../provider/carrier-b.mjs";
import { isDelayed } from "./delay.mjs";

export function summary(records, windowHours, nowEpoch) {
  let delayed = 0, unresolved = 0, grams = 0;
  for (const [carrier, reference] of records) {
    const d = isDelayed(carrier, reference, windowHours, nowEpoch);
    if (d === null) unresolved += 1; else if (d) delayed += 1;
    grams += carrier === "A"
      ? Math.round(dispatchState(reference).weightPounds * 453.59237)
      : Math.round(track(reference).weightOunces * 28.349523125);
  }
  return { delayed, unresolved, grams };
}
```

The files work, and they are short. The problem is in the sentence the rule is telling. "An
undelivered shipment is delayed if it has exceeded the service window since its last
movement" is the domain expert's sentence; the sentence sitting in the file reads "if the
movement code is not H30 or H40, and the state tag is not completed or
returned-to-sender." The two may say the same thing, but the second one goes wrong when the
provider's contract changes, and the place it goes wrong is the business-rule file.

#### The Protected Arrangement

In the second arrangement, each provider has a translation file. The translation does four
things at once: it maps the state set, brings time into a single form, converts weight to
grams, and reduces a not-found record to the internal model's `unknown` state.

```js
// protected/carrier-a-translation.mjs — translates provider A's model into the internal model
import { dispatchState } from "../provider/carrier-a.mjs";

const STATE = { H10: "accepted", H20: "in-transfer", H25: "out-for-delivery", H30: "delivered", H40: "returned" };

export function getShipment(reference) {
  const r = dispatchState(reference);
  if (r.errorCode !== 0) return { shipmentNo: reference, lastState: "unknown", lastMovementAt: 0, weightGrams: 0 };
  return {
    shipmentNo: reference,
    lastState: STATE[r.movementCode] ?? "unknown",
    lastMovementAt: r.lastMovementEpoch,
    weightGrams: Math.round(r.weightPounds * 453.59237),
  };
}
```

```js
// protected/carrier-b-translation.mjs — translates provider B's model into the same internal model
import { track } from "../provider/carrier-b.mjs";

const STATE = { received: "accepted", "en-route": "in-transfer", "handed-to-courier": "out-for-delivery",
                completed: "delivered", "returned-to-sender": "returned" };

export function getShipment(reference) {
  const r = track(reference);
  const lastState = STATE[r.stateTag] ?? "unknown";
  return {
    shipmentNo: reference,
    lastState,
    lastMovementAt: lastState === "unknown" ? 0 : Math.floor(Date.parse(r.lastMovementTime) / 1000),
    weightGrams: Math.round(r.weightOunces * 28.349523125),
  };
}
```

```js
// protected/delay.mjs — the business rule recognizes only the internal model's state set
const CLOSED = ["delivered", "returned"];

export function isDelayed(shipment, windowHours, nowEpoch) {
  if (shipment.lastState === "unknown") return null;
  if (CLOSED.includes(shipment.lastState)) return false;
  return (nowEpoch - shipment.lastMovementAt) / 3600 > windowHours;
}
```

```js
// protected/report.mjs — the report also reads only the internal model
import { isDelayed } from "./delay.mjs";

export function summary(shipments, windowHours, nowEpoch) {
  let delayed = 0, unresolved = 0, grams = 0;
  for (const s of shipments) {
    const d = isDelayed(s, windowHours, nowEpoch);
    if (d === null) unresolved += 1; else if (d) delayed += 1;
    grams += s.weightGrams;
  }
  return { delayed, unresolved, grams };
}
```

The rule file no longer knows which provider is speaking. The weight field's name changed
too: the internal model's time field is `lastMovementAt`, not provider A's
`lastMovementEpoch`. Keeping two names for the same number apart looks needless; but taking
the name from the outside means that name changes along with the outside.

#### Counting the Leakage

The measure consists of three numbers: the count of files where external names and external
state values appear, how many of those are core files, and the number of external state
values the business rule recognizes.

```js
// leak-count.mjs — counts how many files the external model's names and state values appear in
import { readFileSync } from "node:fs";

const EXTERNAL_NAMES = ["movementCode", "lastMovementEpoch", "weightPounds", "errorCode",
                        "stateTag", "lastMovementTime", "weightOunces"];
const EXTERNAL_VALUES = ["H10", "H20", "H25", "H30", "H40", "H99",
                         "received", "en-route", "handed-to-courier", "completed", "returned-to-sender", "no-record"];
const INTERNAL_VALUES = ["accepted", "in-transfer", "out-for-delivery", "delivered", "returned", "unknown"];

const present = (text, names) => names.filter((a) => new RegExp(`(?<![\\w-])${a}(?![\\w-])`).test(text));

for (const [arrangement, files, rule] of [
  ["leaky", ["leaky/delay.mjs", "leaky/report.mjs"], "leaky/delay.mjs"],
  ["protected", ["protected/carrier-a-translation.mjs", "protected/carrier-b-translation.mjs",
                 "protected/delay.mjs", "protected/report.mjs"], "protected/delay.mjs"],
]) {
  const lines = files.reduce((t, d) => t + readFileSync(d, "utf8").trimEnd().split("\n").length, 0);
  console.log(`${arrangement}/ (${files.length} files, ${lines} lines)`);
  let leaking = 0, leakingCore = 0, core = 0;
  for (const d of files) {
    const text = readFileSync(d, "utf8");
    const names = present(text, EXTERNAL_NAMES), extVals = present(text, EXTERNAL_VALUES), intVals = present(text, INTERNAL_VALUES);
    const boundary = d.includes("-translation.mjs");
    if (!boundary) core += 1;
    if (names.length > 0 || extVals.length > 0) { leaking += 1; if (!boundary) leakingCore += 1; }
    console.log(`  ${d.split("/")[1].padEnd(26)} ext name ${names.length}  ext value ${String(extVals.length).padStart(2)}  int value ${intVals.length}`);
  }
  console.log(`  files with an external name or value = ${leaking} / ${files.length}, in core files = ${leakingCore} / ${core}`);
  const k = readFileSync(rule, "utf8");
  console.log(`  state values the business rule recognizes: external ${present(k, EXTERNAL_VALUES).length}, internal ${present(k, INTERNAL_VALUES).length}`);
}
```

```sh
node leak-count.mjs
```

```
leaky/ (2 files, 32 lines)
  delay.mjs                  ext name 5  ext value  6  int value 0
  report.mjs                 ext name 2  ext value  0  int value 0
  files with an external name or value = 2 / 2, in core files = 2 / 2
  state values the business rule recognizes: external 6, internal 0
protected/ (4 files, 51 lines)
  carrier-a-translation.mjs  ext name 4  ext value  5  int value 6
  carrier-b-translation.mjs  ext name 3  ext value  5  int value 6
  delay.mjs                  ext name 0  ext value  0  int value 3
  report.mjs                 ext name 0  ext value  0  int value 0
  files with an external name or value = 2 / 4, in core files = 0 / 2
  state values the business rule recognizes: external 0, internal 3
```

In the leaky arrangement, both of the two files recognize the outside's vocabulary, and both
are core files: 2/2. In the protected arrangement, the outside's vocabulary still appears in
two files — the count is the same — but both are boundary files; in core, 0/2. The total
amount of leakage does not shrink; its location changes. That is the whole claim of an
anti-corruption layer: the outside's model has to show up somewhere, and that place should
not be the business rule.

The second pair of lines is sharper. In the leaky arrangement, the business rule recognizes
six external state values and no internal state value; the internal model's state set is
nowhere in the code, existing only indirectly as the union of the two providers' sets. In the
protected arrangement, the rule recognizes zero external values and three internal values.
As a domain concept, "state" is a single set for the first time.

#### Behavior and Cost

The translation's correctness is not asserted; it is tested. Six records must produce the
same decision and the same summary in both arrangements.

```js
// run.mjs — do the two arrangements give the same delay decision and the same summary?
import { isDelayed as leakyDecision } from "./leaky/delay.mjs";
import { summary as leakySummary } from "./leaky/report.mjs";
import { getShipment as translateA } from "./protected/carrier-a-translation.mjs";
import { getShipment as translateB } from "./protected/carrier-b-translation.mjs";
import { isDelayed as protectedDecision } from "./protected/delay.mjs";
import { summary as protectedSummary } from "./protected/report.mjs";

const RECORDS = [["A", "AA-1041"], ["A", "AA-1042"], ["A", "AA-9999"],
                 ["B", "BB/2210"], ["B", "BB/2211"], ["B", "BB/9999"]];
const WINDOW_HOURS = 24, NOW = 1700100000;

const internalModel = RECORDS.map(([t, r]) => (t === "A" ? translateA(r) : translateB(r)));
let differing = 0;
for (let i = 0; i < RECORDS.length; i += 1) {
  const a = leakyDecision(RECORDS[i][0], RECORDS[i][1], WINDOW_HOURS, NOW);
  const b = protectedDecision(internalModel[i], WINDOW_HOURS, NOW);
  if (a !== b) differing += 1;
  console.log(`${RECORDS[i][1].padEnd(9)} ${String(internalModel[i].lastState).padEnd(17)} leaky ${String(a).padEnd(5)} protected ${b}`);
}
console.log(`differing decision = ${differing} / ${RECORDS.length}`);
console.log(`leaky summary     = ${JSON.stringify(leakySummary(RECORDS, WINDOW_HOURS, NOW))}`);
console.log(`protected summary = ${JSON.stringify(protectedSummary(internalModel, WINDOW_HOURS, NOW))}`);
```

```sh
node run.mjs
```

```
AA-1041   out-for-delivery  leaky true  protected true
AA-1042   delivered         leaky false protected false
AA-9999   unknown           leaky null  protected null
BB/2210   delivered         leaky false protected false
BB/2211   in-transfer       leaky false protected false
BB/9999   unknown           leaky null  protected null
differing decision = 0 / 6
leaky summary     = {"delayed":1,"unresolved":2,"grams":5805}
protected summary = {"delayed":1,"unresolved":2,"grams":5805}
```

The decision matches on all six records, and all three fields of the summary match too.
Including the two not-found records and the two different weight units, the translation
produced no numerical difference.

The cost is two files and nineteen lines: four files totaling 51 lines instead of two files
totaling 32. The second cost is not measured in lines: a translation only passes through what
it maps. A concept found only in provider A — customs hold, say — cannot reach the business
rule unless it has a counterpart in the internal model's state set. This looks like a loss,
but it fixes where the decision gets made: the internal model decides whether a new domain
concept is needed, not the provider.

The layer's justification follows from this, and it does not hold for every external system.
For an upstream whose contract is under the library's control and whose model draws the same
distinctions as the domain, the layer is nothing but an added indirection. The justification
is that the outside's model draws different distinctions and changes without consulting the
library.

#### Summary

- An adapter reconciles signatures; an anti-corruption layer reconciles models. What is
  protected is the internal model's own concepts.
- The two providers gave state as six codes and five tags, time as epoch seconds and ISO
  text, weight in pounds and ounces; one reported a not-found record with an error code, the
  other with a state tag.
- In the leaky arrangement, the outside vocabulary appeared in both of two files, and both
  were core files (2/2); in the protected arrangement it still appeared in two files, but
  both were boundary files, 0/2 in core.
- In the leaky arrangement, the business rule recognized six external state values and no
  internal values; in the protected arrangement it recognized zero external and three
  internal values — state became a single set for the first time.
- The decision and all three fields of the summary came out the same in both arrangements on
  six records; the layer's cost is 2 files and 19 lines, plus a translation that passes
  through only the concepts it maps.
- The layer's justification is that the outside's model draws different distinctions and
  changes without consulting the library; for an upstream whose contract is under the
  library's own control, the layer is nothing but an indirection.

#### Next Step

Both defenses so far rested on the same assumption: the other side is a stranger, and a
translation stands in between. That assumption does not hold when two contexts belong to the
same team rather than two separate ones, or when the downstream team has no power at all to
change the upstream. In the first case, the teams can choose to hold a part in common; in the
second, downstream can give up translation and adopt the upstream's model as it is. Both are
measurable decisions. The next lesson builds these two collaboration patterns, counts how
many files two teams are forced to edit together once the shared part grows, and shows the
cost of giving up translation on the same scale.

### Shared Kernel and Conformist

Source: https://academia.sh/en/courses/domain-driven-design/shared-kernel-and-conformist

Two collaboration patterns: counting the rate of changes requiring a joint decision and the number of files two teams edit together as a shared kernel grows, measuring the core files used by a single context, and comparing the conformist arrangement's file and line savings against the number of names left in downstream's own vocabulary.

The previous two lessons rested on the same assumption: the other side is a stranger, and a
translation stands in between. That assumption does not hold in two cases. First, when two
contexts are held by the same team, or by two teams that plan together; maintaining a
translation between them means making the same decision in two places. Second, when the
downstream team has no power at all to influence the upstream; whether it writes a
translation or not, the upstream changes however it wants.

The answer to the first case is a part held in common: a **shared kernel**. The answer to the
second is giving up translation: being a **conformist**. Both are decisions whose cost can be
measured. This lesson counts how many files two teams are forced to edit together as the
kernel grows, then compares the conformist's gain against the number of names it leaves in
downstream's own vocabulary.

#### Defining the Shared Kernel

A shared kernel is a piece of the model that two contexts jointly own. Joint ownership
requires more than sharing code: a change inside the kernel needs both teams' approval, both
sides' tests run together, releases are planned together. That is why the kernel is a
workable arrangement when kept small, and why it collapses the two contexts back into one
when it grows.

The measure is straightforward: whether a change touches the kernel is whether it requires a
joint decision. The change log below carries sixteen model changes from the shipment pricing
and routing library, each with the domain concept it touches.

```js
// change-log.mjs — sixteen changes and the domain concept each one touches
export const LOG = [
  ["2025-01-14", "unit-of-measure"], ["2025-01-28", "tariff-tier"],
  ["2025-02-11", "state-set"], ["2025-02-25", "tariff-tier"],
  ["2025-03-11", "chargeable-weight-rule"], ["2025-03-25", "discount-rule"],
  ["2025-04-08", "tariff-tier"], ["2025-04-22", "state-set"],
  ["2025-05-06", "vehicle-selection"], ["2025-05-20", "unit-of-measure"],
  ["2025-06-03", "tariff-tier"], ["2025-06-17", "chargeable-weight-rule"],
  ["2025-07-01", "discount-rule"], ["2025-07-15", "state-set"],
  ["2025-07-29", "contract-threshold"], ["2025-08-12", "transfer-rule"],
];
```

The same eight concepts land in different files across two arrangements. In the small
kernel, the only thing held in common is the measure: the definition of the gram and the
cubic decimeter, and the rule for converting volumetric weight into chargeable weight. In the
large kernel, the tariff tier and the state set are also pulled into the kernel — even though
both are a single context's concept.

```js
// shared-count.mjs — how many changes and files a shared kernel forces two teams to edit together as it grows
import { LOG } from "./change-log.mjs";

const SMALL = {
  "unit-of-measure": ["core/unit.mjs", ["pricing", "delivery"]],
  "chargeable-weight-rule": ["core/unit.mjs", ["pricing", "delivery"]],
  "tariff-tier": ["pricing/tariff.mjs", ["pricing"]],
  "discount-rule": ["pricing/discount.mjs", ["pricing"]],
  "contract-threshold": ["pricing/discount.mjs", ["pricing"]],
  "state-set": ["delivery/state.mjs", ["delivery"]],
  "vehicle-selection": ["delivery/vehicle.mjs", ["delivery"]],
  "transfer-rule": ["delivery/route.mjs", ["delivery"]],
};

const LARGE = {
  ...SMALL,
  "tariff-tier": ["core/tariff.mjs", ["pricing"]],
  "state-set": ["core/state.mjs", ["delivery"]],
};

for (const [name, mapping] of [["small kernel", SMALL], ["large kernel", LARGE]]) {
  const core = new Map();
  for (const [file, users] of Object.values(mapping)) {
    if (file.startsWith("core/")) core.set(file, users);
  }
  const sharedChanges = LOG.filter(([, k]) => mapping[k][0].startsWith("core/"));
  const touched = new Set(sharedChanges.map(([, k]) => mapping[k][0]));
  const singleUseCore = [...core].filter(([, users]) => users.length === 1);
  console.log(`${name}: ${core.size} core file(s)`);
  for (const [file, users] of [...core].sort()) {
    console.log(`  ${file.padEnd(20)} used by context: ${users.join(", ")}`);
  }
  console.log(`  changes requiring joint decision = ${sharedChanges.length} / ${LOG.length}`);
  console.log(`  files the two teams edit together = ${touched.size}`);
  console.log(`  core files used by a single context = ${singleUseCore.length} / ${core.size}`);
  for (const b of ["pricing", "delivery"]) {
    const independent = LOG.filter(([, k]) => !mapping[k][0].startsWith("core/") && mapping[k][1].includes(b));
    console.log(`  ${b.padEnd(14)} changes it can make alone = ${independent.length}`);
  }
}
```

```sh
node shared-count.mjs
```

```
small kernel: 1 core file(s)
  core/unit.mjs        used by context: pricing, delivery
  changes requiring joint decision = 4 / 16
  files the two teams edit together = 1
  core files used by a single context = 0 / 1
  pricing        changes it can make alone = 7
  delivery       changes it can make alone = 5
large kernel: 3 core file(s)
  core/state.mjs       used by context: delivery
  core/tariff.mjs      used by context: pricing
  core/unit.mjs        used by context: pricing, delivery
  changes requiring joint decision = 11 / 16
  files the two teams edit together = 3
  core files used by a single context = 2 / 3
  pricing        changes it can make alone = 3
  delivery       changes it can make alone = 2
```

In the small kernel, four of sixteen changes require a joint decision; twelve are changes a
team can make alone. Once the kernel grows from one file to three, the changes requiring a
joint decision rise from four to eleven, and the changes the two teams can make alone drop
from twelve to five. The joint-ownership rate has risen from 25 percent to 69 percent.

One of the last lines shows the decision's wrong side: two of the three files in the large
kernel are used by only a single context. `core/tariff.mjs` is pricing's concept alone,
`core/state.mjs` is delivery's concept alone; both sit under joint ownership and both wait on
approval from a team that does not own them. The kernel's criterion follows from this: a file
belongs in the kernel only if both contexts genuinely use it.

#### What Belongs in the Kernel

The two concepts in the small kernel are not there by accident. The unit of measure and the
rule converting volumetric weight into chargeable weight say the same thing in both contexts:
pricing uses it to compute a fee, delivery uses it to select a vehicle, but the definition is
single and both sides must arrive at the same number. Keeping two definitions here is not a
gain; it is a source of inconsistency.

The tariff tier and the state set are not like that. Where the tiers fall is pricing's
decision; delivery has no need to know it. Which values the state set carries is delivery's
decision; for pricing, a shipment is either priced or it is not. Pulling these two concepts
into the kernel is not a modeling decision — it is the convenience of leaving a file in the
middle.

The criterion can be written in one sentence: only a concept that both contexts use with the
same meaning, and that would produce an inconsistency if defined separately, belongs in the
kernel.

#### Conformist

The second pattern comes from an entirely different situation. The downstream team has no
power to change the upstream's model; the upstream makes its own decisions on its own, and
downstream's needs do not enter its plan. In this situation, downstream has two options:
write a translation, or adopt the upstream's model as it is. The second is the **conformist**
pattern.

The upstream is the pricing context, and it gives the tariff line under its own names.

```sh
mkdir -p upstream conformist translated
```

```js
// upstream/tariff.mjs — upstream: the pricing context's tariff model
const TIERS = [
  { maxChargeableGrams: 1000, tierFeeCents: 4990 },
  { maxChargeableGrams: 5000, tierFeeCents: 8490 },
  { maxChargeableGrams: 30000, tierFeeCents: 24990 },
];
const ZONE_FACTOR = { near: 1, mid: 1.35, far: 1.8 };

export function tariffLine(chargeableGrams, feeZone) {
  const t = TIERS.find((x) => chargeableGrams <= x.maxChargeableGrams);
  return t === undefined ? null
    : { tierFeeCents: t.tierFeeCents, zoneFactor: ZONE_FACTOR[feeZone] ?? 1.8 };
}
```

The downstream is the delivery context, and it needs the shipment's transport value to
select a vehicle. In the conformist arrangement, it writes a single file and uses the
upstream's names directly.

```js
// conformist/vehicle.mjs — downstream adopts the upstream's model as it is
import { tariffLine } from "../upstream/tariff.mjs";

export function selectVehicle(chargeableGrams, feeZone) {
  const t = tariffLine(chargeableGrams, feeZone);
  if (t === null) return "hand-delivery";
  const amount = t.tierFeeCents * t.zoneFactor;
  if (amount >= 30000) return "box-truck";
  return chargeableGrams > 5000 ? "van" : "motorcycle";
}
```

In the translated arrangement, the same work is split across two files; the boundary file
translates the upstream's names into delivery's language.

```js
// translated/transport-translation.mjs — translates the upstream names into transport's own language
import { tariffLine } from "../upstream/tariff.mjs";

export function transportTerms(chargeableGrams, feeZone) {
  const t = tariffLine(chargeableGrams, feeZone);
  if (t === null) return { isPriceable: false, valueCents: 0, loadGrams: chargeableGrams };
  return { isPriceable: true, valueCents: t.tierFeeCents * t.zoneFactor, loadGrams: chargeableGrams };
}
```

```js
// translated/vehicle.mjs — downstream core: speaks only its own language
import { transportTerms } from "./transport-translation.mjs";

export function selectVehicle(loadGrams, zone) {
  const k = transportTerms(loadGrams, zone);
  if (!k.isPriceable) return "hand-delivery";
  if (k.valueCents >= 30000) return "box-truck";
  return k.loadGrams > 5000 ? "van" : "motorcycle";
}
```

#### The Gain and Cost of Conformity

The measure consists of three numbers: the file and line count, whether the upstream's names
appear in downstream's core, and how many names remain in downstream's own vocabulary.

```js
// conformity-count.mjs — file, line, and upstream-name count for the conformist vs. translated arrangement
import { readFileSync } from "node:fs";
import { selectVehicle as conformistSelect } from "./conformist/vehicle.mjs";
import { selectVehicle as translatedSelect } from "./translated/vehicle.mjs";

const UPSTREAM_NAMES = ["tariffLine", "tierFeeCents", "zoneFactor", "feeZone", "chargeableGrams"];
const DOWNSTREAM_NAMES = ["isPriceable", "valueCents", "loadGrams", "zone"];
const present = (text, names) => names.filter((a) => new RegExp(`\\b${a}\\b`).test(text));

for (const [name, files] of [
  ["conformist", ["conformist/vehicle.mjs"]],
  ["translated", ["translated/transport-translation.mjs", "translated/vehicle.mjs"]],
]) {
  const lines = files.reduce((t, d) => t + readFileSync(d, "utf8").trimEnd().split("\n").length, 0);
  console.log(`${name}: ${files.length} file(s), ${lines} lines`);
  let core = 0, leakingCore = 0;
  for (const d of files) {
    const text = readFileSync(d, "utf8");
    const up = present(text, UPSTREAM_NAMES), own = present(text, DOWNSTREAM_NAMES);
    const boundary = d.includes("-translation.mjs");
    if (!boundary) { core += 1; if (up.length > 0) leakingCore += 1; }
    console.log(`  ${d.padEnd(40)} upstream name ${up.length}, own name ${own.length}`);
  }
  console.log(`  core files carrying an upstream name = ${leakingCore} / ${core}`);
}

const EXAMPLES = [[800, "mid"], [4000, "far"], [20000, "near"], [20000, "far"], [50000, "mid"]];
let differing = 0;
for (const [grams, zone] of EXAMPLES) {
  const a = conformistSelect(grams, zone), b = translatedSelect(grams, zone);
  if (a !== b) differing += 1;
  console.log(`${String(grams).padStart(5)} g  ${zone.padEnd(5)} conformist ${a.padEnd(13)} translated ${b}`);
}
console.log(`differing vehicle = ${differing} / ${EXAMPLES.length}`);
```

```sh
node conformity-count.mjs
```

```
conformist: 1 file(s), 10 lines
  conformist/vehicle.mjs                   upstream name 5, own name 0
  core files carrying an upstream name = 1 / 1
translated: 2 file(s), 17 lines
  translated/transport-translation.mjs     upstream name 5, own name 3
  translated/vehicle.mjs                   upstream name 0, own name 4
  core files carrying an upstream name = 0 / 1
  800 g  mid   conformist motorcycle    translated motorcycle
 4000 g  far   conformist motorcycle    translated motorcycle
20000 g  near  conformist van           translated van
20000 g  far   conformist box-truck     translated box-truck
50000 g  mid   conformist hand-delivery translated hand-delivery
differing vehicle = 0 / 5
```

The gain is concrete: one file and seven lines fewer. The cost is concrete too. In the
conformist arrangement, downstream's one core file carries five of the upstream's names and
carries none of its own vocabulary (own name 0). In the translated arrangement, the core file
carries not a single name from upstream and carries four of its own. The vehicle selected in
all five examples is the same in both arrangements.

This is not enough to read the conformist pattern as a mistake. If downstream genuinely finds
the upstream's model fitting, a translation is just a file mapping two names to each other,
and its cost buys nothing back. The pattern is written as a choice: downstream gives up the
right to build its own model in exchange for contract conformity. What is wrong is making
that trade without realizing it — then downstream's own vocabulary never gets built, and the
business-rule file gets edited every time the upstream changes.

#### What the Two Patterns Share

A shared kernel and a conformist give up the same thing: translation. The difference is what
is received in exchange for giving it up. In a shared kernel, both sides give up part of
their decision rights and receive a single definition in return; its measure is the rate of
changes requiring a joint decision. In a conformist arrangement, only one side gives
something up, receiving files and lines in return; its measure is the number of names left in
downstream's own vocabulary. Both are bought at a cost, and both are countable.

#### Summary

- A shared kernel is a piece of the model that two contexts jointly own; every change
  touching the kernel requires both teams' decision.
- Across sixteen changes, once the kernel grew from one file to three, changes requiring a
  joint decision rose from 4/16 to 11/16; changes the teams could make alone dropped from 12
  to 5.
- Two of the three files in the large kernel were used by only a single context; only a
  concept that both contexts use with the same meaning, and that would produce an
  inconsistency if defined separately, belongs in the kernel.
- Conformist means downstream gives up translation and adopts the upstream's model as it is;
  its gain came to 1 file and 7 lines.
- Its cost showed up on the same scale: in the conformist arrangement, downstream's core
  carried 5 names from upstream and 0 of its own; in the translated arrangement, those
  numbers were 0 and 4. The vehicle selected in all five examples was the same in both
  arrangements.
- Both patterns give up translation; a shared kernel receives a single definition in return,
  a conformist receives files and lines in return.

#### Next Step

Up to this point, the boundaries of contexts have been drawn, the relationships between them
named, and the cost of three collaboration forms counted. All of these decisions left one
question unanswered: how much design effort should go to which context? Is the
unit-of-measure definition modeled with the same care as the fee rule; does the carrier
provider's state translation belong in the same class as the discount rule? The domain is not
equal within itself: part of it is the work the organization wins by, part keeps that work
standing, and part is the same in every organization. The next lesson builds this three-way
distinction, extracts how much each part actually changed from the change log, and compares
the distribution of effort against the distribution of change to count the effort spent in
the wrong place.

### Subdomains

Source: https://academia.sh/en/courses/domain-driven-design/subdomains

Separating core, supporting, and generic subdomains: establishing the difference between a subdomain and the domain layer, deriving the effort share per subdomain from line and invariant counts, computing the change share from the change log, and measuring how effort and change align once the generic subdomain is thinned.

The previous four lessons drew the boundaries of contexts, named the relationships between
them, and counted the cost of three collaboration forms. All of these decisions left one
question unanswered: how much design effort should go to which part? Is the unit-of-measure
definition modeled with the same care as the fee rule? Is the postal-code format check
written with the same refinement as the contracted discount rule?

The domain is not equal within itself. Part of it is the work the organization wins by, part
keeps that work standing, and part is the same in every organization. These three parts are
called **subdomains**, and the distinction is not a classification exercise but a decision
about where to put effort. This lesson measures effort with line and invariant counts,
measures need with the change log, and derives the ratio of the two.

#### Subdomain, Domain Layer, and Bounded Context

The three concepts carry similar-sounding names; the distinction needs to be made up front.

The **domain layer** (established in the Data Access Layer and Business Logic course) is a
place in the code's organization: the layer where business rules sit, indifferent to
presentation and infrastructure. A **subdomain** is not in the code but in the work itself: a
portion of what the organization does. A subdomain's code is usually scattered across several
layers — pricing's rule sits in the domain layer, reading the tariff table sits in the
infrastructure layer — and a single domain layer can house the model of more than one
subdomain.

A **bounded context**, by contrast, sits on the solution side: the area where a model holds.
A subdomain is a piece of the problem, a bounded context a piece of the solution. Mapping the
two one-to-one is a good target, but not a requirement; one context can carry the model of
two subdomains, and one subdomain can spread across two contexts. What this lesson measures
is not the context boundary but the effort spent per subdomain.

#### Three Types

A **core subdomain** is where the organization sets itself apart from competitors. In the
pricing library, that is the fee rule and the contracted discount rule: the organization wins
the work with these two rules, both are the organization's own decision, and neither has a
counterpart outside.

A **supporting subdomain** is necessary for the work but makes no difference. Deriving the
delivery window from the service level is like this: without it the work does not run, but no
one chooses this organization because of it.

A **generic subdomain** is where every organization is the same. Validating the postal-code
format and cents arithmetic are like this. The distinguishing criterion is this: if someone
else's solution can be taken as it is, that part is a generic subdomain.

The decision behind the distinction is where effort goes: the core subdomain gets the most
modeling effort, the generic subdomain the least. This is not a preference but a measurable
alignment — and in practice it is often inverted.

#### A Thickly Modeled Generic Subdomain

The two files below show a carefully modeled generic subdomain: the postal code is built as a
value object, carries five invariants, and has equality comparison and string-conversion
methods. Money is the same: four invariants, a currency field, arithmetic methods.

```sh
mkdir -p domain generic-thick generic-thin
```

```js
// generic-thick/postal-code.mjs — generic subdomain, thickly modeled: the postal code value
const PROVINCE_PREFIXES = new Set(["01", "06", "34", "35", "65", "81"]);

export class PostalCode {
  #text;
  constructor(text) {
    const violations = PostalCode.validate(text);
    if (violations.length > 0) throw new RangeError(`invalid postal code: ${violations.join(",")}`);
    this.#text = String(text).trim();
  }
  static validate(text) {
    const violations = [];
    const d = typeof text === "string" ? text.trim() : "";
    if (d.length === 0) violations.push("G1-empty");
    if (d.length !== 5) violations.push("G2-length");
    if (/\D/.test(d)) violations.push("G3-digit");
    if (d.startsWith("00")) violations.push("G4-zero-prefix");
    if (d.length === 5 && !/\D/.test(d) && !PROVINCE_PREFIXES.has(d.slice(0, 2))) violations.push("G5-province");
    return violations;
  }
  get provincePrefix() { return this.#text.slice(0, 2); }
  toString() { return this.#text; }
  equals(other) { return other instanceof PostalCode && other.toString() === this.#text; }
}

export const provincePrefix = (text) => (PostalCode.validate(text).length > 0 ? null : new PostalCode(text).provincePrefix);
```

```js
// generic-thick/money.mjs — generic subdomain, thickly modeled: the money value
export class Money {
  #cents; #currency;
  constructor(cents, currency = "TRY") {
    const violations = Money.validate(cents, currency);
    if (violations.length > 0) throw new RangeError(`invalid money: ${violations.join(",")}`);
    this.#cents = cents; this.#currency = currency;
  }
  static validate(cents, currency) {
    const violations = [];
    if (!Number.isInteger(cents)) violations.push("G1-integer");
    if (cents < 0) violations.push("G2-negative");
    if (cents > 1e12) violations.push("G3-overflow");
    if (currency !== "TRY") violations.push("G4-currency");
    return violations;
  }
  multiply(rate) { return new Money(Math.round(this.#cents * rate), this.#currency); }
  round(step) { return new Money(Math.round(this.#cents / step) * step, this.#currency); }
  get cents() { return this.#cents; }
}

export const multiply = (cents, rate) => new Money(cents).multiply(rate).cents;
export const round = (cents, step) => new Money(cents).round(step).cents;
```

It would be wrong to say either file is poorly written. Both are proper value objects, their
invariants are explicit, and they compare by value rather than identity. The problem is not
quality; it is place.

#### Core and Supporting

The core subdomain's two files take the generic subdomain from outside: they call prefix
extraction, multiplication, and rounding through an object named `generic`. This lets both
generic versions be tested against the same rule.

```js
// domain/fee-rule.mjs — core subdomain: the rule the organization wins the work by
const TIERS = [
  { maxChargeableGrams: 1000, feeCents: 4990 },
  { maxChargeableGrams: 5000, feeCents: 8490 },
  { maxChargeableGrams: 30000, feeCents: 24990 },
];
const ZONE_FACTOR = { "01": 1.35, "06": 1.35, "34": 1, "35": 1.35, "65": 1.8, "81": 1.8 };
const MINIMUM_FEE_CENTS = 3990;

export function validate(shipment) {
  const violations = [];
  if (!(Number.isInteger(shipment.chargeableGrams) && shipment.chargeableGrams > 0)) violations.push("C1-chargeable-weight");
  if (shipment.chargeableGrams > 30000) violations.push("C2-tier-exceeded");
  if (typeof shipment.postalCode !== "string") violations.push("C3-address");
  return violations;
}

export function calculateFee(shipment, generic) {
  if (validate(shipment).length > 0) return null;
  const prefix = generic.provincePrefix(shipment.postalCode);
  if (prefix === null) return null;
  const tier = TIERS.find((t) => shipment.chargeableGrams <= t.maxChargeableGrams);
  const raw = generic.multiply(tier.feeCents, ZONE_FACTOR[prefix]);
  return Math.max(generic.round(raw, 50), MINIMUM_FEE_CENTS);
}
```

```js
// domain/discount-rule.mjs — core subdomain: the contracted discount rule
const VOLUME_TIERS = [
  { monthlyShipments: 500, rate: 0.05 },
  { monthlyShipments: 2000, rate: 0.12 },
  { monthlyShipments: Infinity, rate: 0.2 },
];
const RATE_CAP = 0.3;

export function validate(contract) {
  const violations = [];
  if (!Number.isInteger(contract.monthlyShipments) || contract.monthlyShipments < 0) violations.push("C1-volume");
  if (!(contract.negotiatedRate >= 0 && contract.negotiatedRate <= RATE_CAP)) violations.push("C2-negotiated-rate");
  if (contract.negotiatedRate > 0 && contract.contractNo === null) violations.push("C3-negotiated-without-contract");
  return violations;
}

export function discountedFee(grossCents, contract, generic) {
  if (validate(contract).length > 0) return null;
  const tier = VOLUME_TIERS.find((t) => contract.monthlyShipments <= t.monthlyShipments);
  const rate = Math.min(tier.rate + contract.negotiatedRate, RATE_CAP);
  return generic.multiply(grossCents, 1 - rate);
}
```

```js
// domain/delivery-window.mjs — supporting subdomain: the delivery window from the service level
const WINDOW_HOURS = { SS1: 24, SS2: 48, SS3: 72 };

export function validate(serviceLevelCode) {
  const violations = [];
  if (typeof serviceLevelCode !== "string") violations.push("D1-type");
  if (WINDOW_HOURS[serviceLevelCode] === undefined) violations.push("D2-unknown-level");
  return violations;
}

export const windowHours = (serviceLevelCode) =>
  (validate(serviceLevelCode).length > 0 ? 72 : WINDOW_HOURS[serviceLevelCode]);
```

#### Thinning the Generic Subdomain

The thin version leaves only as much of the generic subdomain as the core genuinely needs:
prefix extraction and two arithmetic operations. There is no value object, no equality
comparison, no currency field, because the library never used them.

```js
// generic-thin/postal-code.mjs — generic subdomain, thin: only as much as the domain needs
const PROVINCE_PREFIXES = new Set(["01", "06", "34", "35", "65", "81"]);

export function provincePrefix(text) {
  const d = typeof text === "string" ? text.trim() : "";
  if (!/^\d{5}$/.test(d) || d.startsWith("00") || !PROVINCE_PREFIXES.has(d.slice(0, 2))) return null;
  return d.slice(0, 2);
}
```

```js
// generic-thin/money.mjs — generic subdomain, thin: only as much as the domain needs
const valid = (cents) => Number.isInteger(cents) && cents >= 0 && cents <= 1e12;

export const multiply = (cents, rate) => {
  if (!valid(cents)) throw new RangeError("invalid money");
  return Math.round(cents * rate);
};
export const round = (cents, step) => Math.round(cents / step) * step;
```

#### The Distribution of Effort and Change

Effort is measured with two indicators: line count per subdomain and the number of
invariants defined. Need is measured by how twenty model changes distribute across
subdomains. The third number is the ratio of the two, and one value is an exact threshold: if
effort share equals change share, the ratio is 1.

```js
// effort-count.mjs — effort share, change share, and their ratio per subdomain
import { readFileSync } from "node:fs";

const SUBDOMAIN = {
  "domain/fee-rule.mjs": "core", "domain/discount-rule.mjs": "core",
  "domain/delivery-window.mjs": "supporting",
  "generic-thick/postal-code.mjs": "generic", "generic-thick/money.mjs": "generic",
  "generic-thin/postal-code.mjs": "generic", "generic-thin/money.mjs": "generic",
};

const LOG = [
  ["fee-rule", 6], ["discount-rule", 5], ["delivery-window", 4],
  ["postal-code", 3], ["money", 2],
];
const CONCEPT_SUBDOMAIN = { "fee-rule": "core", "discount-rule": "core",
  "delivery-window": "supporting", "postal-code": "generic", "money": "generic" };
const TOTAL_CHANGES = LOG.reduce((t, [, n]) => t + n, 0);

const TYPES = ["core", "supporting", "generic"];
const percent = (a, b) => `${((a / b) * 100).toFixed(1).padStart(5)}%`;

for (const [name, files] of [
  ["thick generic", ["domain/fee-rule.mjs", "domain/discount-rule.mjs", "domain/delivery-window.mjs",
                      "generic-thick/postal-code.mjs", "generic-thick/money.mjs"]],
  ["thin generic", ["domain/fee-rule.mjs", "domain/discount-rule.mjs", "domain/delivery-window.mjs",
                     "generic-thin/postal-code.mjs", "generic-thin/money.mjs"]],
]) {
  const lines = {}, invariants = {};
  for (const t of TYPES) { lines[t] = 0; invariants[t] = 0; }
  for (const d of files) {
    const text = readFileSync(d, "utf8");
    const t = SUBDOMAIN[d];
    lines[t] += text.trimEnd().split("\n").length;
    invariants[t] += (text.match(/violations\.push/g) ?? []).length;
  }
  const totalLines = TYPES.reduce((s, t) => s + lines[t], 0);
  const totalInvariants = TYPES.reduce((s, t) => s + invariants[t], 0);
  console.log(`${name}: ${totalLines} lines, ${totalInvariants} invariants, ${TOTAL_CHANGES} changes`);
  for (const t of TYPES) {
    const chg = LOG.filter(([k]) => CONCEPT_SUBDOMAIN[k] === t).reduce((s, [, n]) => s + n, 0);
    const effortShare = lines[t] / totalLines, changeShare = chg / TOTAL_CHANGES;
    console.log(`  ${t.padEnd(12)} lines ${String(lines[t]).padStart(3)} (${percent(lines[t], totalLines)})`
      + `  invariants ${String(invariants[t]).padStart(2)} (${percent(invariants[t], totalInvariants)})`
      + `  change ${String(chg).padStart(2)} (${percent(chg, TOTAL_CHANGES)})`
      + `  effort/change ${(effortShare / changeShare).toFixed(2)}`);
  }
}
```

```sh
node effort-count.mjs
```

```
thick generic: 108 lines, 17 invariants, 20 changes
  core         lines  47 ( 43.5%)  invariants  6 ( 35.3%)  change 11 ( 55.0%)  effort/change 0.79
  supporting   lines  12 ( 11.1%)  invariants  2 ( 11.8%)  change  4 ( 20.0%)  effort/change 0.56
  generic      lines  49 ( 45.4%)  invariants  9 ( 52.9%)  change  5 ( 25.0%)  effort/change 1.81
thin generic: 75 lines, 8 invariants, 20 changes
  core         lines  47 ( 62.7%)  invariants  6 ( 75.0%)  change 11 ( 55.0%)  effort/change 1.14
  supporting   lines  12 ( 16.0%)  invariants  2 ( 25.0%)  change  4 ( 20.0%)  effort/change 0.80
  generic      lines  16 ( 21.3%)  invariants  0 (  0.0%)  change  5 ( 25.0%)  effort/change 0.85
```

The thick arrangement's first line gives the misalignment: 45.4 percent of the code is in the
generic subdomain, yet only 25 percent of the changes touch it. The core subdomain is in the
reverse situation: it takes 55 percent of the changes and holds 43.5 percent of the code. The
ratios are 1.81 and 0.79 — more than double apart.

The invariant count shows a sharper misalignment. The generic subdomain defined nine
invariants, the core subdomain six. For the two rules that win the organization its work, six
invariants were written, while for the postal code and money — the same in every
organization — nine were written.

In the thin arrangement, the three ratios fall into the 1.14–0.80–0.85 range; the generic
subdomain's line share drops from 45.4 percent to 21.3 percent. The generic subdomain no
longer defines a domain invariant: the two checks in the thin version are each a guard
clause, not a domain rule. Total code drops from 108 lines to 75, and the freed-up effort is
left for the core.

#### Is the Core Unchanged?

That thinning did not break the core is not asserted; it is tested. The same core rules run
against both generic versions.

```js
// run.mjs — did thinning the generic subdomain change the core's results?
import { calculateFee } from "./domain/fee-rule.mjs";
import { discountedFee } from "./domain/discount-rule.mjs";
import * as thickPostal from "./generic-thick/postal-code.mjs";
import * as thickMoney from "./generic-thick/money.mjs";
import * as thinPostal from "./generic-thin/postal-code.mjs";
import * as thinMoney from "./generic-thin/money.mjs";

const THICK = { provincePrefix: thickPostal.provincePrefix, multiply: thickMoney.multiply, round: thickMoney.round };
const THIN = { provincePrefix: thinPostal.provincePrefix, multiply: thinMoney.multiply, round: thinMoney.round };

const SHIPMENTS = [
  { chargeableGrams: 800, postalCode: "34710" }, { chargeableGrams: 4000, postalCode: "06800" },
  { chargeableGrams: 20000, postalCode: "65100" }, { chargeableGrams: 20000, postalCode: "99999" },
  { chargeableGrams: 0, postalCode: "34710" }, { chargeableGrams: 40000, postalCode: "34710" },
];
const CONTRACTS = [
  { monthlyShipments: 1500, negotiatedRate: 0.05, contractNo: "S-7" },
  { monthlyShipments: 100, negotiatedRate: 0, contractNo: null },
  { monthlyShipments: 100, negotiatedRate: 0.1, contractNo: null },
];

let differing = 0;
for (const s of SHIPMENTS) {
  const a = calculateFee(s, THICK), b = calculateFee(s, THIN);
  if (a !== b) differing += 1;
  console.log(`${String(s.chargeableGrams).padStart(5)} g ${s.postalCode}  thick ${String(a).padStart(5)}  thin ${String(b).padStart(5)}`);
}
for (const c of CONTRACTS) {
  const a = discountedFee(45000, c, THICK), b = discountedFee(45000, c, THIN);
  if (a !== b) differing += 1;
  console.log(`volume ${String(c.monthlyShipments).padStart(4)} negotiated ${c.negotiatedRate}  thick ${String(a).padStart(5)}  thin ${String(b).padStart(5)}`);
}
console.log(`differing result = ${differing} / ${SHIPMENTS.length + CONTRACTS.length}`);
```

```sh
node run.mjs
```

```
  800 g 34710  thick  5000  thin  5000
 4000 g 06800  thick 11450  thin 11450
20000 g 65100  thick 45000  thin 45000
20000 g 99999  thick  null  thin  null
    0 g 34710  thick  null  thin  null
40000 g 34710  thick  null  thin  null
volume 1500 negotiated 0.05  thick 37350  thin 37350
volume  100 negotiated 0  thick 42750  thin 42750
volume  100 negotiated 0.1  thick  null  thin  null
differing result = 0 / 9
```

The result matches in all nine cases: three valid fees, an unrecognized province prefix, zero
weight, a tier-exceeding weight, two discount calculations, and a negotiated-rate attempt
without a contract, all included. Four of the thick version's five invariants never paid off
in any call, because the core never exercised them.

#### The Limit of the Distinction

A subdomain's type is not a fixed label. What is generic for one organization can be core for
another; address resolution is not a generic subdomain for an organization that wins its
delivery business on address quality. The distinction is read not from the domain itself but
from where the organization competes, and it changes over time.

The second limit is in the measure itself. Line count is a crude indicator of effort; a
rule's difficulty does not grow with its line count. That is why the measure does not decide
anything by itself — it raises a question: why does the part that changes most carry the
least modeling?

#### Summary

- A subdomain is a portion of the work; the domain layer is a place in the code's
  organization, and a bounded context is the area where a model holds. A subdomain's code is
  scattered across several layers.
- Three types are distinguished: a core subdomain is where the organization wins its work, a
  supporting subdomain is necessary but makes no difference, and a generic subdomain is where
  someone else's solution can be taken as it is.
- In the thick arrangement, 45.4 percent of the code was in the generic subdomain, but only
  25 percent of the changes touched it; the core subdomain took 55 percent of the changes and
  held 43.5 percent of the code. The ratios were 1.81 and 0.79.
- The invariant count confirmed the misalignment: the generic subdomain had defined nine
  invariants, the core subdomain six.
- Once the generic subdomain was thinned, the three ratios fell into the 1.14–0.80–0.85
  range, total code dropped from 108 lines to 75, and the core's results were unchanged in
  all nine cases.
- A subdomain's type depends on the organization and changes over time; line count is a
  crude indicator of effort, and the measure does not decide — it raises a question.

#### Next Step

This topic established where a model holds: the boundary runs through the place a name's
meaning changes, the relationships between contexts are named by direction and type, the
outside model's leakage is stopped with a layer, and the subdomain distinction says how much
effort goes to which part. The question left open sits inside a context: how does the model
in that context fit inside an application? Where does a request enter, in what order does it
descend to which object, who draws the boundary of the transaction, who does the result
return to? In every example up to this point, this arrangement stayed implicit; rules were
called directly. The next topic defines the responsibility of the application layer, and its
first lesson takes use cases as that layer's unit: a thin shell that calls the domain model,
starts and ends the transaction, but does not carry the business rule itself.

## Connecting to the Application Architecture

Placing the domain model inside an application: defining the application layer's responsibility through use cases, separating the domain's outward connection points with adapters, the cost and gain of separating the write and read models, storing the event sequence instead of state, and the model's evolution together with the implementation.

### Use Cases

Source: https://academia.sh/en/courses/domain-driven-design/use-cases

Defining the application layer's responsibility through the use case: counting decision points across two versions of the same scenario — one that carries decisions in the application layer and one that pushes them down to the aggregate root — measuring how many places the domain expert's five sentences are written in the code, and testing the two versions' behavioral equality.

The Contexts topic established **where** a model holds: the boundary runs through the place a
name's meaning changes, contexts' relationships are named by direction and type, the outside
model's leakage is stopped with a layer, and the subdomain distinction says how much effort
goes to which part. The question left open sits **inside** a context: how does that context's
model fit inside an application? Where does a request enter, in what order does it descend to
which object, who draws the transaction's boundary, who does the result return to?

This topic sets up that arrangement, and its first lesson takes the **use case** as the
application layer's unit: a thin shell that calls the domain model, starts and ends the
transaction, but does not carry the business rule itself. Rules were called directly in every
example up to this point; now the question is what the caller is. This section's through-line
is the delivery operation context: `shipment` is the physical package with its route and
state timestamps, `route` is the sequence of transfer points, `delivery` is that package's
tracked state through the operation. The scenario at hand is a single sentence: record an
arrival at a transfer point.

#### Step Order and Decision

A **use case** is a single operation the application exposes to the outside, and it can hold
two things at once: the **order** of the steps and the **decisions** inside them. Order is a
technical arrangement — calling the repository, opening the transaction boundary, writing the
result, producing the contract returned to the caller. A decision is the domain's own
sentence.

Defining the application boundary in one place was established in the Design Patterns course's
service layer pattern; the layers' responsibilities and dependency direction were established
in the Data Access Layer and Business Logic course. What is new here is not the layer's
definition but a single question: which file holds the decisions inside the scenario?

The domain expert's five sentences about recording an arrival: an arrival is not processed
against a closed delivery; an arrival is processed only against a point on the route; the same
point does not count as passed twice; an arrival must match the route's order; a shipment
reaching the route's last point goes out for delivery. How many places in the code these five
sentences are written to is this lesson's alignment measure.

Both versions use the same persistence arrangement.

```js
// repository.mjs — in-memory repository holding delivery records; both arrangements use it
const TABLE = new Map();

export const deliveryRepository = {
  find: (id) => structuredClone(TABLE.get(id) ?? null),
  write: (record) => { TABLE.set(record.id, structuredClone(record)); },
};

export function seed(id) {
  TABLE.set(id, { id, state: "accepted", route: ["34", "06", "35"], passed: [] });
}
```

#### Decisions in the Application Layer

In the first version, the delivery record is a behaviorless pile of data; all five sentences
are written into the scenario's body.

```sh
mkdir -p decisions-up/application
```

```js
// decisions-up/application/record-arrival.mjs — the use case carries both order and decisions
import { deliveryRepository } from "../../repository.mjs";

export function recordArrival(id, point) {
  const t = deliveryRepository.find(id);
  if (t === null) throw new RangeError(`delivery not found: ${id}`);
  if (t.state === "delivered") throw new Error("cannot record arrival on a closed delivery");
  if (!t.route.includes(point)) throw new Error(`point not on route: ${point}`);
  if (t.passed.includes(point)) throw new Error(`point already passed: ${point}`);
  const order = t.route.indexOf(point);
  if (order !== t.passed.length) throw new Error("route order broken");
  t.passed.push(point);
  t.state = order === t.route.length - 1 ? "out-for-delivery" : "in-transfer";
  deliveryRepository.write(t);
  return { identity: t.id, state: t.state, passed: t.passed.length };
}
```

The application's second client batch-processes the arrival list the transfer center sends at
day's end. Because the decisions live in the scenario's body, the second client rewrites them.

```js
// decisions-up/application/batch-arrival.mjs — the second client rewrites the same decisions
import { deliveryRepository } from "../../repository.mjs";

export function batchArrival(inputs) {
  return inputs.map(({ id, point }) => {
    try {
      const t = deliveryRepository.find(id);
      if (t === null) throw new RangeError(`delivery not found: ${id}`);
      if (t.state === "delivered") throw new Error("cannot record arrival on a closed delivery");
      if (!t.route.includes(point)) throw new Error(`point not on route: ${point}`);
      if (t.passed.includes(point)) throw new Error(`point already passed: ${point}`);
      const order = t.route.indexOf(point);
      if (order !== t.passed.length) throw new Error("route order broken");
      t.passed.push(point);
      t.state = order === t.route.length - 1 ? "out-for-delivery" : "in-transfer";
      deliveryRepository.write(t);
      return { identity: t.id, state: t.state, passed: t.passed.length };
    } catch (error) { return { identity: id, error: error.message }; }
  });
}
```

The batch script is the single scenario's body wrapped inside a map call. What repeats is not
a line, but the domain's five sentences.

#### Decisions in the Domain Model

In the second version, the five sentences move into the **aggregate root**. Delivery is an
entity: it has an identity, its state changes over time, and the array of passed points
carries its invariant.

```sh
mkdir -p decisions-in-domain/domain decisions-in-domain/application
```

```js
// decisions-in-domain/domain/delivery.mjs — the same five decisions, inside the aggregate root
export class Delivery {
  #id; #route; #passed; #state;

  constructor(record) {
    this.#id = record.id; this.#route = [...record.route];
    this.#passed = [...record.passed]; this.#state = record.state;
  }

  recordArrival(point) {
    if (this.#state === "delivered") throw new Error("cannot record arrival on a closed delivery");
    if (!this.#route.includes(point)) throw new Error(`point not on route: ${point}`);
    if (this.#passed.includes(point)) throw new Error(`point already passed: ${point}`);
    const order = this.#route.indexOf(point);
    if (order !== this.#passed.length) throw new Error("route order broken");
    this.#passed.push(point);
    this.#state = order === this.#route.length - 1 ? "out-for-delivery" : "in-transfer";
  }

  toRecord() { return { id: this.#id, state: this.#state, route: [...this.#route], passed: [...this.#passed] }; }
  toSummary() { return { identity: this.#id, state: this.#state, passed: this.#passed.length }; }
}
```

Four steps remain in the scenario: read the record, tell the aggregate, write the result,
return the contract.

```js
// decisions-in-domain/application/record-arrival.mjs — the use case carries only order now
import { deliveryRepository } from "../../repository.mjs";
import { Delivery } from "../domain/delivery.mjs";

export function recordArrival(id, point) {
  const record = deliveryRepository.find(id);
  if (record === null) throw new RangeError(`delivery not found: ${id}`);
  const delivery = new Delivery(record);
  delivery.recordArrival(point);
  deliveryRepository.write(delivery.toRecord());
  return delivery.toSummary();
}
```

```js
// decisions-in-domain/application/batch-arrival.mjs — the second client only adds a loop
import { recordArrival } from "./record-arrival.mjs";

export function batchArrival(inputs) {
  return inputs.map(({ id, point }) => {
    try { return recordArrival(id, point); }
    catch (error) { return { identity: id, error: error.message }; }
  });
}
```

The second client's real difference from the single case is now visible: a loop and an error
shape. It knows nothing about the five sentences.

#### Measurement

The measurement produces three numbers: decision points per file — the count of branch markers
(`if`, the ternary operator, `catch`); sites per rule — how many files each of the five
sentences appears in; and a run result — do the two versions give the same result on the same
operation sequence?

```js
// decision-count.mjs — decision points, the number of files each rule is written in, and the two arrangements' behavior equality
import { readFileSync } from "node:fs";
import { deliveryRepository, seed } from "./repository.mjs";
import { batchArrival as aboveBatch } from "./decisions-up/application/batch-arrival.mjs";
import { batchArrival as domainBatch } from "./decisions-in-domain/application/batch-arrival.mjs";

const BRANCH = /\bif \(|\bcatch \(| \? /g;
const RULE = {
  "closed-delivery": /=== "delivered"/,
  "point-on-route": /route\.includes\(point\)/,
  "point-repeat": /passed\.includes\(point\)/,
  "route-order": /order !== /,
  "final-point": /"out-for-delivery"/,
};
const ABOVE = ["decisions-up/application/record-arrival.mjs", "decisions-up/application/batch-arrival.mjs"];
const IN_DOMAIN = ["decisions-in-domain/application/record-arrival.mjs", "decisions-in-domain/application/batch-arrival.mjs",
  "decisions-in-domain/domain/delivery.mjs"];

function scan(name, files) {
  console.log(name);
  const texts = files.map((f) => [f, readFileSync(f, "utf8")]);
  let applicationDecisions = 0, domainDecisions = 0;
  for (const [f, m] of texts) {
    const branches = (m.match(BRANCH) ?? []).length;
    if (f.includes("/application/")) applicationDecisions += branches; else domainDecisions += branches;
    console.log(`  ${f.padEnd(52)} decision points = ${branches}`);
  }
  console.log(`  application layer decisions = ${applicationDecisions}, domain module decisions = ${domainDecisions}`);
  let sites = 0;
  for (const [k, pattern] of Object.entries(RULE)) {
    const where = texts.filter(([, m]) => pattern.test(m)).map(([f]) => f.split("/").at(-1));
    sites += where.length;
    console.log(`  rule ${k.padEnd(16)} in ${where.length} place(s) (${where.join(", ")})`);
  }
  console.log(`  total sites for the five rules = ${sites}`);
}

scan("decisions in the application layer", ABOVE);
scan("decisions in the domain model", IN_DOMAIN);

const OPERATIONS = (id) => [
  { id, point: "34" }, { id, point: "34" }, { id, point: "35" }, { id, point: "99" },
  { id, point: "06" }, { id, point: "35" }, { id: "T-MISSING", point: "34" },
];
seed("T-A"); seed("T-B");
const a = aboveBatch(OPERATIONS("T-A"));
const b = domainBatch(OPERATIONS("T-B"));
const normalize = (r, id) => JSON.stringify(r).replaceAll(id, "T");
let diverged = 0;
for (let i = 0; i < a.length; i += 1) {
  const x = normalize(a[i], "T-A"), y = normalize(b[i], "T-B");
  if (x !== y) diverged += 1;
  console.log(`${String(i).padStart(2)} ${x}`);
}
console.log(`diverged result = ${diverged} / ${a.length}`);
console.log(`final record = ${JSON.stringify(deliveryRepository.find("T-B"))}`);
```

```sh
node decision-count.mjs
```

```
decisions in the application layer
  decisions-up/application/record-arrival.mjs          decision points = 6
  decisions-up/application/batch-arrival.mjs           decision points = 7
  application layer decisions = 13, domain module decisions = 0
  rule closed-delivery  in 2 place(s) (record-arrival.mjs, batch-arrival.mjs)
  rule point-on-route   in 2 place(s) (record-arrival.mjs, batch-arrival.mjs)
  rule point-repeat     in 2 place(s) (record-arrival.mjs, batch-arrival.mjs)
  rule route-order      in 2 place(s) (record-arrival.mjs, batch-arrival.mjs)
  rule final-point      in 2 place(s) (record-arrival.mjs, batch-arrival.mjs)
  total sites for the five rules = 10
decisions in the domain model
  decisions-in-domain/application/record-arrival.mjs   decision points = 1
  decisions-in-domain/application/batch-arrival.mjs    decision points = 1
  decisions-in-domain/domain/delivery.mjs              decision points = 5
  application layer decisions = 2, domain module decisions = 5
  rule closed-delivery  in 1 place(s) (delivery.mjs)
  rule point-on-route   in 1 place(s) (delivery.mjs)
  rule point-repeat     in 1 place(s) (delivery.mjs)
  rule route-order      in 1 place(s) (delivery.mjs)
  rule final-point      in 1 place(s) (delivery.mjs)
  total sites for the five rules = 5
 0 {"identity":"T","state":"in-transfer","passed":1}
 1 {"identity":"T","error":"point already passed: 34"}
 2 {"identity":"T","error":"route order broken"}
 3 {"identity":"T","error":"point not on route: 99"}
 4 {"identity":"T","state":"in-transfer","passed":2}
 5 {"identity":"T","state":"out-for-delivery","passed":3}
 6 {"identity":"T-MISSING","error":"delivery not found: T-MISSING"}
diverged result = 0 / 7
final record = {"id":"T-B","state":"out-for-delivery","route":["34","06","35"],"passed":["34","06","35"]}
```

#### Reading the Numbers

The application layer's decision points dropped from 13 to 2; the domain module gained 5. The total
falling from 13 to 7 is because duplication is gone, not decisions. The 2 remaining decision points
are the branch itself: one checks that the record was not found, the other traps every error the
scenario can raise, including that one.

The five sentences' total sites dropped from 10 to 5, the ratio from 2.0 to 1.0 — the lesson's
alignment measure: a sentence living in more than one place means the two places drifting
apart is a version problem, not a design problem. The growth shape is clear too: with `n`
clients each running the scenario alone, sites total `5n`; with decisions in the aggregate
root, a constant 5. A sixth sentence edits `n` files in the first arrangement, 1 in the second.

The last seven lines show behavior was preserved: the same operation sequence produced the
same result in both versions — three valid arrivals, three rule violations, one identity not
found. The diverged result is 0, and the final record confirms all three points were passed
and the state returned out for delivery.

#### Decisions That Stay in the Application Layer

Reading the measure as "decision points should go to zero" would be wrong. The application
layer has decisions of its own, not the domain model's, and pushing them down makes the domain
model start knowing things it should not.

The record not being found is one of these: not a domain rule but a lookup result, given
before the delivery object exists. Where the transaction boundary opens and closes, caller
authorization, the order several aggregates are read in, which fields the outward contract
carries — these too are application layer decisions. None appear in the domain expert's
sentence: the expert says "an arrival is not processed against a closed delivery," not "open
the transaction here."

In practice the distinction is one question: does this decision come from the domain's
vocabulary, or from the application's implementation arrangement? The first case goes down to
the aggregate root; the second stays in the scenario.

#### Summary

- A use case can hold step order and decision together; order belongs to the application, and
  decision belongs to the aggregate root once it belongs to the domain's vocabulary.
- While decisions lived in the scenario, the application layer's decision points were 13 and
  the domain module's were 0; once decisions moved to the aggregate root, the numbers became
  2 and 5.
- The domain expert's five sentences' total sites dropped from 10 to 5, and the sites-per-
  sentence ratio fell from 2.0 to 1.0; in the first arrangement this number grows with the
  client count as `5n`.
- All seven operations gave the same result in both versions; the change did not alter
  transport behavior, only which file the decision lives in.
- A lookup result, the transaction boundary, authorization, and the outward contract are the
  application layer's decisions; the test is whether they appear in the domain expert's
  sentence.

#### Next Step

The decisions moved down to the aggregate root, but one of the scenario's four steps still
carries an outside-world name: `deliveryRepository`. This will not stay a single instance:
recording an arrival wants the carrier's own point code read, wants an arrival number
generated, wants a notice sent to the transfer center.

If each of these names enters the domain module as a direct link, the domain model starts
knowing the outside; its import closure grows and testing the model becomes tied to the
outside world being ready. The next lesson names every place the domain opens to the outside
as an explicit port, keeps adapters outside, and compares — across both arrangements — the
number of direct links from the domain module to the outside world and the number of files
an adapter change edits.

### Ports and Adapters

Source: https://academia.sh/en/courses/domain-driven-design/ports-and-adapters

Presenting hexagonal architecture as an arrangement: turning the places the domain opens to the outside into named ports, counting the domain module's import closure and its direct links to the outside world across two arrangements, running the same domain file with two adapter teams, and reporting an unsatisfied port at run time.

The previous lesson's scenario pushed decisions down to the aggregate root, but one of its
steps still carried an outside-world name. Recording an arrival, in its real form, does not
settle for a single repository either: the carrier's own point code is read, an arrival number
is generated, a notice is sent to the transfer center. If these three names enter the domain
module as direct links, the domain model starts knowing the outside.

What this lesson measures is the count of those links. Building the measure first requires
naming what the outside is. A **port** is the name of a place the domain opens to the outside
and the set of operations it expects; an **adapter** is the implementation that satisfies that
set. More than one adapter can satisfy the same port. This name is the same word as the
transport-layer port in Computer Networks, but it is not related: there it is a number, here
it is a contract for a set of operations.

#### What Is New

Where the boundary is drawn, the distinction between a stable rule and a variable detail, and
the dependency arrow's direction were established and measured in the Design Principles
course's Policy and Detail Separation and Drawing Boundaries lessons; the layers'
responsibilities were established in the Data Access Layer and Business Logic course's Layer
Responsibilities lesson. The adapter pattern itself was covered in Design Patterns. None of
this is re-established here.

The only thing that is new is an **arrangement**: when the same principles are placed so that
they put the domain at the center and turn every place it opens to the outside into a named
point, the resulting layout is called **hexagonal architecture**. The number six has no
meaning; it was chosen to say the shape does not have only two faces, that is, there are not
two sides called input and output. The number of points can just as well be three or nine.

#### The Outside

Three external modules represent the three separate worlds the domain has to talk to.

```sh
mkdir -p external closed/domain closed/application
```

```js
// external/record-table.mjs — external table holding delivery records
const TABLE = new Map();

export const recordTable = {
  read: (id) => structuredClone(TABLE.get(id) ?? null),
  write: (record) => { TABLE.set(record.id, structuredClone(record)); },
  seed: (id) => { TABLE.set(id, { id, state: "accepted", route: ["34", "06", "35"], passed: [] }); },
};
```

```js
// external/tracking-service.mjs — external system giving the carrier's own point codes and arrival number
const CODE = { "34": "IST-A1", "06": "ANK-T2", "35": "IZM-D7" };
let counter = 0;

export const trackingService = {
  pointCode: (n) => CODE[n] ?? "UNKNOWN",
  arrivalNumber: () => `V${String((counter += 1)).padStart(4, "0")}`,
};
```

```js
// external/notification-channel.mjs — external channel sending a notice to the transfer center
const SENT = [];

export const notificationChannel = { send: (m) => SENT.push(m), count: () => SENT.length };
```

#### The Arrangement That Wires the Link Directly

In the first arrangement, the aggregate root imports the three modules itself. The decisions
still live inside it; the only thing that changes is how it talks to the outside.

```js
// closed/domain/delivery.mjs — the aggregate root imports the three external modules directly
import { recordTable } from "../../external/record-table.mjs";
import { trackingService } from "../../external/tracking-service.mjs";
import { notificationChannel } from "../../external/notification-channel.mjs";

export class Delivery {
  #record;

  static load(id) {
    const record = recordTable.read(id);
    if (record === null) throw new RangeError(`delivery not found: ${id}`);
    return new Delivery(record);
  }

  constructor(record) { this.#record = record; }

  recordArrival(point) {
    const r = this.#record;
    if (r.state === "delivered") throw new Error("cannot record arrival on a closed delivery");
    if (!r.route.includes(point)) throw new Error(`point not on route: ${point}`);
    const order = r.route.indexOf(point);
    if (order !== r.passed.length) throw new Error("route order broken");
    r.passed.push(point);
    r.state = order === r.route.length - 1 ? "out-for-delivery" : "in-transfer";
    const number = trackingService.arrivalNumber(), code = trackingService.pointCode(point);
    recordTable.write(r);
    notificationChannel.send({ number, id: r.id, code, state: r.state });
    return { identity: r.id, number, code, state: r.state, passed: r.passed.length };
  }
}
```

```js
// closed/application/record-arrival.mjs — the scenario has nothing left to provide
import { Delivery } from "../domain/delivery.mjs";

export const recordArrival = (id, point) => Delivery.load(id).recordArrival(point);
```

The scenario shrank to one line, because it has nothing left to give. All the links were
wired one level down.

#### Naming the Ports

In the second arrangement, the domain side writes the names it expects into a file. This file
belongs to the domain: the stable side writes the contract, and the side that satisfies it
conforms.

```sh
mkdir -p open/domain open/application open/adapters
```

```js
// open/domain/ports.mjs — the three points the domain opens to the outside, and the five names it expects
export const PORTS = {
  records: ["read", "write"],
  tracking: ["pointCode", "arrivalNumber"],
  notifications: ["send"],
};

export function validateEnvironment(environment) {
  const missing = [];
  for (const [port, names] of Object.entries(PORTS)) {
    for (const name of names) if (typeof environment?.[port]?.[name] !== "function") missing.push(`${port}.${name}`);
  }
  if (missing.length > 0) throw new TypeError(`unsatisfied port: ${missing.join(", ")}`);
}
```

```js
// open/domain/delivery.mjs — the same five decisions; no external module name, environment arrives as a parameter
import { validateEnvironment } from "./ports.mjs";

export class Delivery {
  #record; #environment;

  static load(id, environment) {
    validateEnvironment(environment);
    const record = environment.records.read(id);
    if (record === null) throw new RangeError(`delivery not found: ${id}`);
    return new Delivery(record, environment);
  }

  constructor(record, environment) { this.#record = record; this.#environment = environment; }

  recordArrival(point) {
    const r = this.#record, e = this.#environment;
    if (r.state === "delivered") throw new Error("cannot record arrival on a closed delivery");
    if (!r.route.includes(point)) throw new Error(`point not on route: ${point}`);
    const order = r.route.indexOf(point);
    if (order !== r.passed.length) throw new Error("route order broken");
    r.passed.push(point);
    r.state = order === r.route.length - 1 ? "out-for-delivery" : "in-transfer";
    const number = e.tracking.arrivalNumber(), code = e.tracking.pointCode(point);
    e.records.write(r);
    e.notifications.send({ number, id: r.id, code, state: r.state });
    return { identity: r.id, number, code, state: r.state, passed: r.passed.length };
  }
}
```

```js
// open/application/record-arrival.mjs — the scenario takes the environment from outside and passes it to the domain model
import { Delivery } from "../domain/delivery.mjs";

export const recordArrival = (id, point, environment) => Delivery.load(id, environment).recordArrival(point);
```

#### Two Adapter Teams

The same three ports are satisfied in two different ways. One connects to the external
modules, the other stays in memory.

```js
// open/adapters/production.mjs — adapters connecting the three ports to the external modules
import { recordTable } from "../../external/record-table.mjs";
import { trackingService } from "../../external/tracking-service.mjs";
import { notificationChannel } from "../../external/notification-channel.mjs";

export const productionEnvironment = {
  records: { read: (id) => recordTable.read(id), write: (r) => recordTable.write(r) },
  tracking: { pointCode: (n) => trackingService.pointCode(n), arrivalNumber: () => trackingService.arrivalNumber() },
  notifications: { send: (m) => notificationChannel.send(m) },
};
```

```js
// open/adapters/test.mjs — adapters satisfying the same three ports in memory
const TABLE = new Map();
let counter = 0;
export const SENT = [];

export const testEnvironment = {
  records: {
    read: (id) => structuredClone(TABLE.get(id) ?? null),
    write: (r) => { TABLE.set(r.id, structuredClone(r)); },
  },
  tracking: { pointCode: (n) => `TEST-${n}`, arrivalNumber: () => `S${String((counter += 1)).padStart(4, "0")}` },
  notifications: { send: (m) => SENT.push(m) },
};

export const seed = (id) => TABLE.set(id, { id, state: "accepted", route: ["34", "06", "35"], passed: [] });
```

#### Measurement

The measurement starts at the domain module's root and walks its import links: how many files
enter the closure, how many links land in the `external/` directory. It then runs the same
domain file with two adapter teams and looks at what happens with an unsatisfied port.

```js
// link-count.mjs — the domain module's import closure, direct link count, and a run with two adapter teams
import { readFileSync } from "node:fs";
import { dirname, join, normalize } from "node:path";
import { recordTable } from "./external/record-table.mjs";
import { notificationChannel } from "./external/notification-channel.mjs";
import { PORTS } from "./open/domain/ports.mjs";
import { recordArrival as closedArrival } from "./closed/application/record-arrival.mjs";
import { recordArrival as openArrival } from "./open/application/record-arrival.mjs";
import { productionEnvironment } from "./open/adapters/production.mjs";
import { testEnvironment, seed, SENT } from "./open/adapters/test.mjs";

const IMPORT = /^import[^"']*["']([^"']+)["']/gm;

function closure(root) {
  const seen = new Set(), stack = [root], edges = [];
  while (stack.length > 0) {
    const file = stack.pop();
    if (seen.has(file)) continue;
    seen.add(file);
    for (const [, path] of readFileSync(file, "utf8").matchAll(IMPORT)) {
      if (path.startsWith(".") === false) continue;
      const target = normalize(join(dirname(file), path));
      edges.push([file, target]);
      stack.push(target);
    }
  }
  return { seen, edges };
}

for (const [name, root] of [["closed", "closed/domain/delivery.mjs"], ["open", "open/domain/delivery.mjs"]]) {
  const { seen, edges } = closure(root);
  const outward = edges.filter(([, target]) => target.startsWith("external/"));
  console.log(`${name} domain module: import closure = ${seen.size}, direct link to the outside world = ${outward.length}`);
  for (const [f, t] of edges) console.log(`  ${f} -> ${t}`);
}

const nameCount = Object.values(PORTS).flat().length;
console.log(`ports = ${Object.keys(PORTS).length}, names in contract = ${nameCount}`);

recordTable.seed("T-K"); recordTable.seed("T-U"); seed("T-S");
const runs = [
  ["closed + external modules ", closedArrival("T-K", "34")],
  ["open + production team    ", openArrival("T-U", "34", productionEnvironment)],
  ["open + test team          ", openArrival("T-S", "34", testEnvironment)],
];
for (const [name, s] of runs) console.log(`${name} ${JSON.stringify(s)}`);
const fields = runs.map(([, s]) => `${s.state}/${s.passed}`);
console.log(`domain fields equal = ${new Set(fields).size === 1}, distinct values = ${new Set(fields).size}`);
console.log(`notices sent to external channel = ${notificationChannel.count()}, to test channel = ${SENT.length}`);

try { openArrival("T-S", "06", { records: testEnvironment.records }); }
catch (error) { console.log(`missing environment: ${error.message}`); }
```

```sh
node link-count.mjs
```

```
closed domain module: import closure = 4, direct link to the outside world = 3
  closed/domain/delivery.mjs -> external/record-table.mjs
  closed/domain/delivery.mjs -> external/tracking-service.mjs
  closed/domain/delivery.mjs -> external/notification-channel.mjs
open domain module: import closure = 2, direct link to the outside world = 0
  open/domain/delivery.mjs -> open/domain/ports.mjs
ports = 3, names in contract = 5
closed + external modules  {"identity":"T-K","number":"V0001","code":"IST-A1","state":"in-transfer","passed":1}
open + production team     {"identity":"T-U","number":"V0002","code":"IST-A1","state":"in-transfer","passed":1}
open + test team           {"identity":"T-S","number":"S0001","code":"TEST-34","state":"in-transfer","passed":1}
domain fields equal = true, distinct values = 1
notices sent to external channel = 2, to test channel = 1
missing environment: unsatisfied port: tracking.pointCode, tracking.arrivalNumber, notifications.send
```

#### Reading the Numbers

The domain module's import closure dropped from 4 to 2, its direct links to the outside world
from 3 to 0. The second number is decisive: in the closed arrangement, loading the aggregate
root means loading all three external modules; in the open arrangement, the domain module
knows only its own contract file.

Three links turned into three ports and five names. The port count came out equal to the link
count; this is not a coincidence — the transformation consists of naming the links. The five
names are the surface the outside sees of the domain: the other side only has to provide these
five operations, and how many files it has inside is not the domain's concern.

The three runs' domain results came out equal: same state, same passed count, 1 distinct
value. Only the fields coming from the adapter differ — the arrival number and point code
changed by team, because the domain does not produce them. The notice counters confirm this:
2 notices went to the external channel, 1 to the test channel. The same domain file ran with
both teams, and not one line of it was touched.

The last line is the contract file's counterpart at run time. Given an incomplete environment,
the three unsatisfied names were reported by name. In the closed arrangement there can be no
such error message, because there is no written contract to satisfy; the missing piece shows
up as an import error instead.

#### Counting the Cost

The arrangement is not free. The open arrangement adds 1 file (the contract) plus 1 file per
adapter team; with two teams, 3 files total. Every construction of the aggregate root gained
one more parameter. This parameter grows as the number of ports grows: in a domain with nine
ports, the environment object carries nine fields, and every use case passes it through from
end to end.

The gain depends on two conditions. First, a port must genuinely have more than one adapter; a
port with a single adapter only adds indirection. Second, the domain module being runnable on
its own has to be useful for something. If the domain has no decisions — if the decisions
still sit outside — a port is an empty contract; the measurement would show this as the direct
link count coming out 0, but the decision point count also coming out 0.

#### Summary

- A port is the name of a place the domain opens to the outside and the set of operations it
  expects; an adapter is that set's implementation, and more than one can exist for the same
  port.
- Hexagonal architecture introduces no new principle; it arranges boundary drawing, the
  policy–detail distinction, and the dependency direction so that they put the domain at the
  center.
- The domain module's import closure dropped from 4 to 2, and its direct links to the outside
  world dropped from 3 to 0; three links turned into three ports and five names.
- The same domain file ran with two adapter teams; the three runs' domain results came out
  equal, and only the number and code coming from the adapter changed.
- The cost is 3 files and one parameter per construction; the gain arises when a port has more
  than one adapter and the domain genuinely holds a decision.

#### Next Step

Every measure up to this point pointed toward writing: which decisions an arrival record
passed through, which ports it went out through. The operation desk's real work, though, is
reading — which deliveries are behind their route, how many packages are waiting at which
transfer center, which carrier's delay rate is high. If the answer to these questions is
sought through the aggregate roots, dozens of objects are walked for every question, and the
walk grows with the number of deliveries. The next lesson takes up keeping the same data in a
separate model for reading: it counts the number of objects walked for a query across two
arrangements, measures how many commands behind the separate model falls, and writes down, as
the cost, the number of files and mappings added in return.

### Command and Query Responsibility Segregation

Source: https://academia.sh/en/courses/domain-driven-design/command-and-query-responsibility-segregation

Separating the write and read models: producing the operation dashboard from the aggregate roots and from a separate read model, comparing the number of objects visited across three data sizes, measuring how many commands behind the read model falls and how many answers come out stale, and the cost in added files, lines, and mapping writes.

Every measure up to this point pointed toward writing: which decisions an arrival record
passed through, which ports it went out through. The operation desk's real work, though, is
reading: how many packages are waiting at which transfer point, how many of them are behind
their route. If the answer to these questions is sought through the aggregate roots, every
delivery and every leg of its route is walked for every answer; the walk grows with the number
of deliveries.

**Command and query responsibility segregation** gives these two directions separate models.
The write side is built to preserve invariants — aggregate roots, consistency boundaries,
decisions. The read side is built to match the shape of the question: the rows a query asks
for are kept ready.

This segregation is a different thing from the **command–query separation** introduced in the
Programming Fundamentals course: there, the test was whether a single method either changes
state or returns a value — a single method's signature. What is separated here is not a
method but a **model**: keeping the same data in two separate structures.

#### The Write Model

The write side continues the previous lessons' aggregate root. Every delivery has a route,
the points it has passed, and the number of points it is expected to have passed by now.

```js
// write-model.mjs — delivery aggregates and the single command: record arrival
const POINTS = ["34", "06", "35", "01", "16"];
const DELIVERIES = new Map();

export function seed(count) {
  DELIVERIES.clear();
  let x = 7;
  for (let i = 1; i <= count; i += 1) {
    x = (x * 48271 + 11) % 2147483647;
    const length = 2 + (x % 4);
    const start = x % POINTS.length;
    const route = Array.from({ length }, (_, j) => POINTS[(start + j) % POINTS.length]);
    const progress = x % length;
    DELIVERIES.set(`T${i}`, {
      id: `T${i}`, route, passed: route.slice(0, progress), expected: (x % 2) + progress,
    });
  }
  return DELIVERIES.size;
}

export const writeModel = {
  all: () => [...DELIVERIES.values()].map((t) => structuredClone(t)),
  find: (id) => structuredClone(DELIVERIES.get(id) ?? null),
};

export function arrivalCommand(id) {
  const t = DELIVERIES.get(id);
  if (t === undefined || t.passed.length >= t.route.length) return null;
  const before = structuredClone(t);
  t.passed.push(t.route[t.passed.length]);
  return { before, after: structuredClone(t) };
}
```

Seeding uses a fixed generator; the same delivery count gives the same routes on every run.
The command returns both the before and after of the change together, because the read side
needs to know which row changed.

#### Producing the Dashboard from the Write Model

The dashboard query reads every delivery, walks its route and its passed points, finds the
next point, and increments that point's row.

```js
// dashboard-from-write.mjs — the dashboard query over the write model: every aggregate and every route leg is walked
export function dashboardFromWrite(writeModel) {
  let visited = 0;
  const row = new Map();
  for (const t of writeModel.all()) {
    visited += 1;
    for (const _ of t.route) visited += 1;
    for (const _ of t.passed) visited += 1;
    const next = t.route[t.passed.length];
    if (next === undefined) continue;
    const s = row.get(next) ?? { point: next, waiting: 0, behind: 0 };
    s.waiting += 1;
    if (t.passed.length < t.expected) s.behind += 1;
    row.set(next, s);
  }
  return { rows: [...row.values()].sort((a, b) => a.point.localeCompare(b.point)), visited };
}
```

The `visited` counter turns the claim into a measure: every aggregate root, every route leg,
and every passed point counts as one visit.

#### The Read Model

The read model consists of five rows — one per transfer point. The rows are set up once,
then updated only by the deltas commands produce. Unapplied deltas wait in a queue; the
queue's length is the model's lag.

```js
// read-model.mjs — the read model keeping the dashboard rows ready, and a queue of unapplied updates
const ROW = new Map();
const PENDING = [];

export const readModel = {
  setup(rows) {
    ROW.clear(); PENDING.length = 0;
    for (const r of rows) ROW.set(r.point, { ...r });
  },
  publish(delta) { PENDING.push(delta); },
  lag: () => PENDING.length,
  advance(count = PENDING.length) {
    const applied = PENDING.splice(0, count);
    for (const delta of applied) {
      for (const [point, waiting, behind] of delta) {
        const s = ROW.get(point) ?? { point, waiting: 0, behind: 0 };
        s.waiting += waiting; s.behind += behind;
        ROW.set(point, s);
      }
    }
    return applied.length;
  },
  dashboard() {
    let visited = 0;
    const rows = [];
    for (const s of ROW.values()) { visited += 1; if (s.waiting > 0) rows.push({ ...s }); }
    return { rows: rows.sort((a, b) => a.point.localeCompare(b.point)), visited };
  },
};
```

The single piece connecting the two models is a mapping: it computes which rows a command
changes, and in which direction.

```js
// projection.mjs — the mapping that computes which dashboard rows a command changes
export function projection({ before, after }) {
  const oldPoint = before.route[before.passed.length], newPoint = after.route[after.passed.length];
  const delta = [];
  if (oldPoint !== undefined) delta.push([oldPoint, -1, before.passed.length < before.expected ? -1 : 0]);
  if (newPoint !== undefined) delta.push([newPoint, 1, after.passed.length < after.expected ? 1 : 0]);
  return delta;
}
```

#### Measurement

The measurement counts three things separately. The first is visits: the objects the two
paths walk, across three delivery counts. The second is staleness: across twenty-four
commands, how many commands behind the read model falls, and how many queries give an answer
that differs from the write model. The third is cost: the number of added files, lines, and
mapping writes.

```js
// dashboard-count.mjs — objects visited for the query, staleness lag, and the projection mapping count
import { readFileSync } from "node:fs";
import { seed, writeModel, arrivalCommand } from "./write-model.mjs";
import { dashboardFromWrite } from "./dashboard-from-write.mjs";
import { readModel } from "./read-model.mjs";
import { projection } from "./projection.mjs";

for (const n of [50, 200, 800]) {
  seed(n);
  const w = dashboardFromWrite(writeModel);
  readModel.setup(w.rows);
  const r = readModel.dashboard();
  const equal = JSON.stringify(w.rows) === JSON.stringify(r.rows);
  console.log(`deliveries = ${String(n).padStart(3)}  visited from write model = ${String(w.visited).padStart(4)}` +
    `, visited from read model = ${r.visited}, rows equal = ${equal}`);
}

seed(200);
readModel.setup(dashboardFromWrite(writeModel).rows);
console.log(`starting dashboard = ${JSON.stringify(readModel.dashboard().rows)}`);

const COMMANDS = Array.from({ length: 24 }, (_, i) => `T${i * 7 + 3}`);
const ADVANCE_INTERVAL = 6;
let stale = 0, maxLag = 0, mappingCount = 0, commandCount = 0;
for (const [index, id] of COMMANDS.entries()) {
  const result = arrivalCommand(id);
  if (result === null) continue;
  commandCount += 1;
  const delta = projection(result);
  mappingCount += delta.length;
  readModel.publish(delta);
  if ((index + 1) % ADVANCE_INTERVAL === 0) readModel.advance();
  maxLag = Math.max(maxLag, readModel.lag());
  const write = JSON.stringify(dashboardFromWrite(writeModel).rows);
  const read = JSON.stringify(readModel.dashboard().rows);
  if (write !== read) stale += 1;
}
console.log(`commands = ${commandCount}, projection mapping writes = ${mappingCount}`);
console.log(`stale answers = ${stale} / ${commandCount}, max lag = ${maxLag} commands`);
readModel.advance();
const finalWrite = JSON.stringify(dashboardFromWrite(writeModel).rows);
const finalRead = JSON.stringify(readModel.dashboard().rows);
console.log(`equal after queue drained = ${finalWrite === finalRead}, lag = ${readModel.lag()}`);
console.log(`final dashboard = ${finalRead}`);

const lineCount = (d) => readFileSync(d, "utf8").split("\n").filter((s) => s.trim() !== "").length;
for (const [name, files] of [["single model", ["write-model.mjs", "dashboard-from-write.mjs"]],
  ["added for the read model", ["read-model.mjs", "projection.mjs"]]]) {
  console.log(`${name.padEnd(26)} ${files.length} files, ${files.reduce((s, d) => s + lineCount(d), 0)} lines`);
}
```

```sh
node dashboard-count.mjs
```

```
deliveries =  50  visited from write model =  309, visited from read model = 5, rows equal = true
deliveries = 200  visited from write model = 1220, visited from read model = 5, rows equal = true
deliveries = 800  visited from write model = 4621, visited from read model = 5, rows equal = true
starting dashboard = [{"point":"01","waiting":45,"behind":29},{"point":"06","waiting":46,"behind":25},{"point":"16","waiting":32,"behind":19},{"point":"34","waiting":40,"behind":15},{"point":"35","waiting":37,"behind":24}]
commands = 24, projection mapping writes = 44
stale answers = 20 / 24, max lag = 5 commands
equal after queue drained = true, lag = 0
final dashboard = [{"point":"01","waiting":43,"behind":27},{"point":"06","waiting":39,"behind":18},{"point":"16","waiting":29,"behind":17},{"point":"34","waiting":38,"behind":12},{"point":"35","waiting":47,"behind":23}]
single model               2 files, 46 lines
added for the read model   2 files, 36 lines
```

#### Reading the Numbers

The visit counts say the same thing at all three sizes. Producing the dashboard through the
write model visits 309 objects at 50 deliveries, 1220 at 200, 4621 at 800; the ratio is
roughly 6 per delivery, because each delivery itself, its route legs, and its passed points
are each read separately. The same dashboard through the read model visits 5 objects at every
size. This number depends not on the delivery count but on the transfer point count; it stays
constant as the data grows. At all three sizes, the rows the two paths produce came out equal,
so the read model is a correct projection.

The staleness numbers show the cost. Across twenty-four commands, the queue was drained every
six commands; the read model fell at most 5 commands behind, and 20 of the 24 queries gave an
answer that differed from the write model. These 20 answers are not wrong, they are stale: the
number of packages waiting at a transfer point shows a state up to five commands old. Once the
queue was drained, the two models matched again and the lag dropped to 0.

The cost is two files and 36 lines. Add to that the per-command mapping writes: 24 commands
produced 44 lines of updates, an average of 1.8 per command. The segregation drops the visit
count from `6n` to a constant 5 while loading the write path this much more heavily.

#### Lag Is a Domain Decision

A five-command lag looks like a technical setting, but the domain expert makes the call. On
the transfer center's dashboard, a five-command lag is not a problem: the counter changes
within minutes anyway. The same lag is not acceptable in the decision that prevents a package
from being recorded at the same point twice. For this reason, the segregation is not applied
as "every query from the read model"; how much lag a given query can carry is context-dependent.

The general measures of read-model freshness, invalidation, and the freshness window were
covered in the Caching, Queues and Asynchronous Processing course; the only thing measured
here is the lag the segregation itself introduces.

#### When It Does Not Apply

The gain depends on two conditions. First, the query's visit count must grow with the data
size: a query that reads a single delivery by identity already visits 1 object in the write
model, and the read model gains it nothing. Second, the query's shape must be stable; if the
query changes every month, the mapping changes every month too, and both files stay
perpetually open.

The third boundary is consistency. If a domain rule depends on a query's answer — something
like "a point can hold at most a hundred waiting packages" — that rule cannot be read from the
read model, because the read model runs behind. Such a rule has to stay inside the write
side's consistency boundary.

#### Summary

- Command and query responsibility segregation keeps two separate models for write and read;
  command–query separation says a single method either changes state or returns a value.
- The dashboard visited 309, 1220, and 4621 objects through the write model at 50, 200, and
  800 deliveries; through the read model it visited 5 objects at every size, and the rows came
  out equal.
- The read model fell at most 5 commands behind; 20 of 24 queries gave a stale answer, and the
  two models matched once the queue was drained.
- The cost is 2 files, 36 lines, and an average of 1.8 mapping writes per command; the
  segregation makes the read path cheaper while loading the write path more heavily.
- How much lag is acceptable is a domain decision; if a domain rule depends on a query's
  answer, that rule has to stay inside the write side's consistency boundary.

#### Next Step

The read model was built from the deltas commands produce, but the deltas themselves were
discarded once applied. This leaves a question that cannot be asked of the write side: how
many times was a delivery's address corrected, how many retries happened at which point, in
what order did things happen between going out for delivery and being delivered? Because the
record keeps only the current state, the model has no answer to these questions. The next
lesson takes up a persistence form that stores not state but the event sequence: it tests
whether state can be rebuilt from events, counts how many events a rebuild reads, and measures
where that count lands once a snapshot is taken.

### Event Sourcing

Source: https://academia.sh/en/courses/domain-driven-design/event-sourcing

Storing the event sequence instead of state: comparing how many of four questions the state table and the event sequence can answer, counting how many events are read to rebuild state from events, measuring how the number of events read and snapshots written changes as the snapshot interval changes, and comparing the byte size of the two persistence forms.

The previous lesson's read model was built from the deltas commands produce, but the deltas
themselves were discarded once applied. All that remained on the write side was the current
state. This leaves later questions unanswered: how many times was a delivery's address
corrected, how many transfer points did it pass through, how many attempts did delivery take?
None of these questions was in the record's schema, because the schema was designed for the
tracking screen.

**Event sourcing** reverses the order: what is actually stored is not the state but the
sequence of events that produced it. State becomes a derivative — the events are folded in
sequence to rebuild it.

#### The Event as a Persistence Form

Queuing events, their delivery to consumers, and delivery guarantees were covered in the
Caching, Queues and Asynchronous Processing course. That is not this lesson's subject: here
the event sequence is treated as a **persistence form**, and the only question is — can state
be rebuilt from this sequence, and how many events does rebuilding read?

The events' names also follow the standard set in this course's Domain Events lesson:
`entered-transfer`, `address-corrected`, `delivery-attempted` are words the domain expert
uses, not the names of database operations. Every event corresponds to a transition.

```js
// fold-state.mjs — domain-language transitions, and folding state from the event sequence
export const EMPTY = {
  state: "none", point: null, address: null, transferCount: 0, attemptCount: 0, addressCorrections: 0,
};

const TRANSITION = {
  "accepted": (d, e) => ({ ...d, state: "accepted", address: e.address }),
  "entered-transfer": (d, e) => ({ ...d, state: "in-transfer", point: e.point, transferCount: d.transferCount + 1 }),
  "left-transfer": (d) => ({ ...d, state: "in-transit" }),
  "went-out-for-delivery": (d) => ({ ...d, state: "out-for-delivery" }),
  "delivery-attempted": (d) => ({ ...d, attemptCount: d.attemptCount + 1 }),
  "address-corrected": (d, e) => ({ ...d, address: e.address, addressCorrections: d.addressCorrections + 1 }),
  "delivered": (d) => ({ ...d, state: "delivered" }),
};

export function foldState(start, events) {
  let d = start;
  for (const e of events) {
    const transition = TRANSITION[e.type];
    if (transition === undefined) throw new RangeError(`unknown event: ${e.type}`);
    d = transition(d, e);
  }
  return d;
}
```

The fold function is pure and takes the starting state as a parameter. This detail is what
makes a snapshot possible: it can start from a state in the middle of the sequence.

#### State-Storing Persistence

The other persistence to compare keeps only the current state. Its schema was chosen to
answer the tracking screen's questions: where is the package, what is its state, what is its
address.

```js
// state-model.mjs — persistence keeping only the current state: a schema designed for the tracking screen
const ROW = new Map();

export const stateModel = {
  apply(id, event) {
    const s = ROW.get(id) ?? { id, state: "none", point: null, address: null };
    if (event.type === "accepted") { s.state = "accepted"; s.address = event.address; }
    if (event.type === "entered-transfer") { s.state = "in-transfer"; s.point = event.point; }
    if (event.type === "left-transfer") s.state = "in-transit";
    if (event.type === "went-out-for-delivery") s.state = "out-for-delivery";
    if (event.type === "address-corrected") s.address = event.address;
    if (event.type === "delivered") s.state = "delivered";
    ROW.set(id, s);
  },
  read: (id) => structuredClone(ROW.get(id) ?? null),
  size: () => JSON.stringify([...ROW.values()]).length,
};
```

#### The Event Store and the Snapshot

The event store accepts only appends; a written event is never modified. A **snapshot** keeps
the state computed at a given event count on a shelf, so a rebuild starts from that point
instead of the start of the sequence.

```js
// event-store.mjs — an append-only event sequence, a snapshot shelf, and a rebuild counter
import { EMPTY, foldState } from "./fold-state.mjs";

const SEQUENCE = new Map();
const SNAPSHOT = new Map();

export const eventStore = {
  append(id, event) {
    const sequence = SEQUENCE.get(id) ?? [];
    sequence.push(event);
    SEQUENCE.set(id, sequence);
  },
  ids: () => [...SEQUENCE.keys()],
  eventCount: (id) => SEQUENCE.get(id).length,
  totalEvents: () => [...SEQUENCE.values()].reduce((s, d) => s + d.length, 0),
  size: () => JSON.stringify([...SEQUENCE.entries()]).length,

  prepareSnapshots(interval) {
    SNAPSHOT.clear();
    if (interval === 0) return 0;
    for (const [id, sequence] of SEQUENCE) {
      for (let boundary = interval; boundary <= sequence.length; boundary += interval) {
        SNAPSHOT.set(`${id}:${boundary}`, foldState(EMPTY, sequence.slice(0, boundary)));
      }
    }
    return SNAPSHOT.size;
  },

  rebuild(id, interval) {
    const sequence = SEQUENCE.get(id);
    const boundary = interval === 0 ? 0 : Math.floor(sequence.length / interval) * interval;
    const snapshot = SNAPSHOT.get(`${id}:${boundary}`);
    const remaining = sequence.slice(boundary);
    return {
      state: foldState(snapshot ?? EMPTY, remaining),
      eventsRead: remaining.length,
      snapshotsRead: snapshot === undefined ? 0 : 1,
    };
  },
};
```

In a real system, snapshots are taken as events are appended. Because the interval is a
variable in this measurement, `prepareSnapshots` computes them all at once and returns how
many snapshots were written; this number stands in for the cost.

#### Writing the Same History to Both Persistences

For the comparison to be valid, both persistences have to see the same events. The generator
works from a fixed number sequence; every twenty-fifth delivery is a problem shipment routed
through many transfers.

```js
// generate-history.mjs — a fixed generator writing the same events to both persistences
import { eventStore } from "./event-store.mjs";
import { stateModel } from "./state-model.mjs";

const POINTS = ["34", "06", "35", "01", "16"];

export function generateHistory(deliveryCount) {
  let x = 13;
  const next = () => (x = (x * 48271 + 11) % 2147483647);
  for (let i = 1; i <= deliveryCount; i += 1) {
    const id = `T${i}`;
    const write = (event) => { eventStore.append(id, event); stateModel.apply(id, event); };
    write({ type: "accepted", address: `A${(next() % 900) + 100}` });
    const transfers = i % 25 === 0 ? 14 + (next() % 8) : 1 + (next() % 4);
    for (let a = 0; a < transfers; a += 1) {
      write({ type: "entered-transfer", point: POINTS[next() % POINTS.length] });
      write({ type: "left-transfer" });
    }
    write({ type: "went-out-for-delivery" });
    const attempts = 1 + (next() % 3);
    for (let d = 0; d < attempts; d += 1) {
      write({ type: "delivery-attempted" });
      if (d < attempts - 1 && next() % 2 === 0) write({ type: "address-corrected", address: `A${(next() % 900) + 100}` });
    }
    write({ type: "delivered" });
  }
  return eventStore.totalEvents();
}
```

#### Measurement

The measurement counts three things. First, answerability: how many of four questions each
persistence can answer. Second, rebuild cost: the events read, snapshots read, and snapshots
written across four different snapshot intervals. Third, byte size. Because a rebuild's
duration depends on hardware and the run environment, the measure used is not duration but
the **number of events read**; the workload grows directly with this number.

```js
// event-count.mjs — four questions' answerability, events read for a rebuild, and the snapshot effect
import { eventStore } from "./event-store.mjs";
import { stateModel } from "./state-model.mjs";
import { generateHistory } from "./generate-history.mjs";

const total = generateHistory(400);
console.log(`deliveries = 400, events = ${total}, event sequence size = ${eventStore.size()} bytes` +
  `, state table size = ${stateModel.size()} bytes`);

const QUESTIONS = [
  ["current state", (d) => d.state, (s) => s.state],
  ["address corrections", (d) => d.addressCorrections, (s) => s.addressCorrections],
  ["transfer count", (d) => d.transferCount, (s) => s.transferCount],
  ["delivery attempts", (d) => d.attemptCount, (s) => s.attemptCount],
];
const longest = eventStore.ids().reduce((a, b) => (eventStore.eventCount(a) >= eventStore.eventCount(b) ? a : b));
console.log(`longest history = ${longest}, events = ${eventStore.eventCount(longest)}`);

const fromEvents = eventStore.rebuild(longest, 0).state;
const fromRow = stateModel.read(longest);
console.log(`four questions for ${longest}:`);
let answered = 0;
for (const [name, eventPath, rowPath] of QUESTIONS) {
  const e = eventPath(fromEvents), s = rowPath(fromRow);
  if (s !== undefined) answered += 1;
  console.log(`  ${name.padEnd(20)} event sequence = ${String(e).padEnd(13)} state table = ${s ?? "no answer"}`);
}
console.log(`questions the state table answers = ${answered} / 4, the event sequence's = 4 / 4`);

const complete = JSON.stringify(eventStore.ids().map((id) => eventStore.rebuild(id, 0).state));
for (const interval of [0, 20, 10, 5]) {
  const written = eventStore.prepareSnapshots(interval);
  let eventsRead = 0, snapshotsRead = 0;
  const states = eventStore.ids().map((id) => {
    const r = eventStore.rebuild(id, interval);
    eventsRead += r.eventsRead; snapshotsRead += r.snapshotsRead;
    return r.state;
  });
  const single = eventStore.rebuild(longest, interval).eventsRead;
  console.log(`interval = ${String(interval).padStart(2)}  snapshots written = ${String(written).padStart(4)}` +
    `, events read = ${String(eventsRead).padStart(4)}, snapshots read = ${String(snapshotsRead).padStart(3)}` +
    `, events read for ${longest} = ${String(single).padStart(2)}, state same = ${JSON.stringify(states) === complete}`);
}
```

```sh
node event-count.mjs
```

```
deliveries = 400, events = 4699, event sequence size = 156377 bytes, state table size = 25493 bytes
longest history = T375, events = 49
four questions for T375:
  current state        event sequence = delivered     state table = delivered
  address corrections  event sequence = 1             state table = no answer
  transfer count       event sequence = 21            state table = no answer
  delivery attempts    event sequence = 3             state table = no answer
questions the state table answers = 1 / 4, the event sequence's = 4 / 4
interval =  0  snapshots written =    0, events read = 4699, snapshots read =   0, events read for T375 = 49, state same = true
interval = 20  snapshots written =   26, events read = 4179, snapshots read =  16, events read for T375 =  9, state same = true
interval = 10  snapshots written =  297, events read = 1729, snapshots read = 255, events read for T375 =  9, state same = true
interval =  5  snapshots written =  776, events read =  819, snapshots read = 400, events read for T375 =  4, state same = true
```

#### Reading the Numbers

The four questions show the divide. The state table answered 1 of 4 questions; the remaining
three were unanswerable because they were not in its schema. The event sequence answered all
4, and did so without adding a single field to its schema: 1 address correction, 21 transfers,
3 delivery attempts. The difference between them is not the amount of stored information but
**which questions have to be known in advance**. In state-storing persistence, the question
must be known when the schema is designed; in an event sequence, it is answered the moment it
is asked.

The rebuild rows show the snapshot's effect. Without a snapshot, rebuilding the state of 400
deliveries reads 4699 events. At interval 20, the total drops to only 4179: because the
average history length is 11.7, most deliveries never reach twenty events, and only 16 of the
400 deliveries can use a snapshot. At interval 10, events read drops to 1729; at interval 5, to
819. The rule fits in one sentence: the number of events read does not fall until the snapshot
interval drops below the average history length.

The cost sits in the other column. As the interval drops to 5, events read falls from 4699 to
819 while the number of snapshots written rises from 0 to 776, and every rebuild adds one
snapshot read on top. A snapshot, in other words, is a trade-off that turns read load into
write load.

The longest history shows this on a single delivery. T375 has 49 events; a rebuild without a
snapshot reads 49 events, with interval 20 it reads 9, with interval 10 it reads 9, with
interval 5 it reads 4. At all four intervals, the state built came out identical to a full
rebuild; a snapshot is a shortcut, not a separate source of truth.

#### Cost and Limit

The event sequence took up 156377 bytes, the state table 25493 bytes: roughly 6.1 times as
much. This ratio grows over time, because the event sequence only lengthens. The state table,
in contrast, grows with the delivery count, not with history length.

The second cost is schema evolution. Because a written event is never modified, if an event
type's fields change later, the old events keep their old shape, and the fold function has to
recognize both shapes; the `TRANSITION` table accumulates more than one version per event
type. In state-storing persistence, the same change is finished with a single migration.

The third is a domain constraint: the event sequence makes a record's history permanent. In
cases where a record must be fully deleted, the sequence's append-only nature is an obstacle,
and this shows that the design decision has to be made together with the domain.

The gain, against these three costs, arises under two conditions: the history itself has
value for the domain, and the questions that will later be asked are not known in advance. In
a record where only the current state is ever asked for, the event sequence takes up 6.1 times
the space and a fold function produces not one new answer in return.

#### Summary

- Event sourcing stores not state but the event sequence that produces it; state is derived
  by the fold function, and because the fold is pure, it can be started from the middle of the
  sequence.
- Of four questions, the state table answered 1 and the event sequence answered 4; the
  difference is not the amount of stored information but whether the questions have to be
  known when the schema is designed.
- Rebuilding the state of 400 deliveries without a snapshot read 4699 events; at intervals 20,
  10, and 5, this number became 4179, 1729, and 819. The gain is small until the interval
  drops below the average history length.
- A snapshot turns read load into write load: as events read fell to 819, snapshots written
  rose to 776. At all four intervals, the state built came out identical to a full rebuild.
- The cost is roughly 6.1 times the space, an accumulating version count per event type, and
  an append-only sequence that makes deletion harder; the gain depends on the history having
  value for the domain.

#### Next Step

This topic's four lessons all proceeded the same way: a model was built, a measure was
chosen, two arrangements were compared by that same measure. But 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, the list of event types — each of these changes 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. The next lesson takes up the
model's evolution together with the implementation: it measures the improvement made when one
of the domain expert's sentences has no counterpart in the model, the drop in a rule's number
of writing sites, the overlap rate of names, and the number of files an improvement edits.

### Model-Driven Design

Source: https://academia.sh/en/courses/domain-driven-design/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 the quote and the fee, which diverged in 19 of thirty
shipments in the scattered version, diverged in 0 in the unified one. 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: the units a
request touches, the module boundaries it crosses, the 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.

