Software Architecture · Domain Modeling

Bounded Contexts and the Cost of a Shared Model

Why one organisation-wide model accumulates nullable fields, how context boundaries are drawn around consistency, and what a context map records about the relationships between them.

Ask the sales department and the finance department what a customer is, and the answers will not match. Sales describes a prospect moving through a qualification process, with a stage, an owner, and a probability of closing. Finance describes a party liable for invoices, with payment terms, a tax registration, and a credit limit. Both are correct within their own work. Put them in one class and the class acquires two sets of fields, each optional from the other side’s point of view, and the invariant that a customer must have payment terms becomes conditional on a flag that means “this record is the finance kind”.

One model cannot hold two meanings

The failure is structural rather than a matter of discipline. A single type must be valid for both usages, so every field used by only one of them is nullable, and every rule that applies to only one of them must be guarded by a condition on which usage is in play. Once the guard exists, half the methods in the type are illegal to call in the other half of the cases, and nothing in the type says which half a given call site is in.

The pressure to share comes from a real cost: two models mean two tables, two sets of validation, and a mapping between them. That cost is visible and immediate. The cost of sharing is invisible for a while — it appears as conditional branches, then as bugs where a sales update clears a finance field, then as a migration that cannot proceed because the two meanings must change differently. The trade is between a cost paid up front and a cost paid in every future change.

A bounded context is the answer at the model level: a boundary within which one model is consistent and its terms have exactly one meaning. Outside the boundary, the same word may mean something else and there is no requirement that it does not. The boundary is not a service, a deployment unit, or a team. It is the scope within which a model’s vocabulary and invariants are expected to hold.

Drawing the boundary around consistency

The practical question is where to put the line. Three signals are more reliable than org charts.

The first is invariant ownership. If two parts of the system must keep a rule true at the same instant, they belong in the same context, because the enforcement mechanism — a transaction, a constraint — has to be shared. Splitting them across contexts turns an invariant into a process with compensation, which is a legitimate choice but a much larger one.

The second is rate of change and the direction of change. A context whose model changes weekly should not be the same context as one that changes yearly, because every change forces the slower one to re-verify. This is why billing and shipping tend to separate from product catalogues: the catalogue changes with marketing, the shipping rules change with carriers.

The third is vocabulary conflict. When two groups use the same word with different definitions and neither definition is a specialisation of the other, they are in different contexts. A word that means a subset in one place and a superset in another can sometimes share a model through a hierarchy; a word that means two unrelated things cannot.

The org chart is a weak signal. Conway’s observation that system structure mirrors communication structure explains why boundaries often follow teams, not why they should. A context that spans three teams is workable if the model is stable and the teams coordinate on changes; a context that splits one team’s work in two creates a translation cost inside a single conversation.

Relationship patterns and what each costs

Once boundaries exist, the relationships between them need names, because each relationship has different costs and different failure modes.

A shared kernel is a small model both contexts include and neither may change unilaterally. It works when the shared part is genuinely small and stable — a money type, a unit-of-measure enumeration — and it fails when the kernel grows, because every change requires agreement between all owners. The kernel is a coordination cost paid per change, not per context.

Customer/supplier describes a downstream context that depends on an upstream one and can negotiate its needs. The upstream plans changes with the downstream in mind. This works when the upstream has a reason to care, which is usually because the downstream’s needs are part of the same business process.

Conformist describes a downstream that simply adopts the upstream model without translation. It is the cheapest integration to build and the most expensive to escape: the downstream’s model now contains the upstream’s vocabulary and constraints, and every upstream change propagates. It is a reasonable choice when the upstream model is a genuine standard — a payment processor’s API, an accounting standard — and a poor one when the upstream is an internal team whose model is in flux.

Separate ways means no integration at all, and it is underused. Two contexts that exchange data once a quarter do not need a shared model; a file and a scheduled import suffice. The absence of integration removes an entire class of coupling.

Anticorruption layer puts a translation component at the downstream boundary so that the upstream model does not enter. It is the subject of Anti-Corruption Layers at System Edges.

A context map is the diagram that records which of these applies to each pair of contexts, plus the direction of dependency. Its value is not the drawing; it is that someone has to answer the question for each pair, and the answer “we have not decided” is visible.

Translation is a real component

Where two contexts exchange data, some component converts between the models. That component is not incidental plumbing. It holds the knowledge of how the two vocabularies relate, and it is the only place that knowledge should live.

A translation function takes the upstream representation and produces a downstream domain object, applying the downstream’s rules. It is not a field-by-field copy, because the models do not have the same fields — that is the entire point of having two. The function decides what the upstream’s “account status” means for the downstream’s “billing eligibility”, and when the upstream adds a status, the function is the place that must be updated.

// Sales context: a prospect becomes a customer when the first order is won.
type SalesAccount = {
  id: string;
  name: string;
  stage: "lead" | "qualified" | "won" | "lost";
  owner: string;
};

// Billing context: a party that can be invoiced, with terms and a tax id.
type BillingProfile = {
  id: string;
  legalName: string;
  paymentTermsDays: number;   // 0 means due on receipt
  taxId: string | null;
};

function toBillingProfile(a: SalesAccount, taxId: string | null): BillingProfile {
  if (a.stage !== "won") throw new Error(`account ${a.id} has not been won`);
  // Billing names are legal names; the sales name is a trading name and is
  // deliberately not carried across, because invoices must use the legal one.
  return {
    id: a.id,
    legalName: a.name,
    paymentTermsDays: 30,
    taxId,
  };
}

Two things are worth noticing. The identifier is carried across unchanged, which is a decision — it means the two contexts agree that the identity of the party is shared even though its attributes are not. And a rule is applied during translation (stage === "won"), which is legitimate because it is a precondition for the downstream concept existing at all, not a downstream invariant.

Translation also has to handle the direction and timing of change. If the downstream pulls, it can translate on read and tolerate upstream changes until it redeploys. If the upstream pushes events, the downstream receives payloads shaped by a model it does not own, and must version its translation. The second is more decoupled at runtime and harder to reason about, because a translation failure is asynchronous and may be discovered long after the event was produced.

The failure mode: a canonical model that fits nobody

Large organisations periodically attempt a canonical enterprise model: one set of types, owned centrally, that every system maps to. The intent is to avoid pairwise translations, which grow as the square of the number of systems. The result is usually a model with the union of every system’s fields, most of them optional, and a change process that requires every system’s agreement.

The arithmetic that motivates it is real: twenty systems with pairwise translation need up to 190 mappings, and a hub needs twenty. But the hub model has to be right for all twenty, which means it must be either very abstract — in which case each system still maps its concepts to something generic and loses meaning — or very specific to one system’s view, in which case the others conform to it. Published-language standards solve this for mature domains, where the concepts are settled and the standard is not owned by any participant. They fail where the domain is still being discovered, because the standard freezes an understanding that is still moving.

A workable middle is a small published language for the few things that genuinely are shared — identifiers, dates with time zones, money with currency — plus per-pair translation for everything else. That keeps the hub small enough to be correct and keeps the interesting translation local to the pair that needs it. The vocabulary discipline that makes each context coherent internally is described in Ubiquitous Language; the boundary is what keeps two coherent vocabularies from having to be the same one.

References

  1. Domain-Driven Design: Tackling Complexity in the Heart of Software — Eric Evans, Addison-Wesley, 2003
  2. BoundedContext — Martin Fowler, bliki
  3. Data on the Outside versus Data on the Inside — Pat Helland, CIDR 2005