---
title: 'Boundary-Drawing Criteria'
source: 'https://academia.sh/en/courses/service-architectures/boundary-drawing-criteria'
course: 'Service Architectures'
language: en
updated: '2026-08-23T07:00:30+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

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

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

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