Skip to content
academia.sh

Lesson 02 / 18

Modular Monolith

Protecting boundaries inside the code: measuring the import graph across domains, counting the boundary violations where one module reaches into another, the files touched and the public surface that grows when the rule is put in place; process and deployment unit stay a single one throughout.

Contents

The previous lesson measured the monolith: seven modules, ten import edges, one process, one deployment unit. What the measurement left open was which rule those ten edges were drawn by. The answer was: no rule at all. A module could import any file of any other module; nothing stopped it.

This lesson closes that gap. The same loan workflow is rebuilt with its domains split into directories; first the state where boundaries erode on their own is measured, then the boundary is protected by a rule. This arrangement, where boundaries are protected inside the code, is called a modular monolith. The process count, the deployment unit count, and the workflow’s output do not change at all; the only thing that changes is the import graph. This is the one place where the architecture can be measured independently of deployment.

The monolith here carries the same word as monolithic persistence in the Performance Anti-Patterns and Monitoring course, but it is a separate thing: there, the single item is the data store; here, the single item is the deployment unit.

The Arrangement

AO3. State is kept in memory because the system runs within a single execution. This simplification, which replaces the first lesson’s file store, does not affect the measurement: what is measured is the import graph.

Each of the five domains is a directory. Each directory has an index.mjs that is exposed to the outside, along with the domain’s internal files.

# setup.sh — same workflow as 01, domains became directories; state is kept in memory for a single run
rm -rf broken && mkdir -p broken/catalog broken/membership broken/pricing \
  broken/notification broken/loan && cd broken
cat > store.mjs <<'JS'
export const data = { book: { 1: { status: "on_shelf" } },
                      member: { 4: { name: "Derek", open: 1, limit: 3, balance: 0 } } };
JS
cat > catalog/record.mjs <<'JS'
import { data } from "../store.mjs";
export const book = (id) => data.book[id];
JS
cat > catalog/index.mjs <<'JS'
import { book } from "./record.mjs";
export const status = (id) => book(id).status;
JS
cat > membership/record.mjs <<'JS'
import { data } from "../store.mjs";
export const member = (id) => data.member[id];
JS
cat > membership/index.mjs <<'JS'
import { member } from "./record.mjs";
export const isEligible = (id) => member(id).open < member(id).limit;
export const addOpenLoan = (id) => { member(id).open += 1; };
JS
cat > pricing/index.mjs <<'JS'
import { member } from "../membership/record.mjs";
export const fee = (days) => (days > 0 ? days * 2 : 0);
export const addToBalance = (id, amount) => { member(id).balance += amount; };
JS
cat > notification/index.mjs <<'JS'
import { member } from "../membership/record.mjs";
export const send = (id, text) => console.log(`  notification -> ${member(id).name}: ${text}`);
JS
cat > loan/index.mjs <<'JS'
import { book } from "../catalog/record.mjs";
import * as membership from "../membership/index.mjs";
import * as pricing from "../pricing/index.mjs";
import * as notification from "../notification/index.mjs";
export function issueLoan(bookId, memberId, daysLate = 0) {
  if (membership.isEligible(memberId) === false) throw new Error("loan limit exceeded");
  if (book(bookId).status !== "on_shelf") throw new Error("book not on shelf");
  book(bookId).status = "on_loan";
  membership.addOpenLoan(memberId);
  const amount = pricing.fee(daysLate);
  if (amount > 0) pricing.addToBalance(memberId, amount);
  notification.send(memberId, `book ${bookId} loan issued`);
  return amount;
}
JS
cat > app.mjs <<'JS'
import { issueLoan } from "./loan/index.mjs";
import { status } from "./catalog/index.mjs";
const amount = issueLoan(1, 4, 3);
console.log(`loan issued: book 1 -> member 4, fee ${amount}`);
console.log(`  catalog: book 1 = ${status(1)}`);
JS
find . -name "*.mjs" | sort
node app.mjs
./app.mjs
./catalog/index.mjs
./catalog/record.mjs
./loan/index.mjs
./membership/index.mjs
./membership/record.mjs
./notification/index.mjs
./pricing/index.mjs
./store.mjs
  notification -> Derek: book 1 loan issued
