Skip to content
academia.sh

Lesson 02 / 19

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.

Contents

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.

mkdir -p scattered unified
// scattered/entry.mjs — the record's entry point: cargo
export const cargoRecord = (weightGrams, zoneCode, rebates) =>
  ({ weightGrams, zoneCode, rebates });
// 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);
}
// 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.

// unified/entry.mjs — the record's entry point: shipment, weight in kilograms
export const shipment = (weight, zone, discounts) => ({ weight, zone, discounts });
// 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.

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

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close