Skip to content
academia.sh

Lesson 01 / 19

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.

Contents

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.

mkdir -p technical domain
// 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.

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

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

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close