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

# Subdomains

Separating core, supporting, and generic subdomains: establishing the difference between a subdomain and the domain layer, deriving the effort share per subdomain from line and invariant counts, computing the change share from the change log, and measuring how effort and change align once the generic subdomain is thinned.

The previous four lessons drew the boundaries of contexts, named the relationships between
them, and counted the cost of three collaboration forms. All of these decisions left one
question unanswered: how much design effort should go to which part? Is the unit-of-measure
definition modeled with the same care as the fee rule? Is the postal-code format check
written with the same refinement as the contracted discount rule?

The domain is not equal within itself. Part of it is the work the organization wins by, part
keeps that work standing, and part is the same in every organization. These three parts are
called **subdomains**, and the distinction is not a classification exercise but a decision
about where to put effort. This lesson measures effort with line and invariant counts,
measures need with the change log, and derives the ratio of the two.

## Subdomain, Domain Layer, and Bounded Context

The three concepts carry similar-sounding names; the distinction needs to be made up front.

The **domain layer** (established in the Data Access Layer and Business Logic course) is a
place in the code's organization: the layer where business rules sit, indifferent to
presentation and infrastructure. A **subdomain** is not in the code but in the work itself: a
portion of what the organization does. A subdomain's code is usually scattered across several
layers — pricing's rule sits in the domain layer, reading the tariff table sits in the
infrastructure layer — and a single domain layer can house the model of more than one
subdomain.

A **bounded context**, by contrast, sits on the solution side: the area where a model holds.
A subdomain is a piece of the problem, a bounded context a piece of the solution. Mapping the
two one-to-one is a good target, but not a requirement; one context can carry the model of
two subdomains, and one subdomain can spread across two contexts. What this lesson measures
is not the context boundary but the effort spent per subdomain.

## Three Types

A **core subdomain** is where the organization sets itself apart from competitors. In the
pricing library, that is the fee rule and the contracted discount rule: the organization wins
the work with these two rules, both are the organization's own decision, and neither has a
counterpart outside.

A **supporting subdomain** is necessary for the work but makes no difference. Deriving the
delivery window from the service level is like this: without it the work does not run, but no
one chooses this organization because of it.

A **generic subdomain** is where every organization is the same. Validating the postal-code
format and cents arithmetic are like this. The distinguishing criterion is this: if someone
else's solution can be taken as it is, that part is a generic subdomain.

The decision behind the distinction is where effort goes: the core subdomain gets the most
modeling effort, the generic subdomain the least. This is not a preference but a measurable
alignment — and in practice it is often inverted.

## A Thickly Modeled Generic Subdomain

The two files below show a carefully modeled generic subdomain: the postal code is built as a
value object, carries five invariants, and has equality comparison and string-conversion
methods. Money is the same: four invariants, a currency field, arithmetic methods.

```sh
mkdir -p domain generic-thick generic-thin
```

```js
// generic-thick/postal-code.mjs — generic subdomain, thickly modeled: the postal code value
const PROVINCE_PREFIXES = new Set(["01", "06", "34", "35", "65", "81"]);

export class PostalCode {
  #text;
  constructor(text) {
    const violations = PostalCode.validate(text);
    if (violations.length > 0) throw new RangeError(`invalid postal code: ${violations.join(",")}`);
    this.#text = String(text).trim();
  }
  static validate(text) {
    const violations = [];
    const d = typeof text === "string" ? text.trim() : "";
    if (d.length === 0) violations.push("G1-empty");
    if (d.length !== 5) violations.push("G2-length");
    if (/\D/.test(d)) violations.push("G3-digit");
    if (d.startsWith("00")) violations.push("G4-zero-prefix");
    if (d.length === 5 && !/\D/.test(d) && !PROVINCE_PREFIXES.has(d.slice(0, 2))) violations.push("G5-province");
    return violations;
  }
  get provincePrefix() { return this.#text.slice(0, 2); }
  toString() { return this.#text; }
  equals(other) { return other instanceof PostalCode && other.toString() === this.#text; }
}

export const provincePrefix = (text) => (PostalCode.validate(text).length > 0 ? null : new PostalCode(text).provincePrefix);
```

