---
title: 'Monolithic Application'
source: 'https://academia.sh/en/courses/service-architectures/monolithic-application'
course: 'Service Architectures'
language: en
updated: '2026-08-23T07:00:29+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

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

```js
// 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);
}
```

```js
// 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); }
```

```js
// 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); }
```

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

```js
// 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 };
}
```

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

```sh
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.

```js
// 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`);
```

```sh
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.

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