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

# Maintainability Criteria

Observing the cost of change directly: running the same twelve changes against two structures and counting the files touched, modules touched, and spread depth per change, testing whether the metrics show decay over six quarters, and what a threshold rule catches, misses, and false-alarms on against a real cost record.

Every measure in this topic proposed a change. The quality attribute tree showed the missing
checks, the tension matrix showed which decision suppressed what, the trust boundary graph
showed which crossing went unchecked, the scale unit showed what gets copied. All of them shared
one assumption — that the system can be changed — and that assumption was never measured.
Maintainability was a root attribute in the tree, the most suppressed attribute in the tension
matrix; but in both places it was read indirectly, from leaf values. This lesson measures it
directly: the same set of changes gets run against two separate structures.

Technical debt's interest was measured in the Architectural Decisions and Documentation course,
in the decision record topic, and it is not repeated here. The cost of change is that interest's
observed face — interest does not show up as a line item, it shows up as the extra file touched
by every change. Three quantities get measured: the files touched per change, the modules
touched, and how many steps out a change spreads.

## The Same Change in Two Structures

The block below **models** two structures. The module set is the same in both; two things
differ — the dependency graph and how concepts are placed into modules. The same twelve change
requests get run against both structures. A request that changes a module's surface also touches
the modules that depend on it.

```js
// structure.mjs — runs the same change set against two structures and measures change cost
import { mkdirSync, writeFileSync } from "node:fs";

// QA25: the fictional regional library network's two structures carrying the same functionality. The module
// set is the same in both; the dependency graph and the placement of concepts into modules differ. Model.
const MODULE = ["loan", "fee", "membership", "catalog", "notification", "report", "branch", "shared"];
const DEPENDENCY = {
  A: [["loan", "shared"], ["loan", "fee"], ["loan", "membership"], ["fee", "shared"],
    ["fee", "membership"], ["membership", "shared"], ["catalog", "shared"], ["notification", "loan"],
    ["notification", "membership"], ["report", "loan"], ["report", "fee"], ["report", "membership"],
    ["branch", "loan"], ["branch", "fee"], ["branch", "catalog"]],
  B: [["loan", "shared"], ["fee", "shared"], ["membership", "shared"], ["catalog", "shared"],
    ["notification", "shared"], ["report", "shared"], ["branch", "shared"], ["report", "loan"],
    ["branch", "loan"]],
};

// QA26: twelve change requests. Each request touches one concept; which module the concept
// is in and in how many files varies by structure. surface = does the change reach the
// module's public surface (the same for both structures, for a given request). Model.
const CHANGE = [
  ["fee schedule", true, { fee: 2, loan: 1, branch: 1, report: 1 }, { fee: 2 }],
  ["late fee threshold", false, { fee: 1, notification: 1, loan: 1 }, { fee: 1 }],
  ["member field", true, { membership: 2, loan: 1, report: 1, branch: 1 }, { membership: 2 }],
  ["loan duration", false, { loan: 2, fee: 1, notification: 1 }, { loan: 2 }],
  ["reservation rule", true, { loan: 2, branch: 1 }, { loan: 2 }],
  ["catalog field", true, { catalog: 2, branch: 1, report: 1 }, { catalog: 2 }],
  ["notification text", false, { notification: 2 }, { notification: 2 }],
  ["branch calendar", false, { branch: 2, loan: 1 }, { branch: 2 }],
  ["report column", false, { report: 2, fee: 1 }, { report: 2 }],
  ["currency", true, { shared: 1, fee: 2, report: 1, branch: 1 }, { shared: 2 }],
  ["identity field", true, { membership: 2, shared: 1, loan: 1 }, { membership: 2 }],
  ["discount rule", false, { fee: 2, loan: 1, report: 1 }, { fee: 2 }],
];

// Spread: a change that reaches the public surface also touches every module that depends on
// the module it touches directly; each indirect module gets one call site fixed. Depth is the
// number of steps from the directly touched module to the farthest indirect module.
const measure = (structure, surface, layout) => {
  const direct = Object.keys(layout);
  let file = Object.values(layout).reduce((s, n) => s + n, 0);
  const seen = new Map(direct.map((m) => [m, 0]));
  if (surface) {
    let front = [...direct], depth = 0;
    while (front.length) {
      depth += 1;
      const next = [];
      for (const m of front)
        for (const [a, b] of DEPENDENCY[structure])
          if (b === m && !seen.has(a)) { seen.set(a, depth); next.push(a); file += 1; }
      front = next;
    }
  }
  return { file, module: seen.size, depth: Math.max(...seen.values()) };
};

mkdirSync("model", { recursive: true });
writeFileSync("model/model.mjs",
  `export const MODULE = ${JSON.stringify(MODULE)};\n` +
  `export const DEPENDENCY = ${JSON.stringify(DEPENDENCY)};\n` +
  `export const CHANGE = ${JSON.stringify(CHANGE)};\n` +
  `export const measure = ${measure};\n`);

console.log(`${MODULE.length} modules; dependency A ${DEPENDENCY.A.length}, B ${DEPENDENCY.B.length}`);
console.log(`\n${"change".padEnd(20)}${"surface".padEnd(9)}` +
  `${"A file".padStart(8)}${"A module".padStart(9)}${"A depth".padStart(8)}` +
  `${"B file".padStart(9)}${"B module".padStart(9)}${"B depth".padStart(8)}`);
const measured = CHANGE.map(([name, surface, a, b]) =>
  [name, surface, measure("A", surface, a), measure("B", surface, b)]);
for (const [name, surface, a, b] of measured)
  console.log(`${name.padEnd(20)}${(surface ? "yes" : "no").padEnd(9)}` +
    `${String(a.file).padStart(8)}${String(a.module).padStart(9)}${String(a.depth).padStart(8)}` +
    `${String(b.file).padStart(9)}${String(b.module).padStart(9)}${String(b.depth).padStart(8)}`);

const avg = (structure, field) =>
  (measured.reduce((s, o) => s + o[structure][field], 0) / measured.length).toFixed(2);
console.log(`\naverage per change — A: file ${avg(2, "file")}, module ${avg(2, "module")}, ` +
  `depth ${avg(2, "depth")}`);
console.log(`average per change — B: file ${avg(3, "file")}, module ${avg(3, "module")}, ` +
  `depth ${avg(3, "depth")}`);
const worst = measured.slice().sort((a, b) => b[3].module - a[3].module)[0];
console.log(`most expensive change in both structures: ${worst[0]} ` +
  `(A ${worst[2].module} modules, B ${worst[3].module} modules)`);
```

