Lesson 13 / 18
Monolithic Architecture
Publishing two contexts of the same library as a single deployment unit: counting the boundaries an offer request crosses, its serialization points, and the bytes crossing the boundary; measuring how many files a one-file change republishes; and showing that a defect terminating the process brings down both contexts at once.
Contents
The previous topic covered how units talk to each other. Every interaction style was measured by the same three things: whether a unit is obligated to know its counterpart, the direction of coupling, and the data crossing the boundary. But all of those measurements passed over one question. Client and server, publisher and subscriber, pipe and filter — every one of these can be built inside a single running program. The publish–subscribe arrangement does not need a second process; an event bus object is enough.
This topic asks that question directly: into how many separate pieces is the software published? The answer is independent of the interaction style and has its own measurements. A deployment unit is a set of files that must be published together; when a release goes out, the entire set is replaced at once. Monolithic architecture is the style that keeps every context in a single deployment unit: the module count varies, but the deployment unit count is 1.
Two Contexts, One Process
The library has two bounded contexts. In the pricing context, a shipment is a priceable unit; in the delivery operation context, it is a physical parcel with a route and a carrier. Both are laid out plainly below.
// shared/fee.mjs — pricing context: the shipment is a priceable unit export const TARIFF = { tier: [[1, 3000], [5, 4800], [20, 9600]], zone: { "34": 100, "06": 115, "35": 125 }, minimum: 2500 }; export function baseFee(weight, zone) { const tier = TARIFF.tier.find(([k]) => weight <= k) ?? [0, 9600]; return Math.max(TARIFF.minimum, Math.round((tier[1] * TARIFF.zone[zone]) / 100)); } export function net(weight, zone, contractRate) { const t = baseFee(weight, zone); return t - Math.round(t * Math.min(contractRate, 0.4)); }
// shared/operation.mjs — delivery operation context: the shipment is a physical parcel with a route and a carrier export const TREE = { "34": ["34"], "06": ["34", "06"], "35": ["34", "41", "35"] }; export function route(zone) { return TREE[zone] ?? ["34"]; } export function carrier(zone) { return route(zone).length > 2 ? "MT" : "AN"; } export function deliveryDay(zone) { return route(zone).length; }
To count boundary crossings, each unit’s outer face is wrapped. This wrapper stays the same throughout the course: whichever style is being measured, it is the one collecting the crossings.
// shared/wrap.mjs — wraps a module's outer face; every call is recorded by unit name export function wrap(name, module, log) { const face = {}; for (const [k, v] of Object.entries(module)) { face[k] = typeof v === "function" ? (...a) => { log.push(`${name}.${k}`); return v(...a); } : v; } return face; }
An offer request touches both contexts at once: the net fee comes from pricing, the delivery day and the carrier come from operation. In the monolithic arrangement, this combination is a function call.
// monolith/application.mjs — both contexts in the same process, same memory, one deployment unit import * as feeModule from "../shared/fee.mjs"; import * as operationModule from "../shared/operation.mjs"; import { wrap } from "../shared/wrap.mjs"; export function application(log) { const fee = wrap("fee", feeModule, log); const operation = wrap("operation", operationModule, log); return { offer(s) { log.push("application.offer"); return { id: s.id, net: fee.net(s.weight, s.zone, s.rate), day: operation.deliveryDay(s.zone), carrier: operation.carrier(s.zone), }; }, }; }
Measuring the Request and the Release
The measurement produces two kinds of numbers. The first comes from the run: the units a request touches, the boundaries it crosses, the serialization points, and the bytes crossing the boundary. The second comes from the files: the deployment unit’s import closure, that is, the set of files that must be published together.
// monolith/measure.mjs — units a request touches, boundaries it crosses, the publish set, and the neighbor requirement import { readFileSync } from "node:fs"; import { dirname, join, normalize } from "node:path"; import { application } from "./application.mjs"; const log = []; const output = application(log).offer({ id: "G1", weight: 4, zone: "35", rate: 0.15 }); const unit = new Set(log.map((c) => c.split(".")[0])); console.log(`offer output = ${JSON.stringify(output)}`); console.log(`boundary crossing = ${log.length} (${log.join(" -> ")})`); console.log(`units touched = ${unit.size}, serialization point = 0, bytes crossing the boundary = 0`); const IMPORT = /from\s+"(\.[^"]+)"/g; function closure(entry) { const stack = [entry], visited = new Set(); while (stack.length > 0) { const d = normalize(stack.pop()); if (visited.has(d)) continue; visited.add(d); for (const [, y] of readFileSync(d, "utf8").matchAll(IMPORT)) stack.push(join(dirname(d), y)); } return [...visited].sort(); } const files = closure("monolith/application.mjs"); console.log(`deployment unit = 1, unit closure = ${files.length} files (${files.join(", ")})`); console.log(`if fee.mjs changes: files changed = 1, files republished = ${files.length}`); for (const g of ["shared/fee.mjs", "shared/operation.mjs"]) { console.log(`${g} neighbors needed to run alone = ${closure(g).length - 1}`); }
node monolith/measure.mjs
offer output = {"id":"G1","net":5100,"day":3,"carrier":"MT"}
boundary crossing = 4 (application.offer -> fee.net -> operation.deliveryDay -> operation.carrier)
units touched = 3, serialization point = 0, bytes crossing the boundary = 0
deployment unit = 1, unit closure = 4 files (monolith/application.mjs, shared/fee.mjs, shared/operation.mjs, shared/wrap.mjs)
if fee.mjs changes: files changed = 1, files republished = 4
shared/fee.mjs neighbors needed to run alone = 0
shared/operation.mjs neighbors needed to run alone = 0
Reading the Numbers
There are four boundary crossings, and all four are function calls. The serialization point is 0 and the bytes crossing the boundary are 0: the shipment object passed into pricing by its in-memory reference, not by a copy. This is the style’s most concrete gain. No format is negotiated for the data crossing the boundary, no version compatibility is debated, no transformation point is defined — because there is no transformation.
The second pair of numbers shows the style’s limit. If one line changes in the pricing rule, the file changed is 1, but the files republished are 4. A release that fixes the minimum fee in pricing also republishes the operation context’s code. This is not a coupling at the code level; the two contexts do not import each other. It is a coupling at the release level, and its source is the style itself.
The last two lines expose an important distinction. Both contexts are independently testable: neither needs the other’s file, and the neighbors needed are 0. So monolithic architecture does not block independent testability; what it blocks is independent release. Conflating these two is the most common mistake in style discussions.
Shared Fate
The monolithic style’s second limit is in failure behavior. Because both contexts run in the same process, a defect that terminates the process stops both at once. A per-request catch is not enough: a catch only holds an error raised on its own call stack, not one raised from a task deferred to later.
// monolith/failure.mjs — a defect that terminates the process even with a per-request catch import * as fee from "../shared/fee.mjs"; import * as operation from "../shared/operation.mjs"; const REQUEST = [ { unit: "fee", id: "G1" }, { unit: "operation", id: "G2" }, { unit: "fee", id: "G3", defective: true }, { unit: "operation", id: "G4" }, { unit: "fee", id: "G5" }, { unit: "operation", id: "G6" }, ]; let answered = 0; process.on("exit", () => { console.log(`answered requests = ${answered} / ${REQUEST.length}`); console.log(`units left standing = ${answered === REQUEST.length ? 2 : 0} / 2`); }); for (const request of REQUEST) { try { if (request.unit === "fee") { if (request.defective) setTimeout(() => { throw new RangeError(`no tariff tier: ${request.id}`); }); console.log(`${request.id} fee = ${fee.net(4, "35", 0.15)}`); } else { console.log(`${request.id} route = ${operation.route("35").join("-")}`); } answered += 1; } catch (e) { console.log(`${request.id} caught: ${e.message}`); } await new Promise((c) => setTimeout(c, 5)); }
The stack trace is written to the standard error stream; the command below separates it out and leaves only the counted output and the exit code.
node monolith/failure.mjs 2>/dev/null; echo "exit code = $?"
G1 fee = 5100 G2 route = 34-41-35 G3 fee = 5100 answered requests = 3 / 6 units left standing = 0 / 2 exit code = 1
Three of the six requests were answered. The defect was in the pricing context, but both of the operation requests, G4 and G6, went unanswered; the units left standing are 0. Failure isolation is not an unmeasurable quality in this style — it is measured, and the result is zero.
The Bill the Style Leaves Behind
The numbers describe a quality attribute trade-off, and both of its sides appear in this same lesson. On the gain side: bytes crossing the boundary are 0, serialization points are 0, and the number of processes to look at when tracing a request end to end is 1. On the loss side: a one-file change republishes 4 files, and across six requests the units left standing are 0.
The direction of this trade-off changes with team structure and release cadence. If the same team publishes both contexts on the same schedule, “files republished: 4” is not a cost; the release was going to happen together anyway. Once the contexts move to separate schedules, the same number turns directly into waiting time: the pricing release has to wait for the operation side to be stable.
The scaling limit comes from the same place. Because there is a single process, replication can only happen as a whole: when the number of pricing requests rises, the operation code is replicated right along with it. As long as the replicated unit count stays 1, information about which context is under load cannot enter the replication decision.
Summary
- A deployment unit is a set of files that must be published together; monolithic architecture is the style that keeps every context in a single deployment unit.
- The offer request touched 3 units and crossed 4 boundaries; because all four were function calls, the serialization points measured 0 and the bytes crossing the boundary measured 0.
- A change in one file republished 4 files; this is a release coupling, not a code coupling, and its source is the style itself.
- Both contexts could run on their own (neighbors needed: 0); the style blocks independent release, not independent testability.
- With a defect that terminated the process, 3 of six requests were answered and the units left standing were 0; failure isolation was measured, and it came out at zero.
- The direction of the trade-off is not fixed: if the contexts publish on the same schedule, the release coupling is not a cost; once they move to separate schedules, it becomes waiting time.
Next Step
The cost of release coupling shows up once the two contexts move to separate schedules. The first attempt at a solution is not to separate the contexts but to place a shared integration surface between them: each context ships to its own deployment unit, but both speak through a common record format and a common call path. This arrangement targets enterprise-wide reuse, and its measurement comes from there: the field count of the shared format, the number of transformation points at each boundary crossing, and the number of services forced to publish together when a field in the shared format changes. The next lesson measures those three numbers.
To keep your progress and take notes, Log in
My notes
Log in to take notes.