```js
// generic-thick/money.mjs — generic subdomain, thickly modeled: the money value
export class Money {
  #cents; #currency;
  constructor(cents, currency = "TRY") {
    const violations = Money.validate(cents, currency);
    if (violations.length > 0) throw new RangeError(`invalid money: ${violations.join(",")}`);
    this.#cents = cents; this.#currency = currency;
  }
  static validate(cents, currency) {
    const violations = [];
    if (!Number.isInteger(cents)) violations.push("G1-integer");
    if (cents < 0) violations.push("G2-negative");
    if (cents > 1e12) violations.push("G3-overflow");
    if (currency !== "TRY") violations.push("G4-currency");
    return violations;
  }
  multiply(rate) { return new Money(Math.round(this.#cents * rate), this.#currency); }
  round(step) { return new Money(Math.round(this.#cents / step) * step, this.#currency); }
  get cents() { return this.#cents; }
}

export const multiply = (cents, rate) => new Money(cents).multiply(rate).cents;
export const round = (cents, step) => new Money(cents).round(step).cents;
```

It would be wrong to say either file is poorly written. Both are proper value objects, their
invariants are explicit, and they compare by value rather than identity. The problem is not
quality; it is place.

## Core and Supporting

The core subdomain's two files take the generic subdomain from outside: they call prefix
extraction, multiplication, and rounding through an object named `generic`. This lets both
generic versions be tested against the same rule.

```js
// domain/fee-rule.mjs — core subdomain: the rule the organization wins the work by
const TIERS = [
  { maxChargeableGrams: 1000, feeCents: 4990 },
  { maxChargeableGrams: 5000, feeCents: 8490 },
  { maxChargeableGrams: 30000, feeCents: 24990 },
];
const ZONE_FACTOR = { "01": 1.35, "06": 1.35, "34": 1, "35": 1.35, "65": 1.8, "81": 1.8 };
const MINIMUM_FEE_CENTS = 3990;

export function validate(shipment) {
  const violations = [];
  if (!(Number.isInteger(shipment.chargeableGrams) && shipment.chargeableGrams > 0)) violations.push("C1-chargeable-weight");
  if (shipment.chargeableGrams > 30000) violations.push("C2-tier-exceeded");
  if (typeof shipment.postalCode !== "string") violations.push("C3-address");
  return violations;
}

export function calculateFee(shipment, generic) {
  if (validate(shipment).length > 0) return null;
  const prefix = generic.provincePrefix(shipment.postalCode);
  if (prefix === null) return null;
  const tier = TIERS.find((t) => shipment.chargeableGrams <= t.maxChargeableGrams);
  const raw = generic.multiply(tier.feeCents, ZONE_FACTOR[prefix]);
  return Math.max(generic.round(raw, 50), MINIMUM_FEE_CENTS);
}
```

```js
// domain/discount-rule.mjs — core subdomain: the contracted discount rule
const VOLUME_TIERS = [
  { monthlyShipments: 500, rate: 0.05 },
  { monthlyShipments: 2000, rate: 0.12 },
  { monthlyShipments: Infinity, rate: 0.2 },
];
const RATE_CAP = 0.3;

export function validate(contract) {
  const violations = [];
  if (!Number.isInteger(contract.monthlyShipments) || contract.monthlyShipments < 0) violations.push("C1-volume");
  if (!(contract.negotiatedRate >= 0 && contract.negotiatedRate <= RATE_CAP)) violations.push("C2-negotiated-rate");
  if (contract.negotiatedRate > 0 && contract.contractNo === null) violations.push("C3-negotiated-without-contract");
  return violations;
}

export function discountedFee(grossCents, contract, generic) {
  if (validate(contract).length > 0) return null;
  const tier = VOLUME_TIERS.find((t) => contract.monthlyShipments <= t.monthlyShipments);
  const rate = Math.min(tier.rate + contract.negotiatedRate, RATE_CAP);
  return generic.multiply(grossCents, 1 - rate);
}
```

