Lesson 11 / 19
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.
Contents
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.
mkdir -p contract unnamed named
// 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.
// 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; }
// 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]; }
// 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.
// 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 }; }
// 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)); }
// 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] }; }
// named/delivery.mjs — delivery core: speaks only its own language import { deliveryTerms } from "./delivery-translation.mjs"; export const deliveryWindowHours = (contractNo) => deliveryTerms(contractNo).windowHours;
// 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.
// 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}`); }
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.
// 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`);
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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.