Lesson 07 / 18
Boundary-Drawing Criteria
Measuring from the code where the boundary should pass: counting the inter-module import edge as a coupling measure, determining each table's owner and finding non-owner writes, change coupling extracted from the change log, and comparing three candidate boundary drawings by the same measures.
Contents
The previous topic built the options spanning from the monolith to the serverless approach, and counted the cost of each in deployment units, processes, and deployment steps. All the options shared one common assumption: where the boundary passes was taken as given. Yet the most expensive decision on that list is not the name of the chosen architectural option — it is the location of the boundary. A boundary drawn in the wrong place touches every change on both sides, no matter which architecture is chosen.
This lesson ties the boundary’s location to a measure, and the measure lives inside the code. Domain modeling, bounded contexts, and the context map were built in the Domain-Driven Design course; the domain model is not taught here. The question here is narrower: given a working modular monolith, which number decides where the boundary is drawn. The through-line is the library loan system, and it has five domain modules: catalog, membership, loan, notification, billing.
The Application to Measure
The block below writes the application to disk and runs the workflow for issuing a book loan. The module files are generated from a map; each module carries in its own source which modules it imports, which tables it writes to, and which table it owns.
// setup.mjs — writes the library loan application as module files and runs the workflow import { mkdirSync, writeFileSync } from "node:fs"; const MODULE = { // imports: domain modules | writes: tables | owns: tables catalog: { imports: [], writes: ["book", "copy"], owns: ["book", "copy"] }, membership: { imports: [], writes: ["member"], owns: ["member"] }, loan: { imports: ["catalog", "membership"], writes: ["loan", "copy"], owns: ["loan"] }, billing: { imports: ["loan"], writes: ["fee", "loan"], owns: ["fee"] }, notification: { imports: ["membership", "loan"], writes: ["notification"], owns: ["notification"] }, }; mkdirSync("app", { recursive: true }); writeFileSync("app/store.mjs", "export const records = [];\n" + "export const write = (table, row) => records.push({ table, ...row });\n"); for (const [name, m] of Object.entries(MODULE)) { writeFileSync(`app/${name}.mjs`, `// owns: ${m.owns.join(", ")}\n` + 'import { write, records } from "./store.mjs";\n' + m.imports.map((i) => `import { ${i}Read } from "./${i}.mjs";\n`).join("") + `export const ${name}Read = () => records.filter((k) => k.table === "${m.owns[0]}");\n` + `export function ${name}Process(data) {\n` + m.imports.map((i) => ` ${i}Read();\n`).join("") + m.writes.map((t) => ` write("${t}", { step: "${name}", ...data });\n`).join("") + "}\n"); } writeFileSync("app/app.mjs", 'import { records } from "./store.mjs";\n' + 'import { membershipProcess } from "./membership.mjs";\n' + 'import { catalogProcess } from "./catalog.mjs";\n' + 'import { loanProcess } from "./loan.mjs";\n' + 'import { notificationProcess } from "./notification.mjs";\n' + 'import { billingProcess } from "./billing.mjs";\n' + 'const request = { member: 7, book: 412 };\n' + "membershipProcess(request); catalogProcess(request); loanProcess(request);\n" + "notificationProcess(request); billingProcess(request);\n" + 'export const written = records.map((k) => `${k.step}->${k.table}`);\n'); const { written } = await import("./app/app.mjs"); console.log(`module files: ${Object.keys(MODULE).length} domain modules + store + app entry`); console.log(`workflow (issuing a loan for a book) produced ${written.length} writes:`); console.log(written.join(" "));
module files: 5 domain modules + store + app entry workflow (issuing a loan for a book) produced 8 writes: membership->member catalog->book catalog->copy loan->loan loan->copy notification->notification billing->fee billing->loan
This is the starting point: one deployment unit, one process, eight writes. In this form the system runs and there is no boundary at all. The boundary-drawing decision comes after this.
Two Measures Read From the Code
The first two measures are extracted directly from the source files. Coupling is reduced here to a single number: an import a module makes from another domain module is one edge. Data ownership is the second measure: every table has exactly one owner module, and a non-owner module writing to that table is counted separately.
// measure.mjs — reads the app/ directory setup.mjs wrote; the measure is extracted from the code import { readFileSync, readdirSync } from "node:fs"; const files = readdirSync("app").filter((d) => d !== "store.mjs" && d !== "app.mjs"); const M = {}; for (const d of files) { const t = readFileSync(`app/${d}`, "utf8"); M[d.replace(".mjs", "")] = { imports: [...t.matchAll(/from "\.\/(\w+)\.mjs"/g)].map((m) => m[1]).filter((a) => a !== "store"), writes: [...t.matchAll(/write\("(\w+)"/g)].map((m) => m[1]), owns: t.match(/^\/\/ owns: (.+)$/m)[1].split(", "), }; } const names = Object.keys(M); const ownerOf = (table) => names.find((a) => M[a].owns.includes(table)); const edges = names.flatMap((a) => M[a].imports.map((b) => [a, b])); const foreignWrites = names.flatMap((a) => M[a].writes.filter((t) => ownerOf(t) !== a).map((t) => [a, t])); console.log(`${"module".padEnd(15)}${"imports".padEnd(20)}${"writes".padEnd(15)}owns`); for (const a of names) { console.log(`${a.padEnd(15)}${(M[a].imports.join(",") || "-").padEnd(20)}` + `${M[a].writes.join(",").padEnd(15)}${M[a].owns.join(",")}`); } console.log(`total: ${names.length} modules, ${edges.length} import edges, ` + `${names.reduce((s, a) => s + M[a].writes.length, 0)} writes, ` + `${foreignWrites.length} non-owner writes (${foreignWrites.map(([a, t]) => `${a}->${t}`).join(", ")})`);
module imports writes owns billing loan fee,loan fee catalog - book,copy book,copy loan catalog,membership loan,copy loan membership - member member notification membership,loan notification notification total: 5 modules, 5 import edges, 8 writes, 2 non-owner writes (billing->loan, loan->copy)
Five edges and two non-owner writes. Within a single deployment unit these two writes are merely a layout flaw: the loan module changes copy status directly, and the billing module puts an overdue marker on the loan record. Because both are in the same process, inside the same transaction, they cost nothing today. The moment the boundary is drawn, these turn into boundary violations: a service writing to another service’s table now means a dependency on either an endpoint or a shared table.
The Third Measure: Change Coupling
The third measure is not visible in the source files; it lives in the codebase’s history. Change coupling is how often two modules change together in the same change. The measure is this: a boundary that separates two frequently co-changing modules spreads every change across two deployment units.
SB1 — the last quarter’s change log is twenty lines, and each line gives the modules a change touched. Rationale: every change in the version history can be reduced from the files it touched to a set of modules; this lesson fixes the result of that reduction as input. If the count and the distribution change, the ranking below changes with them; the measure itself does not.
// candidates.mjs — reads the same directory, compares three candidate boundary drawings by the same measures import { readFileSync, readdirSync } from "node:fs"; const M = {}; for (const d of readdirSync("app").filter((x) => x !== "store.mjs" && x !== "app.mjs")) { const t = readFileSync(`app/${d}`, "utf8"); M[d.replace(".mjs", "")] = { imports: [...t.matchAll(/from "\.\/(\w+)\.mjs"/g)].map((m) => m[1]).filter((a) => a !== "store"), writes: [...t.matchAll(/write\("(\w+)"/g)].map((m) => m[1]), owns: t.match(/^\/\/ owns: (.+)$/m)[1].split(", "), }; } const names = Object.keys(M); const ownerOf = (table) => names.find((a) => M[a].owns.includes(table)); const edges = names.flatMap((a) => M[a].imports.map((b) => [a, b])); const foreignWrites = names.flatMap((a) => M[a].writes.filter((t) => ownerOf(t) !== a).map((t) => [a, t])); // SB1: last quarter's change log — each line is one change and the modules it touched const CHANGES = [ "late fee rate: loan billing", "loan extension: loan billing", "fee exemption: billing", "reminder text: notification", "contact preference: membership notification", "membership suspension: membership loan", "genre tag: catalog", "search field: catalog", "copy status: catalog loan", "reservation queue: catalog loan", "penalty threshold: loan billing", "member address field: membership", "notification channel: notification", "renewal reminder: membership notification", "overdue days calculation: loan billing", "lost copy: catalog loan billing", "cover image: catalog", "authentication: membership", "return flow: loan", "fee refund: billing", ].map((s) => [s.split(": ")[0], s.split(": ")[1].split(" ")]); const pairs = {}; for (const [, ms] of CHANGES) { for (let i = 0; i < ms.length; i += 1) { for (let j = i + 1; j < ms.length; j += 1) { const c = [ms[i], ms[j]].sort().join(" + "); pairs[c] = (pairs[c] ?? 0) + 1; } } } console.log(`${CHANGES.length} changes with pairs that changed together (SB1):`); for (const [c, n] of Object.entries(pairs).sort((a, b) => b[1] - a[1])) { console.log(` ${c.padEnd(30)}${n}`); } const CANDIDATE = { "A reference/transaction": [["catalog", "membership"], ["loan", "billing", "notification"]], "B per-entity": [["catalog"], ["membership"], ["loan"], ["billing"], ["notification"]], "C change coupling": [["loan", "billing"], ["catalog"], ["membership", "notification"]], }; console.log(`\n${"candidate".padEnd(26)}${"units".padStart(6)}${"cross-boundary imports".padStart(24)}` + `${"ownership violations".padStart(22)}${"kept in one unit".padStart(19)}${"deployments".padStart(13)}`); for (const [a, split] of Object.entries(CANDIDATE)) { const unitOf = (m) => split.findIndex((b) => b.includes(m)); const touched = CHANGES.map(([, ms]) => new Set(ms.map(unitOf)).size); const total = touched.reduce((s, n) => s + n, 0); console.log(`${a.padEnd(26)}${String(split.length).padStart(6)}` + `${String(edges.filter(([x, y]) => unitOf(x) !== unitOf(y)).length).padStart(24)}` + `${String(foreignWrites.filter(([x, t]) => unitOf(x) !== unitOf(ownerOf(t))).length).padStart(22)}` + `${`${touched.filter((n) => n === 1).length}/${CHANGES.length}`.padStart(19)}` + `${String(total).padStart(13)}`); } // Same change, four implementations: SB1's first line const [dName, dMods] = CHANGES[0]; console.log(`\n"${dName}" change (${dMods.join(", ")}) across four implementations:`); console.log(`${"implementation".padEnd(26)}${"files touched".padStart(16)}${"deployment units".padStart(18)}` + `${"imports->network".padStart(19)}${"writes crossing boundary".padStart(27)}`); for (const [g, split] of Object.entries({ "modular monolith": [names], ...CANDIDATE })) { const unitOf = (m) => split.findIndex((b) => b.includes(m)); console.log(`${g.padEnd(26)}${String(dMods.length).padStart(16)}` + `${String(new Set(dMods.map(unitOf)).size).padStart(18)}` + `${String(edges.filter(([x, y]) => dMods.includes(x) && dMods.includes(y) && unitOf(x) !== unitOf(y)).length).padStart(19)}` + `${String(foreignWrites.filter(([x, t]) => dMods.includes(x) && unitOf(x) !== unitOf(ownerOf(t))).length).padStart(27)}`); }
20 changes with pairs that changed together (SB1): billing + loan 5 catalog + loan 3 membership + notification 2 loan + membership 1 billing + catalog 1 candidate units cross-boundary imports ownership violations kept in one unit deployments A reference/transaction 2 3 1 14/20 26 B per-entity 5 5 2 10/20 31 C change coupling 3 3 1 16/20 24 "late fee rate" change (loan, billing) across four implementations: implementation files touched deployment units imports->network writes crossing boundary modular monolith 2 1 0 0 A reference/transaction 2 1 0 1 B per-entity 2 2 1 2 C change coupling 2 1 0 1
Reading the Three Candidates
The most frequently co-changing pair is loan and billing: five of the twenty changes touch both at once. Catalog and loan change together three times, membership and notification twice.
Candidate B, one service per entity, is the cleanest-looking drawing and the most expensive by measure. All five of the five import edges cross the boundary, both of the two ownership violations turn into boundary violations, and only ten of the twenty changes stay within a single unit. The total deployment count across the twenty changes is 31.
Candidates A and C are equal on coupling — both give three edges and one violation. The distinction shows up in the third measure: A keeps fourteen of the changes within a single unit, C keeps sixteen; total deployments are 26 against 24. The difference comes from A separating membership and notification — that pair changes together twice, and each time A touches two units.
The rule that emerges from this: coupling and ownership narrow the field of candidates; change coupling makes the decision. The first two measures read the code’s current state, the third reads how the code changes; the boundary is drawn by the third, because the boundary’s cost is paid at every change.
The Same Change, Four Implementations
The last table follows a single change: the late fee rate, which touches loan and billing. The number of files touched is two in all four implementations; drawing a boundary does not reduce a change’s code volume. What changes is where those two files belong.
What it made cheaper. In the modular monolith and in candidate C, the change stays within a single deployment unit: one verification, one deployment, one rollback. C does this with no difference from the monolith, while it has still split the system into three independent units — catalog changes never touch the loan unit at all.
What it made more expensive. In candidate B, the same change touches two deployment units, one import edge turns into a network call, and two writes cross the boundary. Billing writing to the loan table is no longer writing to its own data — it is writing to another unit’s data; either that unit opens an endpoint, or the table is shared between two units. Either way means a new contract.
What new failure mode it created. In the monolith, both files are released together and there is never an inconsistent moment between them. Split across two deployment units, a window opens between the two releases: the new rate is already in effect in billing, but loan is still sending the old field. This is a failure mode that does not exist in the monolith, and it is born from the boundary itself. The one ownership violation left in A and C belongs to the same class: the loan module writing copy status will, the moment catalog moves to a separate unit, either turn into a call or lead to two units writing the same row.
Summary
- The boundary’s location is tested with three measures, all three read from the code: import edges (coupling), owner module per table (data ownership), and the module set of changes (change coupling).
- The measured application has 5 domain modules, 5 import edges, and 8 writes; two of the writes come from a non-owner module, and while they cost nothing inside a single deployment unit, they turn into boundary violations once the boundary is drawn.
- The one-service-per-entity drawing is the worst on all three measures: 5 of 5 edges cross the boundary, both of the 2 violations remain, only 10 of 20 changes stay within a single unit, and the total deployment count is 31.
- Two candidates equal on coupling are separated by change coupling: 14/20 against 16/20, and 26 against 24 deployments; a boundary that separates a frequently co-changing pair touches two units at every change.
- The same change touches 2 files in all four implementations; the boundary does not change the file count — it changes the deployment unit count, the network call count, and the number of writes that cross the boundary.
Next Step
The boundary has been tied to a measure, but the moment it is drawn, a number that sat quietly in the table starts to work: an import turning into a network call. An import edge between modules is a function call within the same process, and its cost is measured on the stack. The same edge between two deployment units is a network call, and its cost is measured in rounds. The next lesson builds this conversion with real processes: the same loan-issuing workflow runs first in a single process, then in separate processes, and as the chain grows longer, how the end-to-end round count grows is measured. The same run asks a second question — when one of these processes is down, what share of incoming requests goes unanswered.
To keep your progress and take notes, Log in
My notes
Log in to take notes.