loan issued: book 1 -> member 4, fee 6
  catalog: book 1 = on_loan

The listing is relative to the broken/ directory. The workflow runs, the fee is calculated, the notification goes out. The directory split looks tidy on inspection.

Measuring the Boundary Violation

The rule to be measured is this: a domain may import only the index.mjs file of another domain. Every import outside of that is a boundary violation — one module reaching into another.

// boundary.mjs — domain boundary rule: only index.mjs may be imported from another domain
import { readdirSync, readFileSync, statSync } from "node:fs";
const root = process.argv[2] ?? ".";
const domains = readdirSync(root).filter((a) => statSync(`${root}/${a}`).isDirectory()).sort();
let edge = 0, violation = 0, surface = 0;
for (const domain of domains) {
  for (const file of readdirSync(`${root}/${domain}`).sort()) {
    if (file.endsWith(".mjs") === false) continue;
    const text = readFileSync(`${root}/${domain}/${file}`, "utf8");
    if (file === "index.mjs") surface += [...text.matchAll(/^export /gm)].length;
    for (const [, target] of text.matchAll(/from "(\.[^"]+)"/g)) {
      edge += 1;
      const parts = target.split("/");
      if (parts[0] === ".." && parts.length === 3 && parts[2] !== "index.mjs") {
        violation += 1;
        console.log(`VIOLATION ${domain}/${file} -> ${target}`);
      }
    }
  }
}
console.log(`domain: ${domains.length}  import edge: ${edge}  public surface: ${surface}  boundary violation: ${violation}`);
node boundary.mjs broken
VIOLATION loan/index.mjs -> ../catalog/record.mjs
VIOLATION notification/index.mjs -> ../membership/record.mjs
VIOLATION pricing/index.mjs -> ../membership/record.mjs
domain: 5  import edge: 10  public surface: 7  boundary violation: 3

Three violations. All three are single-line imports added later, none of which bothered anyone. Pricing reached straight into the member record to raise the member’s balance; notification did the same to read the member’s name; loan opened the catalog record to read the book’s status. None of them produced an error, because everything is reachable within the same process.

The cost of this shows up not in the violation count, but in how a change spreads. If the record format inside membership/record.mjs changes, two foreign domains have to be fixed along with membership’s own index.mjs.

Putting the Rule in Place

The fix turns the three outward-reaching imports into the domain’s public surface: the missing operations are added to the relevant domain’s index.mjs, and the three violating files are rewritten to go through this new surface.

# fix.sh — the rule is applied: each domain is only reachable through its index.mjs
rm -rf modular && cp -r broken modular && cd modular
cat > catalog/index.mjs <<'JS'
import { book } from "./record.mjs";
export const status = (id) => book(id).status;
export const isOnShelf = (id) => book(id).status === "on_shelf";
export const markOnLoan = (id) => { book(id).status = "on_loan"; };
JS
cat > membership/index.mjs <<'JS'
import { member } from "./record.mjs";
export const isEligible = (id) => member(id).open < member(id).limit;
export const addOpenLoan = (id) => { member(id).open += 1; };
export const name = (id) => member(id).name;
export const addToBalance = (id, amount) => { member(id).balance += amount; };
JS
cat > pricing/index.mjs <<'JS'
import { addToBalance as addToMemberBalance } from "../membership/index.mjs";
export const fee = (days) => (days > 0 ? days * 2 : 0);
export const addToBalance = (id, amount) => addToMemberBalance(id, amount);
JS
cat > notification/index.mjs <<'JS'
import { name } from "../membership/index.mjs";
export const send = (id, text) => console.log(`  notification -> ${name(id)}: ${text}`);
JS
cat > loan/index.mjs <<'JS'
import * as catalog from "../catalog/index.mjs";
import * as membership from "../membership/index.mjs";
import * as pricing from "../pricing/index.mjs";
import * as notification from "../notification/index.mjs";
export function issueLoan(bookId, memberId, daysLate = 0) {
  if (membership.isEligible(memberId) === false) throw new Error("loan limit exceeded");
  if (catalog.isOnShelf(bookId) === false) throw new Error("book not on shelf");
  catalog.markOnLoan(bookId);
  membership.addOpenLoan(memberId);
  const amount = pricing.fee(daysLate);
  if (amount > 0) pricing.addToBalance(memberId, amount);
  notification.send(memberId, `book ${bookId} loan issued`);
  return amount;
}
JS
echo "file rewritten: 5"
node app.mjs
file rewritten: 5
  notification -> Derek: book 1 loan issued
