---
title: 'Context Map'
source: 'https://academia.sh/en/courses/domain-driven-design/context-map'
course: 'Domain-Driven Design'
language: en
updated: '2026-08-23T07:01:18+00:00'
license: 'CC BY-SA 4.0'
---

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