---
title: 'Monolithic Architecture'
source: 'https://academia.sh/en/courses/architectural-styles/monolithic-architecture'
course: 'Architectural Styles'
language: en
updated: '2026-08-23T07:01:09+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

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

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

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

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

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

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

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

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