```js
// domain/delivery-window.mjs — supporting subdomain: the delivery window from the service level
const WINDOW_HOURS = { SS1: 24, SS2: 48, SS3: 72 };

export function validate(serviceLevelCode) {
  const violations = [];
  if (typeof serviceLevelCode !== "string") violations.push("D1-type");
  if (WINDOW_HOURS[serviceLevelCode] === undefined) violations.push("D2-unknown-level");
  return violations;
}

export const windowHours = (serviceLevelCode) =>
  (validate(serviceLevelCode).length > 0 ? 72 : WINDOW_HOURS[serviceLevelCode]);
```

## Thinning the Generic Subdomain

The thin version leaves only as much of the generic subdomain as the core genuinely needs:
prefix extraction and two arithmetic operations. There is no value object, no equality
comparison, no currency field, because the library never used them.

```js
// generic-thin/postal-code.mjs — generic subdomain, thin: only as much as the domain needs
const PROVINCE_PREFIXES = new Set(["01", "06", "34", "35", "65", "81"]);

export function provincePrefix(text) {
  const d = typeof text === "string" ? text.trim() : "";
  if (!/^\d{5}$/.test(d) || d.startsWith("00") || !PROVINCE_PREFIXES.has(d.slice(0, 2))) return null;
  return d.slice(0, 2);
}
```

```js
// generic-thin/money.mjs — generic subdomain, thin: only as much as the domain needs
const valid = (cents) => Number.isInteger(cents) && cents >= 0 && cents <= 1e12;

export const multiply = (cents, rate) => {
  if (!valid(cents)) throw new RangeError("invalid money");
  return Math.round(cents * rate);
};
export const round = (cents, step) => Math.round(cents / step) * step;
```

## The Distribution of Effort and Change

Effort is measured with two indicators: line count per subdomain and the number of
invariants defined. Need is measured by how twenty model changes distribute across
subdomains. The third number is the ratio of the two, and one value is an exact threshold: if
effort share equals change share, the ratio is 1.

```js
// effort-count.mjs — effort share, change share, and their ratio per subdomain
import { readFileSync } from "node:fs";

const SUBDOMAIN = {
  "domain/fee-rule.mjs": "core", "domain/discount-rule.mjs": "core",
  "domain/delivery-window.mjs": "supporting",
  "generic-thick/postal-code.mjs": "generic", "generic-thick/money.mjs": "generic",
  "generic-thin/postal-code.mjs": "generic", "generic-thin/money.mjs": "generic",
};

const LOG = [
  ["fee-rule", 6], ["discount-rule", 5], ["delivery-window", 4],
  ["postal-code", 3], ["money", 2],
];
const CONCEPT_SUBDOMAIN = { "fee-rule": "core", "discount-rule": "core",
  "delivery-window": "supporting", "postal-code": "generic", "money": "generic" };
const TOTAL_CHANGES = LOG.reduce((t, [, n]) => t + n, 0);

const TYPES = ["core", "supporting", "generic"];
const percent = (a, b) => `${((a / b) * 100).toFixed(1).padStart(5)}%`;

for (const [name, files] of [
  ["thick generic", ["domain/fee-rule.mjs", "domain/discount-rule.mjs", "domain/delivery-window.mjs",
                      "generic-thick/postal-code.mjs", "generic-thick/money.mjs"]],
  ["thin generic", ["domain/fee-rule.mjs", "domain/discount-rule.mjs", "domain/delivery-window.mjs",
                     "generic-thin/postal-code.mjs", "generic-thin/money.mjs"]],
]) {
  const lines = {}, invariants = {};
  for (const t of TYPES) { lines[t] = 0; invariants[t] = 0; }
  for (const d of files) {
    const text = readFileSync(d, "utf8");
    const t = SUBDOMAIN[d];
    lines[t] += text.trimEnd().split("\n").length;
    invariants[t] += (text.match(/violations\.push/g) ?? []).length;
  }
  const totalLines = TYPES.reduce((s, t) => s + lines[t], 0);
  const totalInvariants = TYPES.reduce((s, t) => s + invariants[t], 0);
  console.log(`${name}: ${totalLines} lines, ${totalInvariants} invariants, ${TOTAL_CHANGES} changes`);
  for (const t of TYPES) {
    const chg = LOG.filter(([k]) => CONCEPT_SUBDOMAIN[k] === t).reduce((s, [, n]) => s + n, 0);
    const effortShare = lines[t] / totalLines, changeShare = chg / TOTAL_CHANGES;
    console.log(`  ${t.padEnd(12)} lines ${String(lines[t]).padStart(3)} (${percent(lines[t], totalLines)})`
      + `  invariants ${String(invariants[t]).padStart(2)} (${percent(invariants[t], totalInvariants)})`
      + `  change ${String(chg).padStart(2)} (${percent(chg, TOTAL_CHANGES)})`
      + `  effort/change ${(effortShare / changeShare).toFixed(2)}`);
  }
}
```

