The maintainability triad
"Good code" splits into three measurable, separable properties — readability (cost to understand), changeability (cost to safely modify), testability (cost to verify in isolation) — and seniors review code by naming which property each change improves or degrades.
“Good code” is a compliment, not a measurement. In a review you can’t act on “this feels good” — you need to say what is good and what the change traded away. The previous lesson gave the economic lens: good code is code whose next change is cheap. But “cheap to change” is still one fuzzy number. To actually evaluate code, you have to break that number into the forces underneath it.
There are three, and they are not the same force. A function can be perfectly clear to read and still impossible to test. Another can be trivially testable and still a fog to read. Naming these three properties — and saying which one a change moves — is most of what separates a senior review comment from “looks fine to me.”
After this lesson you can name the three properties that make up maintainability — readability (cost to understand), changeability (cost to safely modify), testability (cost to verify in isolation); explain why they are correlated but separable, with a concrete TS function that is readable yet untestable; use dependency injection to add testability without spending readability; and review code the way seniors do — by saying which property a change improves and which it degrades, instead of “looks good.”
Maintainability is not one property — it is three measurable, separable ones. “Cheap to change” bundles three distinct costs, and you should be able to score code on each independently:
- Readability — the cost to understand what this code does and why, from a cold start. Measured in how long a competent reader takes to be sure.
- Changeability — the cost to safely modify it: how many places you must touch, how big the blast radius is, how confident you can be you didn’t break a caller.
- Testability — the cost to verify a unit in isolation: can you exercise this logic without standing up a database, faking the clock, or running the whole app?
These are the axes a senior actually grades on. “Good code” means high marks on all three; “bad code” usually fails one specific axis, and naming which one is the whole point.
They are correlated but separable — and the gaps between them are where bugs hide. The properties tend to move together: well-named, cohesive code is usually easy to read, change, and test. That correlation is real, which is why beginners treat “good code” as one thing. But they come apart often enough that conflating them is a senior-level mistake:
// Readable. Obvious intent. Untestable.
function isTrialExpired(user: User): boolean {
const elapsedDays = (Date.now() - user.signupAt) / 86_400_000;
return elapsedDays > 14;
}You can read this in five seconds — readability is high. But you cannot write a deterministic unit test for it: the Date.now() makes the result depend on when the test runs. To test “expired” vs “not expired” you’d have to mock global time, fake the system clock, or sleep. The function is readable and untestable at the same time. The two properties just diverged.
The usual culprit for low testability is a hidden dependency: time, IO, randomness, or global state reached for directly. Date.now(), Math.random(), process.env, fetch, fs.readFileSync, a module-level singleton — each is a dependency the function grabs from the ambient environment instead of receiving. It reads fine because the dependency is invisible in the signature. That invisibility is exactly what kills testability: a test can only control what it can pass in, and you can’t pass in something the function reaches for behind your back.
// Readable, and just as untestable: pulls config from the ambient env.
function buildApiUrl(path: string): string {
const base = process.env.API_BASE ?? "https://prod.example.com";
return `${base}${path}`;
}To test the staging branch you’d mutate process.env (global, leaks between tests) and remember to restore it. The signature (path: string) => string lies: the function actually depends on path and the environment. Untestability is usually a signature that doesn’t tell the truth about its inputs.
Inject the hidden dependency and testability rises with readability untouched. Make the dependency a parameter — a now value, a clock, a config object. The logic doesn’t change; the signature stops lying; the test gets a seam to pass values through.
// Same logic, same clarity — now deterministically testable.
function isTrialExpired(user: User, now: number): boolean {
const elapsedDays = (now - user.signupAt) / 86_400_000;
return elapsedDays > 14;
}
// Test controls time. No mocking, no sleeping, no global state.
isTrialExpired({ signupAt: 0 }, 13 * 86_400_000); // false
isTrialExpired({ signupAt: 0 }, 20 * 86_400_000); // trueRead it again: it is exactly as easy to understand as before. We spent no readability and bought full testability. That is the ideal move — and it shows the properties are separable in the good direction too: you can raise one without paying with another. The cost is pushed up to the caller (it must supply now: Date.now() once at the edge), which is precisely where the real, untestable clock belongs.
The failure mode: chasing one property can wreck the other two. Because the axes are separable, you can over-optimise one and pay on the others. The classic case is testability bought with mocking and indirection:
// "Testable" — but readability and changeability are now wrecked.
class TrialChecker {
constructor(
private clock: IClock,
private config: IConfig,
private logger: ILogger,
private featureFlags: IFeatureFlags,
) {}
isExpired(user: User): boolean {
this.logger.debug("checking");
const limit = this.config.get("trialDays");
if (!this.featureFlags.isOn("trials")) return false;
return (this.clock.now() - user.signupAt) / 86_400_000 > limit;
}
}Each interface exists “to be mockable,” but now: reading the rule means chasing four abstractions; the unit test needs four mocks just to assert a date comparison; and changing the rule means touching the class, its interfaces, and every test’s mock setup. Testability went up by a hair and readability + changeability collapsed. The senior instinct: a plain now: number parameter gave you the same testability for free. Indirection is a cost, not a default — add a seam only where a real, hard-to-control dependency lives.
Score a change on all three axes, then pick the move that lifts one without sinking another. Here is a function that emails a receipt, written the obvious way:
// before
async function sendReceipt(order: Order): Promise<void> {
const id = `rcpt_${Math.random().toString(36).slice(2)}`;
const sentAt = new Date().toISOString();
await mailer.send(order.email, `Receipt ${id} at ${sentAt}`);
}Grade it: readability is fine — you can follow it. Testability is near zero — three hidden dependencies (Math.random, new Date, the module-global mailer) mean a test can’t assert the receipt id, can’t fix the timestamp, and can’t avoid actually sending mail. Changeability is mediocre — the global mailer is reached for directly, so swapping providers touches this and every sibling.
Now inject the hidden dependencies — only the ones that are genuinely uncontrollable — and leave the readable logic exactly as it reads:
// after
async function sendReceipt(
order: Order,
deps: { genId: () => string; now: () => Date; mailer: Mailer },
): Promise<void> {
const id = `rcpt_${deps.genId()}`;
const sentAt = deps.now().toISOString();
await deps.mailer.send(order.email, `Receipt ${id} at ${sentAt}`);
}A test now passes genId: () => "fixed", now: () => new Date(0), and a fake mailer that records the call — fully deterministic, no real email, exact assertions. Testability went from near-zero to high. Changeability improved: the mailer is a parameter, so swapping it is a wiring change at the edge, not a code change here. Readability is unchanged — three lines, same shape, the deps bag names exactly what’s variable. We deliberately stopped at three real dependencies; we did not wrap each in its own interface, because that would have spent readability to buy testability we already had.
▸Why this works
Why bother naming three axes instead of just “maintainable”? Because a single score hides the trade you’re making. Every real design choice raises one property and risks lowering another, and the only way to discuss that honestly in review is to have words for each. “This extraction improves changeability but costs a little readability — worth it because this rule changes monthly” is a decision a team can evaluate. “This is cleaner” is not. The three-axis vocabulary turns review from taste into engineering: you state which property you’re buying and what you’re paying for it.
▸Common mistake
The most expensive misread is treating the three as one — assuming readable code must be testable, or that adding tests proves code is good. They are correlated, not identical. Readable-but-untestable (a hidden clock or IO) ships constantly and looks fine in review precisely because it reads well. And the reverse trap is worse: teams that equate “testable” with “good” reach for mock-heavy indirection that tanks readability and changeability to satisfy a coverage number. Score the axes separately or you will sacrifice the two that matter most to protect the one that’s easiest to measure.
A reviewer says a function is 'very readable, ship it.' The function computes a discount window by reading Date.now() directly. By the maintainability triad, what is the precise problem and the cheapest fix?
Maintainability decomposes into three measurable, separable properties: readability (cost to understand), changeability (cost to safely modify), and testability (cost to verify in isolation). They are correlated — good code usually scores well on all three — but they come apart, and the gaps are where senior judgment lives: a function that reads Date.now(), process.env, or a global directly is readable yet untestable, because a hidden dependency makes its signature lie about its inputs. Injecting that dependency (pass a now, a clock, a config bag) lifts testability with readability untouched — proof the axes move independently. The failure mode is chasing one axis off a cliff: buying testability with mock-heavy indirection wrecks the other two. So review code by naming the axis — say which property a change improves and which it degrades — and you have replaced “looks good” with an engineering argument.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.