Lesson 10 / 19
Bounded Context
The boundary of the area where a model holds: counting the fields a class carries and the field pairs that never appear together in any use case when one word's two meanings are combined into a single class, measuring how many objects two contexts' invariants reject when merged into that class, and showing that drawing the boundary does not change the fee.
Contents
The Domain Model topic closed with the anemic model critique. Across that topic, the internal structure of a single model was built — which object holds identity, which boundary an invariant sits inside, where behavior belongs — and every decision was measured by the model’s alignment with the domain. The one question left unasked was this: whose domain is this a model of?
The question is not empty. In the shipment pricing and routing library, the word shipment
names two different objects in two teams’ mouths. For the pricing team, a shipment is a
priceable unit carrying weight, volume, zone, and declared value. For the delivery operations
team, a shipment is a physical package with a route, transfer points, and state timestamps.
Because the word is the same, a single class gets written, and that class is forced to do two
jobs at once. This lesson measures the cost of that forcing with two numbers: the field count
the class carries, and the count of field pairs that never appear together in any use case.
A Bounded Context Is a Language Boundary
The area where a model and that model’s language hold is called a bounded context. Inside the boundary, every name denotes exactly one thing; outside it, the same name may denote something else, and that is not an inconsistency.
The module, component, and policy–detail boundaries in the Design Principles course answered where to draw a boundary with change frequency and change-together; a bounded context is a language boundary instead, running through the place where a name’s meaning changes, not through files packaged together. Two contexts can even live in the same component; what makes the distinction is not packaging but vocabulary.
The rule established in the Ubiquitous Language lesson picks up a condition here: the language being the same everywhere means it is the same everywhere inside one context. When the two teams’ vocabularies are compared, the overlap is smaller than assumed.
| Word | Pricing context | Delivery operations context |
|---|---|---|
shipment |
a priceable unit carrying weight, volume, zone, and declared value | a physical package carrying a route, transfer count, and state history |
zone |
the tariff zone that gives the fee coefficient | no counterpart; a distribution-center code stands in its place |
tariff |
the tier-and-coefficient table | no counterpart |
route |
no counterpart | the sequence of departure, transfer, and delivery points |
delivery |
no counterpart | the state-and-timestamp record |
Only one of the five words appears in both contexts, and it names two different objects. Two arrangements of the same domain follow from this table: one that combines the word into a single class, and one that runs the boundary through the place where the word’s meaning changes.
The Arrangement Combined Into One Class
In the first arrangement, the two vocabularies are gathered into one class. The class carries
both sides’ fields, both sides’ behavior, and both sides’ invariants. The invariants are
collected in the validate method; five from the pricing side are marked with a U prefix,
five from the delivery side with a T prefix.
mkdir -p combined pricing delivery
// combined/shipment.mjs — a shipment carrying both contexts' fields and invariants in one class const ZONES = ["near", "mid", "far"]; const STATES = ["accepted", "in-transfer", "out-for-delivery", "delivered"]; const EMPTY = { shipmentNo: null, weightGrams: null, volumeDm3: null, zone: null, declaredValueCents: null, tariffCode: null, contractNo: null, discountRate: null, carrierCode: null, routePoints: null, transferCount: null, lastState: null, stateHistory: null, recipientName: null, deliveryTime: null, }; export class Shipment { constructor(v) { Object.assign(this, EMPTY, v); } fee(tariff) { const chargeableGrams = Math.max(this.weightGrams, this.volumeDm3 * 200); const rate = tariff[this.tariffCode][this.zone]; return Math.round((chargeableGrams / 1000) * rate * (1 - this.discountRate)); } markDelivered(name, time) { this.recipientName = name; this.deliveryTime = time; this.lastState = "delivered"; this.stateHistory = [...this.stateHistory, ["delivered", time]]; } validate() { const violations = []; if (!(Number.isInteger(this.weightGrams) && this.weightGrams > 0)) violations.push("U1-weight"); if (!(this.volumeDm3 > 0)) violations.push("U2-volume"); if (!ZONES.includes(this.zone)) violations.push("U3-zone"); if (!Number.isInteger(this.declaredValueCents)) violations.push("U4-declared-value"); if (!(this.discountRate >= 0 && (this.discountRate === 0 || this.contractNo !== null))) violations.push("U5-discount"); if (!(this.routePoints?.length >= 2)) violations.push("T1-route"); if (this.transferCount !== (this.routePoints?.length ?? 0) - 2) violations.push("T2-transfer"); if (!STATES.includes(this.lastState)) violations.push("T3-state"); if (this.stateHistory?.at(-1)?.[0] !== this.lastState) violations.push("T4-history"); if (this.lastState === "delivered" && this.recipientName === null) violations.push("T5-delivery"); return violations; } }
The EMPTY object in the class’s constructor is worth noting: every field’s default is
null. This is not an implementation detail but the forced consequence of combining the two.
A pricing record has no route, a delivery record has no declared value; if a single class is
to accept both, it must leave every field open to absence.
The Arrangement With the Boundary Drawn
In the second arrangement, the boundary runs through the place where the word’s meaning
changes. Two directories, two Shipment classes, two sets of invariants. The same name
appearing in two files is not a duplication; it is the name of two different objects in two
different contexts.
// pricing/shipment.mjs — shipment in the pricing context: a priceable unit const ZONES = ["near", "mid", "far"]; export class Shipment { constructor(v) { Object.assign(this, { contractNo: null, discountRate: 0 }, v); } fee(tariff) { const chargeableGrams = Math.max(this.weightGrams, this.volumeDm3 * 200); const rate = tariff[this.tariffCode][this.zone]; return Math.round((chargeableGrams / 1000) * rate * (1 - this.discountRate)); } validate() { const violations = []; if (!(Number.isInteger(this.weightGrams) && this.weightGrams > 0)) violations.push("U1-weight"); if (!(this.volumeDm3 > 0)) violations.push("U2-volume"); if (!ZONES.includes(this.zone)) violations.push("U3-zone"); if (!Number.isInteger(this.declaredValueCents)) violations.push("U4-declared-value"); if (!(this.discountRate >= 0 && (this.discountRate === 0 || this.contractNo !== null))) violations.push("U5-discount"); return violations; } }
// delivery/shipment.mjs — shipment in the delivery operations context: a physical package const STATES = ["accepted", "in-transfer", "out-for-delivery", "delivered"]; export class Shipment { constructor(v) { Object.assign(this, { recipientName: null, deliveryTime: null }, v); } markDelivered(name, time) { this.recipientName = name; this.deliveryTime = time; this.lastState = "delivered"; this.stateHistory = [...this.stateHistory, ["delivered", time]]; } validate() { const violations = []; if (!(this.routePoints?.length >= 2)) violations.push("T1-route"); if (this.transferCount !== (this.routePoints?.length ?? 0) - 2) violations.push("T2-transfer"); if (!STATES.includes(this.lastState)) violations.push("T3-state"); if (this.stateHistory?.at(-1)?.[0] !== this.lastState) violations.push("T4-history"); if (this.lastState === "delivered" && this.recipientName === null) violations.push("T5-delivery"); return violations; } }
Comparing the two classes needs measurement data: the same three shipments from each team’s own records, and pricing’s tariff table.
// data.mjs — the same three shipments from each team's own records, plus the shared tariff export const TARIFF = { standard: { near: 1800, mid: 2400, far: 3200 }, heavy: { near: 1400, mid: 1900, far: 2600 }, }; export const PRICING_ROWS = [ { shipmentNo: "G-1041", weightGrams: 2400, volumeDm3: 9, zone: "mid", declaredValueCents: 180000, tariffCode: "standard", contractNo: "S-77", discountRate: 0.12 }, { shipmentNo: "G-1042", weightGrams: 700, volumeDm3: 2, zone: "near", declaredValueCents: 0, tariffCode: "standard", contractNo: null, discountRate: 0 }, { shipmentNo: "G-1043", weightGrams: 18000, volumeDm3: 60, zone: "far", declaredValueCents: 950000, tariffCode: "heavy", contractNo: "S-12", discountRate: 0.2 }, ]; export const DELIVERY_ROWS = [ { shipmentNo: "G-1041", carrierCode: "T-A", routePoints: ["34-ctr", "06-trf", "06-dlv"], transferCount: 1, lastState: "out-for-delivery", stateHistory: [["accepted", 1], ["in-transfer", 2], ["out-for-delivery", 3]] }, { shipmentNo: "G-1042", carrierCode: "T-B", routePoints: ["34-ctr", "34-dlv"], transferCount: 0, lastState: "accepted", stateHistory: [["accepted", 1]] }, { shipmentNo: "G-1043", carrierCode: "T-A", routePoints: ["34-ctr", "35-trf", "65-trf", "65-dlv"], transferCount: 2, lastState: "in-transfer", stateHistory: [["accepted", 1], ["in-transfer", 2]] }, ];
Counting Fields and Field Pairs
The first measure is the field count the class carries. The second measure is more discriminating: if two fields are never read together in any use case, there is no justification for them standing in the same class. The script below carries six use-case scenarios and the fields each one reads, then counts whether each field pair appears together in at least one of them.
// field-count.mjs — field count, unread fields per use case, and pairs never read together import { Shipment } from "./combined/shipment.mjs"; import { Shipment as PricingShipment } from "./pricing/shipment.mjs"; import { Shipment as DeliveryShipment } from "./delivery/shipment.mjs"; import { PRICING_ROWS, DELIVERY_ROWS } from "./data.mjs"; const USE_CASES = [ ["calculateFee", ["shipmentNo", "weightGrams", "volumeDm3", "zone", "tariffCode"]], ["applyDiscount", ["shipmentNo", "tariffCode", "contractNo", "discountRate"]], ["insurancePremium", ["shipmentNo", "declaredValueCents", "zone"]], ["assignRoute", ["shipmentNo", "carrierCode", "routePoints", "transferCount"]], ["recordTransfer", ["shipmentNo", "routePoints", "transferCount", "lastState", "stateHistory"]], ["markDelivered", ["shipmentNo", "lastState", "stateHistory", "recipientName", "deliveryTime"]], ]; const fieldsOf = (Class, row) => Object.keys(new Class(row)); function measure(name, fields, scenarios) { let pairs = 0, together = 0; for (let i = 0; i < fields.length; i += 1) { for (let j = i + 1; j < fields.length; j += 1) { pairs += 1; if (scenarios.some(([, o]) => o.includes(fields[i]) && o.includes(fields[j]))) together += 1; } } console.log(`${name}: ${fields.length} fields, ${pairs} field pairs, ${scenarios.length} scenarios`); for (const [scenario, read] of scenarios) { const present = read.filter((a) => fields.includes(a)).length; console.log(` ${scenario.padEnd(18)} read ${String(present).padStart(2)}, unread ${String(fields.length - present).padStart(2)}`); } console.log(` pairs never read together = ${pairs - together} / ${pairs}`); return fields; } const all = measure("combined", fieldsOf(Shipment, {}), USE_CASES); const pricingFields = fieldsOf(PricingShipment, PRICING_ROWS[0]); const deliveryFields = fieldsOf(DeliveryShipment, DELIVERY_ROWS[0]); const pricingOnly = pricingFields.filter((a) => !deliveryFields.includes(a)); const deliveryOnly = deliveryFields.filter((a) => !pricingFields.includes(a)); console.log(` ${pricingOnly.length * deliveryOnly.length} of these are cross-context pairs (${pricingOnly.length} x ${deliveryOnly.length})`); measure("pricing", pricingFields, USE_CASES.filter(([, o]) => o.every((a) => pricingFields.includes(a)))); measure("delivery", deliveryFields, USE_CASES.filter(([, o]) => o.every((a) => deliveryFields.includes(a)))); console.log(`fields shared by both contexts = ${all.filter((a) => pricingFields.includes(a) && deliveryFields.includes(a)).join(", ")}`);
node field-count.mjs
combined: 15 fields, 105 field pairs, 6 scenarios calculateFee read 5, unread 10 applyDiscount read 4, unread 11 insurancePremium read 3, unread 12 assignRoute read 4, unread 11 recordTransfer read 5, unread 10 markDelivered read 5, unread 10 pairs never read together = 68 / 105 49 of these are cross-context pairs (7 x 7) pricing: 8 fields, 28 field pairs, 3 scenarios calculateFee read 5, unread 3 applyDiscount read 4, unread 4 insurancePremium read 3, unread 5 pairs never read together = 11 / 28 delivery: 8 fields, 28 field pairs, 3 scenarios assignRoute read 4, unread 4 recordTransfer read 5, unread 3 markDelivered read 5, unread 3 pairs never read together = 8 / 28 fields shared by both contexts = shipmentNo
The combined class carries 15 fields, and none of the six scenarios reads more than five: at
least ten fields sit unused in every use case. The real number is in the last line. Of the 105
field pairs, 68 never appear together in any scenario, and 49 of those are cross-context
pairs — one field belonging to pricing, the other to delivery. Once the boundary is drawn, the
pair count drops from 105 to 56 and the never-together count from 68 to 19; the 49
cross-context pairs disappear entirely. The two contexts’ one shared field is shipmentNo —
identity itself.
Counting Invariants
The field count shows the looseness of the structure; the invariant count shows something
sharper. The combined class’s validate method carries ten invariants, and all ten run for
every object. Because a pricing record has no route and a delivery record has no declared
value, the result is foregone, but it still needs counting.
// violation-count.mjs — how many violations ten invariants raise on six shipments, and whether the fee changes import { Shipment } from "./combined/shipment.mjs"; import { Shipment as PricingShipment } from "./pricing/shipment.mjs"; import { Shipment as DeliveryShipment } from "./delivery/shipment.mjs"; import { TARIFF, PRICING_ROWS, DELIVERY_ROWS } from "./data.mjs"; const combinedObjects = [...PRICING_ROWS, ...DELIVERY_ROWS].map((s) => new Shipment(s)); let total = 0, passing = 0; console.log("combined class (10 invariants)"); for (const s of combinedObjects) { const violations = s.validate(); total += violations.length; if (violations.length === 0) passing += 1; console.log(` ${s.shipmentNo} violations ${violations.length} ${violations.join(" ") || "-"}`); } console.log(` passing objects = ${passing} / ${combinedObjects.length}, total violations = ${total}`); const separated = [...PRICING_ROWS.map((s) => new PricingShipment(s)), ...DELIVERY_ROWS.map((s) => new DeliveryShipment(s))]; let total2 = 0, passing2 = 0; console.log("separated classes (5 + 5 invariants)"); for (const s of separated) { const violations = s.validate(); total2 += violations.length; if (violations.length === 0) passing2 += 1; } console.log(` passing objects = ${passing2} / ${separated.length}, total violations = ${total2}`); let differing = 0; for (const s of PRICING_ROWS) { const a = new Shipment(s).fee(TARIFF), b = new PricingShipment(s).fee(TARIFF); if (a !== b) differing += 1; console.log(` ${s.shipmentNo} combined ${a} separated ${b}`); } console.log(`differing fee = ${differing} / ${PRICING_ROWS.length}`);
node violation-count.mjs
combined class (10 invariants) G-1041 violations 4 T1-route T2-transfer T3-state T4-history G-1042 violations 4 T1-route T2-transfer T3-state T4-history G-1043 violations 4 T1-route T2-transfer T3-state T4-history G-1041 violations 5 U1-weight U2-volume U3-zone U4-declared-value U5-discount G-1042 violations 5 U1-weight U2-volume U3-zone U4-declared-value U5-discount G-1043 violations 5 U1-weight U2-volume U3-zone U4-declared-value U5-discount passing objects = 0 / 6, total violations = 27 separated classes (5 + 5 invariants) passing objects = 6 / 6, total violations = 0 G-1041 combined 5069 separated 5069 G-1042 combined 1260 separated 1260 G-1043 combined 37440 separated 37440 differing fee = 0 / 3
None of the six real shipments passes the combined class’s invariants: 0/6, 27 violations total. The same six shipments, the same ten invariants, pass 6/6 once distributed across two classes, and the violation count is zero. The invariants’ text did not change, only the scope in which they applied did.
The last three lines show that splitting did not break behavior: the fee for all three shipments is the same in both arrangements. Drawing the boundary splits the model into two parts; it does not change the arithmetic.
What a Conditional Invariant Hides
There is a known way to rescue the combined class: writing the invariants conditionally. Let the route check run only when the route field is filled, and the weight check only when the weight is filled. This drives the violation count to zero, but it loses something: the condition puts back, as data inside the class, which context’s object is being worked with. The invariant no longer says “a shipment’s route has at least two points”; it says “if this object came from delivery, its route has at least two points.” The first is the domain expert’s sentence; the second is not.
The procedure for finding where the boundary runs follows from this: look at which of a class’s invariants can only be written under a condition. If the condition is hiding a context distinction, the boundary runs through that condition’s place.
Summary
- A bounded context is the area where a model and its language hold; unlike a component boundary, it runs through the place where a name’s meaning changes, not through files packaged together.
- Only one of five words in the two contexts’ vocabulary was shared, and that word named two
different objects; the two classes’ one shared field turned out to be
shipmentNo. - The combined class carried 15 fields, and none of the six scenarios read more than five; of the 105 field pairs, 68 never appeared together in any scenario, and 49 of those were cross-context pairs.
- Once the boundary was drawn, the field-pair count dropped from 105 to 56 and the never-together count from 68 to 19; the 49 cross-context pairs disappeared entirely.
- Ten invariants in one class rejected all six real shipments (0/6, 27 violations); the same invariants, distributed across two classes, passed 6/6, and the fee for all three shipments stayed the same.
- Writing an invariant conditionally zeroes out the violations but puts the context distinction back inside the class as data; the condition’s location shows where the boundary belongs.
Next Step
The boundary has been drawn, and each context now has its own vocabulary. Yet the two contexts cannot work in ignorance of each other: while pricing computes a shipment’s price, delivery sends that same shipment out; once it is delivered, pricing needs to know. A boundary is not a wall but a gate, and how many gates there are, which way each one faces, and who conforms to whose vocabulary is a design decision. The next lesson writes the relationships between contexts as a graph, distinguishes relationship types by direction and dependency, then runs the same measure on two maps: how many contexts have a file edited when one context changes, and where that number lands once the relationships are named.
To keep your progress and take notes, Log in
My notes
Log in to take notes.