```
8 modules; dependency A 15, B 9

change              surface    A file A module A depth   B file B module B depth
fee schedule        yes             6        5       1        2        1       0
late fee threshold  no              3        3       0        1        1       0
member field        yes             7        6       1        2        1       0
loan duration       no              4        3       0        2        1       0
reservation rule    yes             5        4       1        4        3       1
catalog field       yes             4        3       0        2        1       0
notification text   no              2        1       0        2        1       0
branch calendar     no              3        2       0        2        1       0
report column       no              3        2       0        2        1       0
currency            yes             9        8       2        9        8       1
identity field      yes             9        8       1        2        1       0
discount rule       no              4        3       0        2        1       0

average per change — A: file 4.92, module 4.00, depth 0.50
average per change — B: file 2.67, module 1.75, depth 0.17
most expensive change in both structures: currency (A 8 modules, B 8 modules)
```

All three metrics point the same direction but diverge by a different magnitude. The ratio is
1.8 in files, 2.3 in modules, 3 in depth. The weakest divergence is in files, because most of the
directly touched file count comes from the work itself — a fee schedule change needs the two
files the schedule is written in, whichever structure it happens in. **The structural difference
shows up not in the file count but in which modules those files are spread across.**

The entire difference comes from the six changes that touch the surface. In the six requests
that do not touch the surface, there is no spread, depth is zero, and even the scattered
structure stays between two and four files. The structure's cost is paid not on every change but
only on a change that reaches the exposed surface; if a measurement does not separate these two
sets, the average shows a smaller difference than the real one.

One row is identical in both structures: the currency change touches eight modules in either
one. In the tidy structure the concept is gathered into a single module, but that module is the
shared module, and everyone depends on it. **Gathering a concept gains nothing if the concept
already sits where everyone depends on it.** A modular structure's payoff is selective: large
where it can tuck a concept away in a corner, zero where it has to gather it at the center.

## Do the Metrics Show Decay?

A metric's real test is not a single measurement but the curve it gives over time. The block
below **models** the tidy structure decaying over six quarters — one concept leaks into one
extra module every quarter, and two quarters also open a new dependency edge — and tests whether
the three metrics show this decay. Then it writes a threshold rule and runs it against the real
cost record from the sixth quarter.

