---
title: 'Scalability Decisions'
source: 'https://academia.sh/en/courses/architecture-governance/scalability-decisions'
course: 'Quality Attributes and Governance'
language: en
updated: '2026-08-23T07:01:05+00:00'
license: 'CC BY-SA 4.0'
---

# Scalability Decisions

Where the scale unit's boundary gets drawn: counting the copied component, the shared remaining resource, and the singleton that cannot be copied for the whole-system, service, and tenant candidates, and what the copy rule catches, misses, and false-alarms on in past scaling attempts.

The trust boundary graph separated zones from each other, but did not say how many copies of
each zone run. Once the system grows, this question comes to the front, and it usually gets
asked the wrong way. How many copies are needed and how capacity gets computed was measured in
the Performance Anti-Patterns and Monitoring course, in the Capacity Planning lesson — node count
is the larger of the load constraint and the failure constraint; it is not repeated here. The
question here comes before the arithmetic, and it is one sentence: **what gets copied.**

The copied whole is called a scale unit. The term is the same one used in the Deployment Stamps
and Geo-Replicas lesson; the question there was how many copies of the unit are needed, the
question here is where the unit's boundary gets drawn. The boundary can be drawn in different
places, and each drawing copies one thing and leaves another shared. This lesson lines up three
candidates and pulls out three numbers: the copied component, the shared remaining resource, the
singleton that cannot be copied.

## Three Scale Unit Candidates

The block below **models** the fictional regional library network's components, the
dependencies between them, and three scale unit candidates. The network serves several
municipalities; a tenant means one municipality's full set of branches. Each candidate is a rule
that selects which component gets copied.

```js
// unit.mjs — models three scale unit candidates and counts what gets copied, shared, singleton
import { mkdirSync, writeFileSync } from "node:fs";

// QA17: the fictional regional library network's fourteen components. The network serves
// several municipalities; a tenant means one municipality's full set of branches. Fields:
// [code, name, state, splits by tenant, reason it cannot be copied]. Model.
const COMPONENT = [
  ["C1", "loan service", "stateless", true, ""],
  ["C2", "fee service", "stateless", true, ""],
  ["C3", "membership service", "stateless", true, ""],
  ["C4", "notification sender", "stateless", true, ""],
  ["C5", "management console", "stateless", false, ""],
  ["C6", "search index", "stateful", true, ""],
  ["C7", "catalog cache", "stateful", false, ""],
  ["C8", "loan database", "stateful", true, ""],
  ["C9", "membership database", "stateful", true, ""],
  ["C10", "fee ledger", "stateful", true, ""],
  ["C11", "external catalog connector", "stateless", false, "single external contract"],
  ["C12", "identity connector", "stateless", false, "single external contract"],
  ["C13", "number generator", "stateful", false, "global sequence"],
  ["C14", "audit record store", "stateful", false, "single-writer obligation"],
];

// QA18: eighteen dependencies between components [source, target, kind]. Model.
const DEPENDENCY = [
  ["C1", "C8", "writer"], ["C1", "C9", "reader"], ["C1", "C13", "writer"], ["C1", "C14", "writer"],
  ["C1", "C7", "reader"], ["C2", "C10", "writer"], ["C2", "C8", "reader"], ["C2", "C14", "writer"],
  ["C3", "C9", "writer"], ["C3", "C12", "reader"], ["C3", "C14", "writer"], ["C4", "C8", "reader"],
  ["C4", "C9", "reader"], ["C5", "C14", "reader"], ["C5", "C10", "reader"], ["C6", "C7", "reader"],
  ["C11", "C7", "writer"], ["C11", "C6", "writer"],
];

// A scale unit is the whole that runs as more than one concurrent copy. The term is the same
// as in the deployment stamp lesson; there the question was how many copies the unit needs,
// here it is where the unit's boundary gets drawn.
const CANDIDATE = {
  "whole system": (b) => b[4] === "",
  service: (b) => b[4] === "" && b[2] === "stateless",
  tenant: (b) => b[4] === "" && b[3],
};
const singleton = COMPONENT.filter((b) => b[4] !== "");
const find = (k) => COMPONENT.find((b) => b[0] === k);

mkdirSync("model", { recursive: true });
writeFileSync("model/model.mjs",
  `export const COMPONENT = ${JSON.stringify(COMPONENT)};\n` +
  `export const DEPENDENCY = ${JSON.stringify(DEPENDENCY)};\n` +
  `export const CANDIDATE = ${JSON.stringify(Object.keys(CANDIDATE))};\n`);

console.log(`${COMPONENT.length} components, ${DEPENDENCY.length} dependencies, ` +
  `${DEPENDENCY.filter((e) => e[2] === "writer").length} of them writes`);
console.log(`\n${"scale unit candidate".padEnd(22)}${"copied".padStart(9)}` +
  `${"shared".padStart(9)}${"singleton".padStart(11)}${"dependency into the shared part".padStart(34)}`);
for (const [name, pick] of Object.entries(CANDIDATE)) {
  const copy = COMPONENT.filter(pick);
  const shared = COMPONENT.filter((b) => b[4] === "" && !pick(b));
  const into = DEPENDENCY.filter(([, h]) => shared.some((p) => p[0] === h)).length;
  console.log(`${name.padEnd(22)}${String(copy.length).padStart(9)}` +
    `${String(shared.length).padStart(9)}${String(singleton.length).padStart(11)}` +
    `${String(into).padStart(34)}`);
}

console.log(`\nsingleton that cannot be copied under any candidate (${singleton.length}):`);
for (const b of singleton) console.log(`  ${b[0].padEnd(4)}${b[1].padEnd(28)}${b[4]}`);
const intoSingleton = DEPENDENCY.filter(([, h]) => singleton.some((t) => t[0] === h));
console.log(`dependencies into a singleton ${intoSingleton.length}, ` +
  `${intoSingleton.filter((e) => e[2] === "writer").length} of them writes; ` +
  `writing components: ${[...new Set(intoSingleton.filter((e) => e[2] === "writer")
    .map((e) => find(e[0])[1]))].join(", ")}`);
```

