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

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

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.

```sh
mkdir -p combined pricing delivery
```

```js
// 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.

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

```js
// 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.

```js
// 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.

```js
// 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(", ")}`);
```

```sh
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.

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

```sh
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.