```js
// decay.mjs — tests whether the metrics show decay over time, then runs a threshold rule
// against a known cost record
import { DEPENDENCY, CHANGE, measure } from "./model/model.mjs";

// QA27: structure B decays over six quarters. Each quarter one concept leaks into one extra
// module [concept, module, dependency edge]; two quarters also open a new dependency edge. Model.
const LEAK = [
  ["fee schedule", "branch", null],
  ["member field", "loan", null],
  ["loan duration", "branch", ["report", "fee"]],
  ["fee schedule", "report", null],
  ["catalog field", "report", ["notification", "membership"]],
  ["member field", "report", null],
];

const layout = CHANGE.map(([name, surface, , b]) => [name, surface, { ...b }]);
const reading = [];
const take = () => {
  const m = layout.map(([name, surface, y]) => [name, measure("B", surface, y)]);
  const avg = (field) => m.reduce((s, [, v]) => s + v[field], 0) / m.length;
  return { m, file: avg("file"), module: avg("module"), depth: avg("depth"),
    violation: m.filter(([, v]) => v.module > 2).length };
};
reading.push(take());
for (const [concept, mod, edge] of LEAK) {
  const y = layout.find(([name]) => name === concept)[2];
  y[mod] = (y[mod] || 0) + 1;
  if (edge) DEPENDENCY.B.push(edge);
  reading.push(take());
}

console.log(`${"quarter".padEnd(8)}${"file/change".padStart(13)}${"module/change".padStart(15)}` +
  `${"depth".padStart(10)}${"changes over threshold".padStart(24)}  (Q0 = before decay)`);
reading.forEach((m, i) =>
  console.log(`${`Q${i}`.padEnd(8)}${m.file.toFixed(2).padStart(13)}` +
    `${m.module.toFixed(2).padStart(15)}${m.depth.toFixed(2).padStart(10)}` +
    `${String(m.violation).padStart(24)}`));

const firstMove = (field) => reading.findIndex((m, i) => i > 0 && m[field] > reading[0][field]);
const rising = (field) => reading.every((m, i) => i === 0 || m[field] >= reading[i - 1][field]);
for (const field of ["file", "module", "depth", "violation"])
  console.log(`${field.padEnd(9)} first move Q${firstMove(field)}, ` +
    `end-to-end rise ${(reading.at(-1)[field] - reading[0][field]).toFixed(2)}, ` +
    `ever reverses: ${rising(field) ? "no" : "yes"}`);

// QA28: at the end of six quarters the real cost of the twelve changes is read from the team's record;
// four came in expensive. The record is an observation, it does not come out of the metric. Model.
const EXPENSIVE = ["fee schedule", "member field", "reservation rule", "catalog field"];
// Rule: a change should touch at most two modules.
const THRESHOLD = 2;
const last = reading.at(-1).m;
let caught = 0, missed = 0, falseAlarm = 0, silent = 0;
const mismatch = [];
for (const [name, v] of last) {
  const fires = v.module > THRESHOLD, expensive = EXPENSIVE.includes(name);
  if (fires && expensive) caught += 1; else if (fires) falseAlarm += 1;
  else if (expensive) missed += 1; else silent += 1;
  if (fires !== expensive) mismatch.push(`  ${fires ? "false alarm" : "missed"}: ${name} ` +
    `(${v.module} modules, recorded as ${expensive ? "expensive" : "cheap"})`);
}
console.log(`\nthreshold ${THRESHOLD} modules, ${last.length} changes, ${EXPENSIVE.length} expensive: ` +
  `caught ${caught}, missed ${missed}, false alarm ${falseAlarm}, correct silence ${silent}`);
for (const line of mismatch) console.log(line);

// QA29: the part that cannot be checked. The metric counts breadth, not difficulty; whether a
// concept is in the right module is also a judgment call. In its place: a quarterly layout
// review, 12 changes x 1 person-hour.
const HOURS = 1;
console.log(`\ncannot be checked: a change's difficulty and whether the concept is in the right module ` +
  `(a judgment call); false alarm ${falseAlarm}, missed ${missed} fall exactly in this scope`);
console.log(`stands in its place: a quarterly layout review, ` +
  `${last.length * HOURS} person-hours/quarter, ${last.length * HOURS * 6} across six quarters`);
const record = CHANGE.length * 3 + DEPENDENCY.B.length;
console.log(`metric cost: ${record} records (12 changes x 3 fields + ` +
  `${DEPENDENCY.B.length} dependency edges); run ${reading.length * last.length} measurements; ` +
  `change cost is the observed face of what gets measured as debt interest`);