```
14 components, 18 dependencies, 9 of them writes

scale unit candidate     copied   shared  singleton   dependency into the shared part
whole system                 10        0          4                                 0
service                       5        5          4                                12
tenant                        8        2          4                                 3

singleton that cannot be copied under any candidate (4):
  C11 external catalog connector  single external contract
  C12 identity connector          single external contract
  C13 number generator            global sequence
  C14 audit record store          single-writer obligation
dependencies into a singleton 6, 4 of them writes; writing components: loan service, fee service, membership service
```

The three candidates' copied column differs from each other: the whole system copies ten
components, tenant eight, service five. But the singleton column is four under all three
candidates. **Changing the scale unit does not change the singletons.** Wherever the boundary is
drawn, four components stay at one copy, because the reason they cannot be copied has nothing to
do with the unit's boundary: two are bound to a single external contract, one produces a global
sequence, one carries a single-writer obligation. The last two are a technical constraint, the
first two are not. Singleness that comes from a contract does not get solved by an architectural
decision; it gets solved by a different decision, changing the contract, and that sits outside
this scale unit discussion.

The real cost shows in the last column. The service candidate copies the fewest components and
leaves twelve dependencies flowing into the shared remainder; the tenant candidate leaves three,
the whole-system candidate leaves zero. **The count of copied components and the load left in the
shared part move in opposite directions**, and this relationship is the candidates' real
comparison: the service candidate is cheap to copy but rests twelve dependencies on a shared
resource; each is a bottleneck candidate as the copies grow.

The whole-system candidate's zero needs a careful read. Zero does not mean nothing is shared;
that candidate's shared column is empty because everything shared moved into the singleton column
instead. Four of the six dependencies into the four singletons are writes, and the writers are
three separate services. So whichever candidate gets picked, three services keep writing to a
component that stays at one copy. Choosing a scale unit does not remove these three writes, it
only changes which boundary they show up inside.

## The Copy Rule

Three columns fill a table; the next question is whether this can be turned into an executable
rule. The rule can be written like this: a component inside a scale unit must not write to a
stateful component outside the unit. If it does, two copies of the unit touch the same state
concurrently. The block below writes this rule and runs it against ten scaling steps attempted
over the past year.