```sh
node effort-count.mjs
```

```
thick generic: 108 lines, 17 invariants, 20 changes
  core         lines  47 ( 43.5%)  invariants  6 ( 35.3%)  change 11 ( 55.0%)  effort/change 0.79
  supporting   lines  12 ( 11.1%)  invariants  2 ( 11.8%)  change  4 ( 20.0%)  effort/change 0.56
  generic      lines  49 ( 45.4%)  invariants  9 ( 52.9%)  change  5 ( 25.0%)  effort/change 1.81
thin generic: 75 lines, 8 invariants, 20 changes
  core         lines  47 ( 62.7%)  invariants  6 ( 75.0%)  change 11 ( 55.0%)  effort/change 1.14
  supporting   lines  12 ( 16.0%)  invariants  2 ( 25.0%)  change  4 ( 20.0%)  effort/change 0.80
  generic      lines  16 ( 21.3%)  invariants  0 (  0.0%)  change  5 ( 25.0%)  effort/change 0.85
```

The thick arrangement's first line gives the misalignment: 45.4 percent of the code is in the
generic subdomain, yet only 25 percent of the changes touch it. The core subdomain is in the
reverse situation: it takes 55 percent of the changes and holds 43.5 percent of the code. The
ratios are 1.81 and 0.79 — more than double apart.

The invariant count shows a sharper misalignment. The generic subdomain defined nine
invariants, the core subdomain six. For the two rules that win the organization its work, six
invariants were written, while for the postal code and money — the same in every
organization — nine were written.

In the thin arrangement, the three ratios fall into the 1.14–0.80–0.85 range; the generic
subdomain's line share drops from 45.4 percent to 21.3 percent. The generic subdomain no
longer defines a domain invariant: the two checks in the thin version are each a guard
clause, not a domain rule. Total code drops from 108 lines to 75, and the freed-up effort is
left for the core.

## Is the Core Unchanged?

That thinning did not break the core is not asserted; it is tested. The same core rules run
against both generic versions.

```js
// run.mjs — did thinning the generic subdomain change the core's results?
import { calculateFee } from "./domain/fee-rule.mjs";
import { discountedFee } from "./domain/discount-rule.mjs";
import * as thickPostal from "./generic-thick/postal-code.mjs";
import * as thickMoney from "./generic-thick/money.mjs";
import * as thinPostal from "./generic-thin/postal-code.mjs";
import * as thinMoney from "./generic-thin/money.mjs";

const THICK = { provincePrefix: thickPostal.provincePrefix, multiply: thickMoney.multiply, round: thickMoney.round };
const THIN = { provincePrefix: thinPostal.provincePrefix, multiply: thinMoney.multiply, round: thinMoney.round };

const SHIPMENTS = [
  { chargeableGrams: 800, postalCode: "34710" }, { chargeableGrams: 4000, postalCode: "06800" },
  { chargeableGrams: 20000, postalCode: "65100" }, { chargeableGrams: 20000, postalCode: "99999" },
  { chargeableGrams: 0, postalCode: "34710" }, { chargeableGrams: 40000, postalCode: "34710" },
];
const CONTRACTS = [
  { monthlyShipments: 1500, negotiatedRate: 0.05, contractNo: "S-7" },
  { monthlyShipments: 100, negotiatedRate: 0, contractNo: null },
  { monthlyShipments: 100, negotiatedRate: 0.1, contractNo: null },
];

let differing = 0;
for (const s of SHIPMENTS) {
  const a = calculateFee(s, THICK), b = calculateFee(s, THIN);
  if (a !== b) differing += 1;
  console.log(`${String(s.chargeableGrams).padStart(5)} g ${s.postalCode}  thick ${String(a).padStart(5)}  thin ${String(b).padStart(5)}`);
}
for (const c of CONTRACTS) {
  const a = discountedFee(45000, c, THICK), b = discountedFee(45000, c, THIN);
  if (a !== b) differing += 1;
  console.log(`volume ${String(c.monthlyShipments).padStart(4)} negotiated ${c.negotiatedRate}  thick ${String(a).padStart(5)}  thin ${String(b).padStart(5)}`);
}
console.log(`differing result = ${differing} / ${SHIPMENTS.length + CONTRACTS.length}`);
```