```

```
quarter   file/change  module/change     depth  changes over threshold  (Q0 = before decay)
Q0               2.67           1.75      0.17                       2
Q1               2.75           1.83      0.17                       2
Q2               3.00           2.08      0.25                       3
Q3               3.17           2.25      0.33                       4
Q4               3.17           2.25      0.25                       4
Q5               3.42           2.50      0.33                       4
Q6               3.42           2.50      0.33                       4
file      first move Q1, end-to-end rise 0.75, ever reverses: no
module    first move Q1, end-to-end rise 0.75, ever reverses: no
depth     first move Q2, end-to-end rise 0.17, ever reverses: yes
violation first move Q2, end-to-end rise 2.00, ever reverses: no

threshold 2 modules, 12 changes, 4 expensive: caught 3, missed 1, false alarm 1, correct silence 7
  missed: catalog field (2 modules, recorded as expensive)
  false alarm: currency (8 modules, recorded as cheap)

cannot be checked: a change's difficulty and whether the concept is in the right module (a judgment call); false alarm 1, missed 1 fall exactly in this scope
stands in its place: a quarterly layout review, 12 person-hours/quarter, 72 across six quarters
metric cost: 47 records (12 changes x 3 fields + 11 dependency edges); run 84 measurements; change cost is the observed face of what gets measured as debt interest
```

The file and module metrics move in the first quarter and never reverse across all six. The
depth metric does neither: it moves in the second quarter and reverses in the fourth. While the
structure is only getting worse, the metric looks like it improved for one quarter. The reason
sits in the metric's own definition: a leak writes a concept directly into a module it had
previously touched only indirectly, so that module now counts as a starting point instead of a
spread, and depth drops. **If the decay itself changes the metric's input, the metric does not
stay one-directional** — and this does not make the metric useless, it only means it cannot be
read alone.

The count of changes over the threshold moves in steps: two, three, four, then flat for three
quarters. The blunt count moves late and shows no decay at all over the last three quarters; the
average, over the same three quarters, climbs from 2.25 to 2.50. The two do not substitute for
each other: the average gives an early warning, the threshold count marks a decision point.

The rule itself, applied where four of the twelve changes in the sixth quarter came in
expensive, produces three catches, one miss, one false alarm, and seven correct silences. The
miss is the catalog field change, which touched only two modules yet came in expensive; the
false alarm is the currency change, which touched eight modules yet came in cheap — the same
constant's name got fixed in eight places. **Both errors come from one source: the metric counts
breadth, not difficulty.** This is exactly the part that cannot be checked; a change's difficulty
and whether a concept sits in the right module are both a judgment call. In their place a
quarterly layout review is set: 12 person-hours for the twelve changes, 72 across six quarters.

The metric's own cost looks small — 47 records and 84 measurements. But every one of the records
is held by hand: which concept each change request touches, whether it changes the surface, and
which module it appears in, in how many files. If this record gets dropped, the metric does not
go silent, it speaks wrong; the curve that shows decay becomes the curve of the record going
stale.

## Summary

- Running the same twelve changes against both structures, the scattered structure costs 4.92
  files, 4.00 modules, and 0.50 depth per change; the tidy structure costs 2.67 files, 1.75
  modules, and 0.17 depth.
- The divergence is weakest in files (1.8x), sharpest in depth (3x); the entire difference comes
  from the six surface-changing requests, the two structures do not diverge on the rest.
- A change touching the shared module spreads across eight modules in both structures: gathering
  gains nothing when the concept already sits where everyone depends on it.
- Over six quarters of decay, the file and module metrics move in the first quarter and never
  reverse; depth reverses in the fourth quarter, because the leak turns an indirect touch into a
  direct one.
- The threshold rule gives 3 caught, 1 missed, 1 false alarm, 7 correct silences; both errors
  come from the metric counting breadth and not difficulty.
- In place of what cannot be checked, a quarterly layout review of 12 person-hours is set; the
  metric's run is 84 measurements, its upkeep 47 hand-held records.

## Next Step

This topic tied attributes to a measure. The tree counted thirty leaves and separated which turn
into a check; the tension matrix showed which decision suppressed which attribute; trust
boundaries and unchecked crossings were counted; the scale unit's three candidates were compared
on what gets copied, shared, and left as a singleton; the cost of change was measured directly
across two structures. It is now possible to write down which decision protects which attribute.

But every one of these measurements was taken once. The thresholds are one day's thresholds, the
graph is one day's graph, the layout is one day's layout. Nothing says whether any of it still
holds six months later — even this lesson's decay table was a hand-built model, not a mechanism
that runs on its own. What is needed is a mechanism that repeats the measurement, reports the
drift, and settles who owns each measure; without it, every number in this topic stays a
photograph of the day it was taken.