```js
// copyrule.mjs — writes the scale unit rule and runs it against attempted scaling steps
import { COMPONENT, DEPENDENCY } from "./model/model.mjs";

const find = (k) => COMPONENT.find((b) => b[0] === k);
const PICK = {
  "whole system": (b) => b[4] === "",
  service: (b) => b[4] === "" && b[2] === "stateless",
  tenant: (b) => b[4] === "" && b[3],
};
// Under the service candidate the unit is a single service; under the other two candidates the unit
// is every component the candidate selects. Rule: a component inside the unit must not write to a
// stateful component outside the unit. If it does, the copies touch the same state concurrently.
const unit = (candidate, component) =>
  (candidate === "service" ? [component] : COMPONENT.filter(PICK[candidate]).map((b) => b[0]));
const violatingEdge = (u) => DEPENDENCY.filter(([k, h, t]) =>
  t === "writer" && u.includes(k) && !u.includes(h) && find(h)[2] === "stateful");

// QA19: known set of violations — ten scaling steps attempted over the last year, and their
// real outcome [candidate, component, outcome, note]. Model.
const ATTEMPT = [
  ["service", "C1", "problem", "two copies writing to the global loan number"],
  ["service", "C2", "problem", "duplicate entry in the fee ledger"],
  ["service", "C3", "problem", "duplicate write to the membership database"],
  ["service", "C4", "no problem", "read-only"],
  ["service", "C5", "no problem", "read-only"],
  ["service", "C6", "no problem", "the index gets rebuilt per copy"],
  ["service", "C7", "problem", "two copies share the same local disk path"],
  ["service", "C11", "problem", "the single external contract does not tolerate two copies"],
  ["tenant", "", "no problem", "writes split by tenant key, audit record is append-only"],
  ["whole system", "", "problem", "two stamps writing to the audit record store"],
];

console.log(`${"candidate".padEnd(14)}${"unit".padEnd(28)}${"rule".padEnd(11)}` +
  `${"actual".padEnd(12)}outcome`);
let caught = 0, missed = 0, falseAlarm = 0, silence = 0;
for (const [candidate, code, actual, note] of ATTEMPT) {
  const u = unit(candidate, code);
  const edge = violatingEdge(u);
  const fires = edge.length > 0;
  const outcome = fires && actual === "problem" ? "caught"
    : fires ? "false alarm" : actual === "problem" ? "missed" : "correct silence";
  if (outcome === "caught") caught += 1;
  else if (outcome === "missed") missed += 1;
  else if (outcome === "false alarm") falseAlarm += 1;
  else silence += 1;
  console.log(`${candidate.padEnd(14)}${(code ? find(code)[1] : "whole candidate").padEnd(28)}` +
    `${(fires ? `fires (${edge.length})` : "silent").padEnd(11)}${actual.padEnd(12)}${outcome}`);
}
console.log(`\n${ATTEMPT.length} attempts, ${ATTEMPT.filter((d) => d[2] === "problem").length} had a problem: ` +
  `caught ${caught}, missed ${missed}, false alarm ${falseAlarm}, correct silence ${silence}`);
for (const d of ATTEMPT) {
  const fires = violatingEdge(unit(d[0], d[1])).length > 0;
  if ((fires && d[2] === "no problem") || (!fires && d[2] === "problem"))
    console.log(`  ${fires ? "false alarm" : "missed"}: ${d[0]}/${d[1] ? find(d[1])[1] : "whole"} — ${d[3]}`);
}

// QA20: the part that cannot be checked. The rule looks at declared dependencies; it cannot see
// two copies sharing an undeclared resource (the same disk path, the same process, the same
// scheduler). In its place: a copy test — run two copies side by side and watch for a collision,
// 6 person-hours per attempt. The test shows the missed attempt the moment the copies come up.
const HOURS = 6;
console.log(`\ncannot be checked: an undeclared shared resource; this is why the missed attempt was missed`);
console.log(`stands in its place: a copy test, ${ATTEMPT.length} attempts x ${HOURS} = ` +
  `${ATTEMPT.length * HOURS} person-hours; the test catches the missed case and separates the false alarm too`);
const field = COMPONENT.length * 5 + DEPENDENCY.length * 3;
console.log(`rule cost: ${field} fields held by hand (${COMPONENT.length} components x 5, ` +
  `${DEPENDENCY.length} dependencies x 3); run ${ATTEMPT.length * DEPENDENCY.length} edge ` +
  `comparisons; if the record goes stale, missed grows`);
```