loan issued: book 1 -> member 4, fee 6
  catalog: book 1 = on_loan

The output is identical to the first run. Nothing changed on the caller’s side.

node boundary.mjs modular
echo "external file reaching into membership's internal file -> broken: $(grep -l 'membership/record.mjs' broken/*/*.mjs | wc -l | tr -d ' ')  modular: $(grep -l 'membership/record.mjs' modular/*/*.mjs | wc -l | tr -d ' ')"
domain: 5  import edge: 10  public surface: 11  boundary violation: 0
external file reaching into membership's internal file -> broken: 2  modular: 0

Three numbers should be read. Violations dropped from 3 to 0. The import edge count stayed at 10: the rule does not reduce the edge count, it changes what the edges point to; three edges moved from a domain’s internal file to that domain’s public surface. The public surface went from 7 to 11 — four newly exported names.

The last line gives the real payoff. The number of files reaching into membership’s internal file from outside dropped from 2 to 0. When the record format inside membership/record.mjs changes, the files that need touching are 3 in the broken version (its own index.mjs plus two foreign domains) and 1 in the modular version.

addToBalance in the pricing domain turned into a wrapper that calls membership’s function of the same name. This is the direct cost of the rule: every new cross-domain need adds a layer of indirection and widens the public surface.

The Rule Staying in Place

This arrangement has a weakness: the rule belongs to the checker, not the compiler.

# leak.sh — a one-line import is added to the modular version; the program keeps running
sed -i.bak '1i\
import { member } from "../membership/record.mjs";
' modular/notification/index.mjs
rm -f modular/notification/index.mjs.bak
( cd modular && node app.mjs | head -n 1 )
node boundary.mjs modular | tail -n 2
  notification -> Derek: book 1 loan issued
VIOLATION notification/index.mjs -> ../membership/record.mjs
domain: 5  import edge: 11  public surface: 11  boundary violation: 1

The program kept running; no test broke. Only the checker spoke up. The violation count went from 0 to 1, the import edge count from 10 to 11. A boundary violation is not a runtime error, it is a measurement: left unmeasured, it grows freely. The -i option used for in-place editing behaves differently between the GNU and BSD versions, so a backup extension is given and then removed here; it produces the same result on both.

Three Columns

Modular monolith
Cheapens Files touched when a domain’s internal structure changes dropped from 3 to 1; the boundary became a countable measure
Makes expensive Public surface went from 7 to 11; every cross-domain need adds a layer of indirection
Failure mode created The rule only holds while a checker runs; a one-line import silently rolled back the boundary without breaking anything

The numbers that did not change are worth noting too: process 1, deployment unit 1, network hop 0. A modular monolith is not a deployment decision, it is a code organization decision. All domains still ship together, and an infinite loop in one domain still stops all of them together.

Summary

  • Even after domains were split into directories, the import graph eroded on its own: three domains had reached into a neighbor’s internal file, and none of them produced an error.
  • A boundary violation is a measurable unit: every line that imports a file other than index.mjs from another domain is a violation.
  • When the rule was put in place, 5 files were rewritten, violations dropped from 3 to 0, and import edges stayed at 10; the rule does not reduce edges, it moves their endpoints to the public surface.
  • The cost is the public surface widening from 7 to 11, and a wrapper being added for every cross-domain need.
  • The rule depends on the checker; a one-line import brought the violation back without breaking the program.

Next Step

The modular monolith protected the boundary inside the code, but it never questioned one thing: store.mjs keeps holding the shared data structure for every domain. Catalog and membership both write to different branches of the same object, so the boundary exists in the import graph but not in the data. The next lesson grows that shared ground: what happens when domains become separate units even while they talk through a shared contract and a common data model — it measures how many units a single change in the shared layer touches.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close