Skip to content
academia.sh

Lesson 14 / 19

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.

Contents

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.

mkdir -p domain generic-thick generic-thin
// 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);
// 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.

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

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

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

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

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close