```
candidate     unit                        rule       actual      outcome
service       loan service                fires (3)  problem     caught
service       fee service                 fires (2)  problem     caught
service       membership service          fires (2)  problem     caught
service       notification sender         silent     no problem  correct silence
service       management console          silent     no problem  correct silence
service       search index                silent     no problem  correct silence
service       catalog cache               silent     problem     missed
service       external catalog connector  fires (2)  problem     caught
tenant        whole candidate             fires (4)  no problem  false alarm
whole system  whole candidate             fires (4)  problem     caught

10 attempts, 6 had a problem: caught 5, missed 1, false alarm 1, correct silence 3
  missed: service/catalog cache — two copies share the same local disk path
  false alarm: tenant/whole — writes split by tenant key, audit record is append-only

cannot be checked: an undeclared shared resource; this is why the missed attempt was missed
stands in its place: a copy test, 10 attempts x 6 = 60 person-hours; the test catches the missed case and separates the false alarm too
rule cost: 124 fields held by hand (14 components x 5, 18 dependencies x 3); run 180 edge comparisons; if the record goes stale, missed grows
```

Six of the ten attempts had produced a problem. The rule catches five, misses one, false-alarms
once, and correctly stays silent three times. Correct silence gets counted separately, because a
rule's value does not live only in its firings: the rule stayed silent on the notification
sender, management console, and search index attempts, and none of the three had a problem.
Counting only the firings would make the rule's accuracy look like five out of six; counting the
silences too, it is eight out of ten.

The missed attempt is the catalog cache. Two copies collided because they shared the same local
disk path, but this sharing is not in the dependency list — because the list carries dependencies
between components, not a component's bond with its environment. **The rule looks at declared
dependencies; an undeclared sharing sits outside the rule**, and this is a matter of the rule's
scope, not its sensitivity.

The false alarm shows up on the tenant candidate. The rule sees something true: components inside
the tenant unit write to four stateful points outside the unit. But every one of those writes is
either split by the tenant key or append-only to the audit record, so two copies never touch the
same row. **The rule sees that a write exists, not the shape the write takes.** Telling apart a
split write from a colliding write needs one more field in the dependency record, and that field
gets filled in by hand for every dependency.

There is also one row where the right outcome came from the wrong reason. In the external catalog
connector attempt, the rule fired and the attempt really did produce a problem, but the cause was
the single external contract, not the write edge the rule pointed at. If the edge the rule
flagged had been fixed, the problem would have persisted exactly the same. **The caught count
does not measure whether the reason is correct**; that distinction only shows up once every
catch's cause gets read.

The part of the rule that cannot be checked is the undeclared shared resource, and that is
exactly why the one attempt got missed. In its place a copy test is set up: two copies run side
by side and the collision gets watched for, six person-hours per attempt, 60 person-hours across
the ten attempts. The test shows the missed case the moment the copies come up, and it separates
out the false alarm too, because a split write produces no collision to watch for. The rule's own
cost is a separate line item: 124 fields declared by hand for fourteen components and eighteen
dependencies. The run itself, 180 edge comparisons, carries no cost worth measuring; a stale
declaration, though, turns directly into a missed case.

## Summary

- A scale unit is the whole that runs as more than one concurrent copy; this lesson asks not
  how many copies the unit needs but where its boundary gets drawn.
- The three candidates differ in what gets copied (whole system 10, tenant 8, service 5) but not
  in the singleton: four components cannot be copied under any of them, and two of the reasons
  are contractual, not technical.
- The copied and the shared-remaining counts move in opposite directions: the service candidate
  copies the least and leaves twelve dependencies flowing into the shared resource, tenant three,
  whole system zero.
- The copy rule gives 5 caught, 1 missed, 1 false alarm, 3 correct silences across ten attempts;
  the miss is a matter of scope, not sensitivity, and the false alarm comes from the rule not
  seeing the shape of the write.
- In one catch the reason is wrong: the rule pointed at a write edge, but the problem's cause was
  the single external contract; the copy test standing in for what cannot be checked costs 60
  person-hours across ten attempts.

## Next Step

Everything counted so far — the quality attribute tree, the tension matrix, the trust boundary
graph, the scale unit — requires changing the system. Changing itself has never been measured.
The next lesson runs the same set of changes against two separate structures and counts, per
change, the files touched, the modules touched, and how many steps out the change spreads. Then
it tests the metrics themselves: do these numbers show decay over time, or do they hold steady
while the structure gets worse.
