Lesson 13 / 19
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.
Contents
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.
// 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.
// 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}`); } }
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.
mkdir -p upstream conformist translated
// 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.
// 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.
// 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 }; }
// 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.
// 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}`);
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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.