Lesson 01 / 18
Monolithic Application
Building and measuring a working monolithic loan service: seven modules, ten import edges, one process, zero network hops; the cheapness of a local call, the ease of change that a single deployment unit brings, and the scaling limit that appears when that same unit is replicated.
Contents
The previous course, Caching, Queues and Asynchronous Processing, closed by pointing at a single assumption: every queue, delivery guarantee, and backpressure mechanism it built was built inside a single deployment unit. This course takes on splitting that unit apart. Before anything can be split, what is being split has to stand on its own.
This lesson builds and measures a working monolith implementation of the library loan service. What it builds is the base for the next five lessons: each one splits it by one step, or counts the cost of splitting over these same measurements. The definition of architectural styles was covered in the Architectural Styles course; the work here is not definition, it is implementation and measurement.
The Boundary of the System
AO1. State is kept in a single JSON file. In a real deployment this would be a shared data store; the property the model carries is that state lives outside the process, in one place.
AO2. Modules talk to each other in the same process, through a local function call. Network latency, serialization, and partial failure do not exist in this model; they are measured with real processes in the fourth lesson.
The System: A Monolithic Loan Service
The system carries five business domains: catalog, membership, loans, notification, and pricing. There is a shared module that holds the state.
// store.mjs — state for all modules; single file, single process import { existsSync, readFileSync, writeFileSync } from "node:fs"; const PATH = "data.json"; const INITIAL = { book: { 1: { title: "Blindness", status: "on_shelf" }, 2: { title: "The Book of Sand", status: "on_shelf" } }, member: { 4: { name: "Derek", open: 1, limit: 3, balance: 0 }, 5: { name: "Grace", open: 3, limit: 3, balance: 0 } }, loans: [], }; export function read() { if (existsSync(PATH) === false) writeFileSync(PATH, JSON.stringify(INITIAL)); return JSON.parse(readFileSync(PATH, "utf8")); } export function write(d) { writeFileSync(PATH, JSON.stringify(d)); }
Three business modules read from and write to this store; the notification module carries no state.
// catalog.mjs — book records import { read, write } from "./store.mjs"; export function status(bookId) { return read().book[bookId].status; } export function markOnLoan(bookId) { const d = read(); if (d.book[bookId].status !== "on_shelf") throw new Error("book not on shelf"); d.book[bookId].status = "on_loan"; write(d); }
// membership.mjs — member records and loan limit import { read, write } from "./store.mjs"; export function isEligible(memberId) { const m = read().member[memberId]; return m.open < m.limit; } export function addOpenLoan(memberId) { const d = read(); d.member[memberId].open += 1; write(d); }
// pricing.mjs — late fee import { read, write } from "./store.mjs"; export const DAILY_RATE = 2; export function fee(daysLate) { return daysLate > 0 ? daysLate * DAILY_RATE : 0; } export function addToBalance(memberId, amount) { const d = read(); d.member[memberId].balance += amount; write(d); }
// notification.mjs — notification to member export function send(memberId, text) { console.log(` notification -> member ${memberId}: ${text}`); }
The module that runs the workflow calls all four of them in order.
// loan.mjs — loan-issuing workflow; calls the four modules in sequence import { read, write } from "./store.mjs"; import * as catalog from "./catalog.mjs"; import * as membership from "./membership.mjs"; import * as pricing from "./pricing.mjs"; import * as notification from "./notification.mjs"; export function issueLoan(bookId, memberId, daysLate = 0) { if (membership.isEligible(memberId) === false) throw new Error("loan limit exceeded"); catalog.markOnLoan(bookId); membership.addOpenLoan(memberId); const amount = pricing.fee(daysLate); if (amount > 0) pricing.addToBalance(memberId, amount); const d = read(); d.loans.push({ bookId, memberId }); write(d); notification.send(memberId, `book ${bookId} loan issued`); return { bookId, memberId, amount }; }
// app.mjs — single entry point; runs the workflow start to finish import { issueLoan } from "./loan.mjs"; import { status } from "./catalog.mjs"; const [bookId = 1, memberId = 4, daysLate = 0] = process.argv.slice(2).map(Number); try { const s = issueLoan(bookId, memberId, daysLate); console.log(`loan issued: book ${s.bookId} -> member ${s.memberId}, fee ${s.amount}`); } catch (h) { console.log("failed:", h.message); } console.log(` catalog: book ${bookId} = ${status(bookId)}`);
Running the Workflow
Four requests test four paths: a loan that succeeds, a second request for a book already on loan, a member at their limit, and a record that produces a late fee.
rm -f data.json node app.mjs 1 4 echo "--- same book a second time ---" node app.mjs 1 4 echo "--- member at the limit ---" node app.mjs 2 5 echo "--- record with a late fee ---" node app.mjs 2 4 3
notification -> member 4: book 1 loan issued loan issued: book 1 -> member 4, fee 0 catalog: book 1 = on_loan --- same book a second time --- failed: book not on shelf catalog: book 1 = on_loan --- member at the limit --- failed: loan limit exceeded catalog: book 2 = on_shelf --- record with a late fee --- notification -> member 4: book 2 loan issued loan issued: book 2 -> member 4, fee 6 catalog: book 2 = on_loan
What stands out in the third run is that the membership check ran before the catalog
check: because the limit was exceeded, the book was never marked at all. What keeps this
ordering in place is that both checks sit in the same process, in the same issueLoan body.
After the fifth lesson this ordering will turn into a contract; for now it is a single if
line.
Measurement
The numbers the architectural discussion will carry come from reading the source files.
// measure.mjs — module, import edge and cross-module call count in the workflow import { readdirSync, readFileSync } from "node:fs"; const EDGE = /from "\.\/([\w-]+\.mjs)"/g; const files = readdirSync(".").filter((a) => a.endsWith(".mjs") && a !== "measure.mjs").sort(); let total = 0; for (const f of files) { const target = [...readFileSync(f, "utf8").matchAll(EDGE)].map((m) => m[1].replace(".mjs", "")); total += target.length; console.log(`${f.padEnd(18)} -> ${target.join(", ") || "(none)"}`); } const flow = [...readFileSync("loan.mjs", "utf8") .matchAll(/\b(catalog|membership|pricing|notification)\.\w+\(/g)]; console.log(`module: ${files.length} import edge: ${total}`); console.log(`cross-module call in the workflow: ${flow.length} network hop: 0`); console.log(`process: 1 deployment unit: 1 startup step: 1`);
node measure.mjs
app.mjs -> loan, catalog catalog.mjs -> store loan.mjs -> store, catalog, membership, pricing, notification membership.mjs -> store notification.mjs -> (none) pricing.mjs -> store store.mjs -> (none) module: 7 import edge: 10 cross-module call in the workflow: 6 network hop: 0 process: 1 deployment unit: 1 startup step: 1
Seven modules, ten import edges, six cross-module calls. All six of these calls are local:
they pass parameters on the stack, receive the return value directly, and let errors
propagate through throw. The network hop count is zero. Bringing the system up is a single
step; running the loan workflow end to end does not require waiting for any other process to
be ready.
These three numbers — six local calls, zero hops, one deployment unit — say on their own what
the monolith cheapens. There is no such thing as a call getting lost halfway through: a
function either returns or throws. Consistency between catalog and membership does not
require building any mechanism, because both write to the same data.json file from the same
process. A change that spans all five domains is tested in a single build and ships in a
single deployment.
The Scaling Limit
The cost shows up in the fact that this same unit cannot be split. When load on the loan path rises, the only thing that can be done is to replicate the entire unit.
# scale.sh — three copies of the same monolith; each copy carries every module rm -rf copy1 copy2 copy3 for k in copy1 copy2 copy3; do mkdir "$k" for m in store catalog membership pricing notification loan app; do cp "$m.mjs" "$k/"; done ( cd "$k" && node app.mjs 1 4 | grep '^loan' | sed "s|^|$k: |" ) done echo "distributed module file: $(ls copy*/*.mjs | wc -l | tr -d ' ')"
copy1: loan issued: book 1 -> member 4, fee 0 copy2: loan issued: book 1 -> member 4, fee 0 copy3: loan issued: book 1 -> member 4, fee 0 distributed module file: 21
Two numbers should be read here. First: even though only the loan path needed extra capacity, the number of distributed module files went from 7 to 21. Three copies of the notification and pricing code stand ready even though nothing needed them. The unit of scaling is the entire codebase; knowing which module is the bottleneck does not shrink this unit.
Second, the run itself: all three copies loaned out the same book. Because each copy carries
its own data.json file, all three saw the book as on the shelf. In a real deployment, state
would move to a shared store and this tripling would disappear — but then all copies would
load onto a single store. This is where the monolith’s scaling limit sits: the process can be
replicated, the state cannot.
There is a limit on the change side too. Because there is a single deployment unit, even a one-line change to the fee rate republishes the catalog and membership modules along with it. In measurement terms: one file touched, seven modules redeployed.
Three Columns
Every lesson in this course compares the same job across two implementations and fills in three columns.
| Monolith | |
|---|---|
| Cheapens | Local calls, a shared transaction boundary, a single build, a single deployment step |
| Makes expensive | The scaling unit is the entire codebase; a one-line change redeploys seven modules |
| Failure mode created | A memory leak or infinite loop in one module stops every domain together |
The third row was not measured, because in a single process the only way to measure it is to crash the process. It is left as a record: the same failure is tried in separate processes in the fourth lesson, for comparison.
Summary
- The library loan service was built as a monolith: 7 modules, 10 import edges, 1 process, 1 deployment unit; the loan workflow runs start to finish.
- All 6 cross-module calls in the workflow are local; network hops are zero, and the startup step count is one.
- A single transaction boundary and local calls keep catalog and membership consistent without any extra mechanism.
- The scaling limit shows up under replication: adding capacity to only the loan path turned 7 modules into 21 files, because the unit of scaling is the entire codebase.
- A single deployment unit means even a one-line fee change redeploys every module.
Next Step
One of the measured numbers has not been questioned yet: loan.mjs imports all four modules
directly, but nothing stops notification.mjs from importing catalog.mjs too, or catalog
reaching into membership. The monolith has no boundary inside its code; the import graph grows
toward a complete graph if nobody watches it. The next lesson measures this graph, counts the
boundary violations where one module reaches into another, and builds the arrangement
where the boundary is protected by a rule inside the code: the process is still one, the
deployment unit is still one, but import edges are no longer free.
To keep your progress and take notes, Log in
My notes
Log in to take notes.