```sh
node run.mjs
```

```
  800 g 34710  thick  5000  thin  5000
 4000 g 06800  thick 11450  thin 11450
20000 g 65100  thick 45000  thin 45000
20000 g 99999  thick  null  thin  null
    0 g 34710  thick  null  thin  null
40000 g 34710  thick  null  thin  null
volume 1500 negotiated 0.05  thick 37350  thin 37350
volume  100 negotiated 0  thick 42750  thin 42750
volume  100 negotiated 0.1  thick  null  thin  null
differing result = 0 / 9
```

The result matches in all nine cases: three valid fees, an unrecognized province prefix, zero
weight, a tier-exceeding weight, two discount calculations, and a negotiated-rate attempt
without a contract, all included. Four of the thick version's five invariants never paid off
in any call, because the core never exercised them.

## The Limit of the Distinction

A subdomain's type is not a fixed label. What is generic for one organization can be core for
another; address resolution is not a generic subdomain for an organization that wins its
delivery business on address quality. The distinction is read not from the domain itself but
from where the organization competes, and it changes over time.

The second limit is in the measure itself. Line count is a crude indicator of effort; a
rule's difficulty does not grow with its line count. That is why the measure does not decide
anything by itself — it raises a question: why does the part that changes most carry the
least modeling?

## Summary

- A subdomain is a portion of the work; the domain layer is a place in the code's
  organization, and a bounded context is the area where a model holds. A subdomain's code is
  scattered across several layers.
- Three types are distinguished: a core subdomain is where the organization wins its work, a
  supporting subdomain is necessary but makes no difference, and a generic subdomain is where
  someone else's solution can be taken as it is.
- In the thick arrangement, 45.4 percent of the code was in the generic subdomain, but only
  25 percent of the changes touched it; the core subdomain took 55 percent of the changes and
  held 43.5 percent of the code. The ratios were 1.81 and 0.79.
- The invariant count confirmed the misalignment: the generic subdomain had defined nine
  invariants, the core subdomain six.
- Once the generic subdomain was thinned, the three ratios fell into the 1.14–0.80–0.85
  range, total code dropped from 108 lines to 75, and the core's results were unchanged in
  all nine cases.
- A subdomain's type depends on the organization and changes over time; line count is a
  crude indicator of effort, and the measure does not decide — it raises a question.

## Next Step

This topic established where a model holds: the boundary runs through the place a name's
meaning changes, the relationships between contexts are named by direction and type, the
outside model's leakage is stopped with a layer, and the subdomain distinction says how much
effort goes to which part. The question left open sits inside a context: how does the model
in that context fit inside an application? Where does a request enter, in what order does it
descend to which object, who draws the boundary of the transaction, who does the result
return to? In every example up to this point, this arrangement stayed implicit; rules were
called directly. The next topic defines the responsibility of the application layer, and its
first lesson takes use cases as that layer's unit: a thin shell that calls the domain model,
starts and ends the transaction, but does not carry the business rule itself.
