Skip to content

9.1 — What Good Code Is

Here is a ticket that lands on two different teams on the same morning: "Add PayPal as a payment method."

On the first team, one engineer finishes it before lunch. They create one new file, register it in one list, write a test for it, and open a pull request that touches two files.

On the second team, the same ticket takes three weeks. The engineer has to find every place in the codebase that already knows the difference between a card payment and a bank transfer, and there turn out to be fourteen of them, spread across six files. Two of those places are switch statements that nobody thought to look at. One is a database column with a hard-coded list of allowed values. Halfway through, the release breaks refunds, even though the engineer never opened the refund code.

Same feature. Same language, same framework, same skill level. The only difference is how the code that was already there had been arranged.

That difference is what this Part is about, and it has a definition you can actually test against:

Good code is code where the next change is cheap and safe.

Cheap means the change is small and lands in few places. Safe means that when you make it, nothing you did not intend to touch breaks.

Everything else in Part 9 — cohesion, coupling, the SOLID principles, the design patterns, the API rules — is machinery for buying those two properties. This page defines them and shows you the mechanical causes underneath, so that when you reach the patterns you can say exactly which problem each one solves instead of applying them by ritual.

1. Why "the next change" is the right measuring stick

The obvious measuring stick would be "does it work". That one is the floor, not the bar, and here is why in numbers rather than opinion.

You read code far more often than you write it. The commonly quoted ratio is about ten to one. Treat the exact number as illustrative, because it comes from surveys rather than controlled measurement, but the ordering is not in doubt for anyone who has worked on a system older than a year. Before you can safely change five lines, you have to read enough of the surrounding code to be confident about what those five lines are connected to. So a coding style that makes writing fast, at the cost of making reading slow, has optimised the small half of the job.

You will spend far more of the system's life changing it than you spent building it. A feature takes two weeks to write and then lives for six years, during which it gets bug fixes, new requirements, a library upgrade, a compliance rule, and a performance fix. Adding those up, the writing was the cheap part.

Put those two facts together and you get the definition above. It also gives you a precise meaning for a phrase people throw around loosely.

Technical debt is Ward Cunningham's metaphor, and his actual meaning is narrower and more useful than "code I do not like." Debt is structure that no longer matches the way the code needs to change. The interest payment is real and you can see it on a calendar: every future change in that area costs more time than it should. And like real debt, taking it on can be the correct business decision. Shipping the tangled version this week to win the customer, and paying it down next quarter, is a legitimate trade. The failure is not taking the loan. The failure is taking it without noticing, so nobody ever budgets to repay it.

Now, what actually makes the next change expensive? There are two causes, and every principle in this Part attacks one or both of them:

  1. Coupling — you change something here, and that forces you to change something over there.
  2. Low cohesion — one single idea has been smeared across many places, so changing that one idea means visiting all of them.

Notice that the second team's three-week ticket had both. Take them one at a time.

2. Coupling: how far a change travels

Coupling is how much one piece of code depends on another piece of code. Every dependency is a channel, and change travels along it.

The goal is never "no coupling". Code with no dependencies does nothing. A function has to call other functions, a service has to read a database. The real questions are: how much travels along the channel, and can you see the channel at all.

There is a classic ladder here, from worst to best. What makes it worth memorising is that each rung has a visible tell in the code, so you can spot it during a review.

Global / contentControlStampDataeditshared mutable stateblast radius: everythingeditevery caller passing a flagblast radius: all callersediteveryone holding the big objectblast radius: its neighbourseditexplicit parameters onlyblast radius: this module
The same edit, made in four codebases that differ only in how they are coupled. Each grid is nine modules. The shaded ones are the modules you are now forced to open, test and re-review because of that one edit. This shrinking shaded area is the entire point of Part 9.

Rung 1 (worst): global and content coupling

This is when one module reaches directly into another module's internals, or when several modules share the same piece of mutable data.

typescript
// checkout-session.ts
export let currentUser: User | null = null;        // (1) anyone can import this and write to it

export function checkout(cart: Cart): Receipt {
  const token = currentUser!.paymentToken;         // (2) who set this, and how long ago?
  return chargeCard(token, cart.total);
}

Line (1) exports a let, which means it exports a variable that any other file can assign to. In JavaScript and TypeScript, a module-level variable is created once, the first time that module is imported, and then lives for the whole life of the process. Every file that imports it is reading and writing the same single box in memory. That makes it global state wearing a module's clothing, which is exactly the point made in 3.6.5.

Line (2) is where the damage shows up. The ! is TypeScript's non-null assertion, and it means "trust me, this is not null." But checkout has no way to know whether currentUser was set two milliseconds ago by the login handler or forty minutes ago by a request that has since finished. Nothing in the signature of checkout tells the caller that it depends on currentUser at all. You could read this function's entire body and still not know what it needs in order to run correctly.

Now imagine changing how currentUser gets set — say you make it clear itself after each request. Every single file that reads it may now break, and the compiler will not tell you which ones, because they all still type-check. That is a blast radius of "the entire program," which is the red panel in the figure.

The fix is to make the dependency visible by putting it in the signature:

typescript
export function checkout(cart: Cart, user: User): Receipt {   // dependency is now in the type
  return chargeCard(user.paymentToken, cart.total);
}

That version is honest. You cannot call it without supplying a user, the compiler enforces it, and you can test it by handing it a user object rather than by setting up global state first.

Rung 2: control coupling

Control coupling is when the caller passes a value whose only job is to tell the callee which behaviour to run.

typescript
// ❌ the caller has to know how render() is built on the inside
function render(user: User, isAdminView: boolean, isCompact: boolean): Html { /* … */ }

render(user, true, false);      //  what do true and false mean here?

The problem is not ugliness. It is that render is really three different functions sharing one body, and the caller now has to know which internal branch it wants. Read the call site on its own and you cannot tell what it does. Worse, the two booleans imply four combinations, and at least one of them — the compact admin view — probably has never been tested and may not even make sense.

The cure is to split by behaviour and let callers pick by name:

typescript
// ✅ each function does one thing; the call site says what it means
function renderAdminView(user: User): Html { /* … */ }
function renderCompactCard(user: User): Html { /* … */ }

renderCompactCard(user);        // reads exactly as it behaves

A boolean parameter is the tell. When you see one, ask whether it is data the function operates on (setActive(true) is fine, the boolean is the subject) or a switch choosing between behaviours (render(user, true) is not). When a whole family of behaviours needs to be chosen at runtime rather than at the call site, splitting into separate functions is not enough, and the full treatment is the Strategy pattern in 9.4.12.

Rung 3: stamp coupling

Stamp coupling is when you hand a function a big structure and it only uses a sliver of it.

typescript
// ❌ needs 2 fields, receives all 40 — and has now declared a dependency on all 40
function formatGreeting(user: User): string {
  return `${user.firstName} ${user.lastName}`;
}

// ✅ Pick declares the true dependency, and the compiler enforces it
function formatGreeting(user: Pick<User, "firstName" | "lastName">): string {
  return `${user.firstName} ${user.lastName}`;
}

Pick<User, "firstName" | "lastName"> is a built-in TypeScript utility type that builds a new type containing only the named fields of User. It is worth knowing exactly why the second version is better, because at first glance it looks like extra ceremony for no gain.

First, the signature now tells the truth. A reader can see what this function actually touches without opening the body. Second, it is reusable. Anything with a first and last name can be passed in — a Contact, an Author, a test fixture built from two strings — because TypeScript matches types by shape rather than by name, which is the structural typing described in 3.7.2. Third, and most valuable, when somebody later removes the email field from User, this function is provably unaffected, because it never claimed to need it.

Stamp coupling is not automatically wrong. Objects that genuinely belong together should travel together, which is the cohesion argument in the next section. The smell is specifically the mismatch: a large declared dependency against a tiny real one.

Rung 4 (best): data coupling

Data coupling is when modules talk through explicit parameters and return values that carry exactly what is needed and nothing more. That is the green panel, where the edit stays put.

The axis that matters more than the ladder: visible or hidden

The ladder is useful, but there is a second distinction that costs teams more money, and it cuts across all four rungs.

A visible channel is one the compiler and the reader can see. A parameter is visible. A return type is visible. An imported function is visible. When you break a visible channel, something tells you immediately — usually a red squiggle before you have even saved the file.

A hidden channel is a real dependency that nothing in the code declares:

  • Two services that both write to the same database table.
  • A file format written by one program and parsed by another.
  • An environment variable that a module reads at startup.
  • The rule that you must call init() before run(). This one has its own name, temporal coupling, which just means that the correctness of your code depends on the order things happen in, and nothing enforces that order.

Hidden channels are the expensive kind precisely because breaking one is silent. The build stays green. The tests pass. The failure arrives later, in production, in a part of the system the author of the change had never opened. That is exactly how the second team's release broke refunds without anyone touching refund code.

A large part of what senior engineers actually do, day to day, is turn hidden channels into visible ones. Give the shared table one owning service and make the other one call an API. Replace the "call init() first" rule with a constructor that cannot produce an unusable object. Replace the environment variable read in the middle of a function with a config object passed in at startup. None of these are clever. All of them convert a silent failure into a compile error.

One more direction to design toward, which will come back as the D in SOLID: depend on things that do not change often. If your order code depends directly on a specific database client library, it inherits every breaking change that library makes. If it depends on an interface you defined, called OrderStore, then the database client can be swapped and the order code never notices. That is not an abstract principle, it is a coupling statement: you get to choose what your change travels along, so point your dependencies at the stable things. Chapter 9.3.9 develops this.

3. Cohesion: does this module have one reason to exist?

Cohesion is the mirror image of coupling. Coupling asks how strongly this module is tied to other modules. Cohesion asks how strongly the things inside one module belong together.

High cohesion means the module does one thing completely. Low cohesion shows up in two opposite shapes, and it is worth being able to name both.

Shape one: many unrelated ideas in one place. Every codebase has this file:

typescript
// utils.ts — 3,000 lines, imported by 200 other files
export function formatDate(d: Date): string { /* … */ }
export function validateEmail(s: string): boolean { /* … */ }
export function calculateShipping(cart: Cart): Money { /* … */ }   
export function retryWithBackoff<T>(fn: () => Promise<T>): Promise<T> { /* … */ }

The four functions here share a filename and nothing else. Date formatting changes when the design team picks a new date style. Email validation changes when the signup rules change. calculateShipping changes when the business changes shipping prices, which is a completely different department. retryWithBackoff changes when your reliability policy changes. Four functions, four unrelated reasons to be edited, one file.

This is called coincidental cohesion, and the reason it always grows is structural rather than cultural. When you write a helper and cannot decide where it belongs, utils.ts accepts anything, so it becomes the path of least resistance. And because two hundred files import it, every edit to it puts two hundred files at risk and invalidates their build cache.

The fix is to split by reason to change, not by size or by type. Genuinely generic helpers that almost never change can stay together in dates.ts or strings.ts. But calculateShipping is not a utility at all, it is business logic that wandered, so it moves into the shipping module where the rest of the shipping rules live. retryWithBackoff moves to an infrastructure module owned by whoever owns reliability.

Shape two: one idea scattered across many places. This one is more dangerous, because you cannot see it by opening any single file.

Suppose the knowledge of "how tax is computed" lives partly in the Cart class, partly in the checkout() function, and partly in a SQL view that the finance report uses. Nothing links these three. Now the business says tax depends on the delivery address instead of the billing address. That is one change to one business rule, and it forces you to find and edit three unrelated places, in three different languages, with nothing to tell you when you have found them all. Refactoring literature calls this shotgun surgery, and the giveaway is a ticket where the description is one sentence and the diff is fourteen files.

Both shapes are prevented by the same rule, which is the single most useful sentence in this chapter:

Things that change together should live together. Things that change separately should live apart.

That is what cohesion and coupling are both really about. You are arranging code to match the shape of the changes that are coming.

This is also the honest content of the Single Responsibility Principle, which gets its full treatment in 9.3.5. The common summary — "a class should do one thing" — is too vague to act on, because "one thing" can mean anything you want it to. Robert Martin's sharper wording is that a module should have one reason to change, and the way to find that reason is to ask who asks for the change. If the finance team can request a change to a class and the marketing team can also request a change to the same class, that class has two reasons to change and two masters, and sooner or later one of them will break the other's feature.

4. Naming: the cheapest design tool you have

Naming looks like a matter of taste. It is actually interface design, and it is the highest-leverage thing on this page because it costs nothing to do well.

A name is a compressed contract. When a reader trusts the name, they skip the body, and skipping the body is the whole reason a large system stays readable at all. A bad name has two failure modes and the second is much worse than the first. Either the reader does not trust it and reads the body, which costs time. Or the reader does trust it and it was lying, which costs a bug.

The rules that carry real weight:

Say what it is and why it exists, never how it works. activeSubscribers is better than filteredList, which is better than arr2. And keep the mechanism out: a function called getUsersViaRedisCache becomes a lie the day somebody swaps Redis for something else, and now the name is actively misleading rather than merely unhelpful.

Let name length grow with scope. i inside a three-line loop is perfect, because the entire lifetime of that variable is visible on screen. The same i as a module-level variable is a real problem, because a reader who finds it three hundred lines away has no context at all. Public API names get the most design effort, loop counters get the least.

Do not hide surprises. A function called getUser that quietly creates a user when it does not find one will burn somebody. Either the surprise goes into the name (findOrCreateUser) or it goes out of the function. This matters most for anything that writes to a database, sends an email, or charges money, because those are the ones you cannot take back.

One word per concept, and one concept per word. If your codebase uses get, fetch, retrieve and load interchangeably, every reader wastes energy hunting for the distinction you never made. Pick a house rule and hold it: get for something cheap and local, fetch for something that crosses the network, compute for something derived on the spot, build for something constructed. Then a reader can predict cost from the verb.

Use the words the business uses. If the people who fund the work say "policy", "quote" and "rider", then the code says Policy, Quote and Rider, not DataRecord and Item2. Domain-Driven Design calls this the shared language of the team, and the payoff is concrete: a requirement written in a meeting translates almost directly into a diff, and a conversation with a non-engineer stops needing a translation layer.

Booleans read as statements that are true or false (isExpired, hasAccess, canRetry), functions read as verbs, classes read as nouns. And keep pairs symmetric: open/close, begin/end, add/remove. Never open/deactivate, because now the reader has to check whether those two are really opposites.

The test for any name, and it runs in both directions: could a new team member guess roughly what the body does from the name, and could they guess roughly what the name is from the body? If either direction fails, keep working on it.

5. Complexity: the budget you are actually spending

Fred Brooks drew the distinction that organises this whole topic.

Essential complexity belongs to the problem. Tax law really is complicated. A payment system really does have to handle partial refunds, currency conversion, chargebacks and retries. No design removes this, because it is what the software is for.

Accidental complexity is what your solution added on top. A clever abstraction nobody needed. Four layers of indirection where one call would do. A configuration system for a value that has never changed. Framework ceremony that exists to satisfy the framework.

The goal in one line: spend your complexity budget on the problem, and refuse to spend any on the solution.

Two measurements let you make this argument with numbers instead of taste, which matters when you are trying to persuade someone.

Cyclomatic complexity counts the number of independent paths through a piece of code. In practice it is roughly one, plus one for every branching point: each if, each case, each && or ||, each loop condition. It matters because paths are what you have to test and what a reader has to simulate in their head. A function with a cyclomatic complexity of twelve has at least twelve behaviours hiding behind one name. Every linter can compute it, and around ten per function is a common threshold for "this should probably be split."

Cognitive complexity is SonarSource's refinement, and it tracks human reading cost more closely. It charges a penalty for nesting — a branch inside a loop inside a branch costs more each level down — and it forgives long flat sequences. That single difference explains why early returns feel so much better to read:

typescript
// ❌ nested: you must hold three conditions in your head at once
function ship(order: Order): void {
  if (order.isPaid) {
    if (order.items.length > 0) {
      if (!order.isShipped) {
        dispatch(order);
      }
    }
  }
}

// ✅ guard clauses: each line is one fact, and then you forget it
function ship(order: Order): void {
  if (!order.isPaid) return;                 // (1)
  if (order.items.length === 0) return;      // (2)
  if (order.isShipped) return;               // (3)
  dispatch(order);                           // (4) by here, all three are guaranteed
}

Walk the second version. Line (1) disposes of unpaid orders and then you never have to think about payment again. Line (2) does the same for empty ones, line (3) for already-shipped ones. By line (4), the reader is standing on solid ground: three facts are true, and none of them are still open in their working memory. The nested version asks you to keep all three conditions live at once, and the real cost is that mental stack, not the line count.

The two versions have almost the same cyclomatic complexity, because the number of branches barely changed. Cognitive complexity drops sharply, and that difference is the reason the second one is easier to read. That is the practical use of having both numbers: cyclomatic tells you which functions to look at, cognitive tells you how to restructure them.

There is a bonus that is not a coincidence. TypeScript's control-flow narrowing works better on the guard-clause version too, because the compiler is doing the same thing your reader is doing — walking the branches and tracking what is known to be true at each line (3.7.3).

The refinement that separates seniors from juniors here: complexity only costs you when the code is touched. A gnarly function that nobody has edited in five years is costing you nothing at all. A mildly messy function that three people edit every week is costing you constantly.

That gives you the hotspot technique. Take how often each file changes, which git log will tell you, and multiply it by that file's complexity score. Sort the list. The top of that list is where your refactoring time actually pays back. This is the move that turns "we should clean up the codebase," which is unbounded and unfundable and will therefore never be approved, into "these six files cause most of our pain and here is the plan for them," which fits in a sprint.

6. The expert lens

Three ideas that only show up after a few years, and each one changes how you evaluate a design.

Optimise for deletion. The best single predictor of a healthy codebase is not how elegant it looks. It is whether you can delete things. Ask of any feature: if the business killed this tomorrow, what would removing it look like? If the answer is "delete that folder and one line from the registry", the code is in good shape, and you know it without reading a single line. That is because deletable code has to be cohesive, since the feature lives in one place, and it has to be loosely coupled, since nothing else reached into it. The deletion question is really the change question with a much sharper answer, because "can I change this" invites hand-waving and "can I delete this" does not.

Duplication is cheaper than the wrong abstraction. This is Sandi Metz's line, and it is worth understanding rather than repeating.

When you merge two pieces of similar-looking code, you have coupled their futures. From now on, they change together whether they want to or not. If they really were the same idea, that is a win, and you will maintain the rule in one place forever.

But if they only looked alike, you have built a trap. Six months later one caller needs slightly different behaviour, and since the shared function is right there, someone adds a boolean parameter. That is control coupling from section 2, now living inside your abstraction. Then another flag arrives. Eventually the shared function is a maze of options that nobody can change safely, and every edit to either caller requires understanding both callers plus every flag.

Here is what makes this asymmetric, and this is the part people miss: undoing a wrong abstraction is much harder than removing duplication. Duplication is visible, local, and mechanical to fix later. A wrong abstraction has consumers that have already shaped themselves around it, so unwinding it means changing every one of them.

The bound on the claim: it tells you to wait for evidence, not to never abstract. Two tests. The rule of three, which says extract on the third occurrence rather than the second, because two points can look like a line by accident. And the better, semantic test: do these two pieces of code change for the same reason? A tax formula copy-pasted into two files is a genuine defect, because somebody will update one and forget the other, and it changes for exactly one reason. Two forms that both happen to have three fields today are a coincidence wearing a costume. Abstract the knowledge, not the syntax.

Metrics are thermometers, not targets. Cyclomatic thresholds, test coverage percentages, lint scores. Each one measures a side effect of the thing you actually care about, which is how expensive your next change will be. And each one can be gamed.

Take a concrete example, because this is the objection you will actually face. Somebody splits a complex 200-line function into five small functions, and the complexity score per function drops nicely. But those five functions all read and write the same shared object, and none of them makes sense on its own, and you cannot understand any one of them without reading the other four. The score improved and the design got worse. What happened is that visible complexity, which was sitting right there in one function where you could see it, became hidden coupling between five functions where you cannot. The reader's job got harder, not easier.

So use metrics to find candidates and to argue about trends, never as goals to hit. The measurements that cannot be faked are the ones you get by asking the team three questions: how long does a typical change take, how often does a change break something unrelated, and which files is everybody afraid of. Ask those three about any codebase and you have its true health report.

What the next page does with all this. You now have the target and the two mechanical causes of missing it. Chapter 9.2 examines the toolset that object-oriented programming actually gives you for lowering coupling and raising cohesion, past the four textbook buzzwords, starting with what an object really is in memory and ending with why composition usually beats inheritance.

Recall

  • Good code = the next change is cheap and safe. You read code roughly ten times more than you write it, and you spend more of a system's life changing it than building it. Technical debt is structure that no longer matches how the code needs to change, and the interest shows up as slower changes.
  • Coupling is how far a change travels. The ladder, worst to best: global/content (shared mutable state) → control (a boolean picks the behaviour) → stamp (big object, one field used, fix with Pick) → data (explicit parameters). The bigger axis is visible or hidden: hidden channels like shared tables, file formats and required call order (temporal coupling) break silently, so turn them into visible ones.
  • Cohesion is one reason to exist per module. Two smells: the utils.ts junk drawer (unrelated things together) and shotgun surgery (one idea scattered). One law fixes both: things that change together live together.
  • Naming is interface design. Predict the body from the name and the name from the body. Length grows with scope, no hidden surprises, one word per concept, use the business's own words.
  • Essential complexity is the problem's, accidental is yours. Cyclomatic counts paths and finds candidates, cognitive charges for nesting and tells you how to fix them. Complexity only costs when code is touched, so refactor by churn × complexity hotspots.

Self-test: Define good code in one sentence, without using the word "clean". Recite the coupling ladder with the code tell for each rung. Why is hidden coupling worse than visible coupling, given hidden coupling is often weaker? State the cohesion law and name the two smells it prevents. Why is a wrong abstraction more expensive than duplication? What two numbers does a hotspot multiply, and why does the multiplication matter?

Quiz Bank

FoundationalWhat makes code good, and why is it works not the bar?

Good code is code where the next change is cheap and safe. Cheap means the change is small and lands in few places; safe means nothing you did not intend to touch breaks. The reason this beats "it works" is economic rather than aesthetic. Engineers read code far more than they write it, roughly ten times more by common estimates, and a system that survives spends most of its life being modified rather than created. So correctness today tells you nothing about the two properties that price tomorrow: how quickly a reader can build an accurate picture of what the code does, and how far a change spreads once they make one.

Code that works but is tangled is technical debt in Ward Cunningham's precise sense — structure that no longer matches the way the code needs to change — and the interest is paid in real time on every future ticket. Debt can be a correct decision, because shipping this week to win a customer is worth something real. The failure mode is taking the loan without noticing, because then nobody ever schedules the repayment.

FoundationalWalk the coupling ladder from worst to best, and give the tell for each level.

Global / content coupling. One module reaches into another's internals, or several share mutable state. The tell is a module-level let, a singleton holding data, or debugging that starts with "who set this and when?". The blast radius is the whole program, because nothing declares the dependency, so nothing warns you when you break it.

Control coupling. The caller passes a value that selects which behaviour the callee runs. The tell is boolean parameters — render(user, true, false) — where you cannot read the call site and know what it does. The function is really several functions sharing a body, and every caller now depends on that internal fork. The cure is separate named functions, or the Strategy pattern when the choice has to be made at runtime.

Stamp coupling. A function receives a large structure and uses a sliver of it. The tell is a function that touches two fields of a forty-field object. It has declared a dependency forty times bigger than the real one. In TypeScript the cure is Pick<User, "firstName" | "lastName">, which makes the signature tell the truth and makes the function reusable with anything of that shape.

Data coupling. Explicit parameters and returns carrying exactly what is needed. This is the good end.

Cutting across all four is the axis that matters more: visible or hidden. Parameters and return types are visible, so breaking them produces a compile error. Shared database tables, file formats, environment variables and required call order (temporal coupling) are hidden, so breaking them produces a silent failure that shows up later in a part of the system the author never opened. Much of senior engineering is converting hidden channels into visible ones.

AppliedYour team has a utils.ts that is 3,000 lines and imported by 200 files. Diagnose it precisely and prescribe the split.

Diagnosis, in two parts. First, coincidental cohesion: the functions in that file share a filename and nothing else. Date formatting changes when design changes, email validation changes when signup rules change, shipping calculation changes when the business changes prices. Different departments, different reasons, one file. Second, maximum incoming dependency: 200 files import it, so every edit puts all of them at risk and invalidates their build and test caches. Those two together also explain why it grows rather than shrinks — when a new helper has no obvious home, this file accepts anything, so it is always the path of least resistance.

Prescription: split by reason to change, not by size or by type. For each function, ask what kind of requirement would force an edit to it. Generic helpers whose reason is essentially never (formatDate, truncate) can stay grouped in dates.ts and strings.ts. Business logic that wandered in, like calculateShipping, moves into the shipping module, because its reasons to change are business reasons and it belongs next to the other shipping rules. Infrastructure concerns like retryWithBackoff become an infra module owned by whoever owns reliability policy.

Do it opportunistically, not as a big-bang project. Move a function when you are already touching it for another reason, so the risk stays attached to work that is already being tested and reviewed. Then stop the bleeding with a lint rule that bans new exports from the legacy file, otherwise the drawer keeps accepting socks while you empty it. The success measure is not the line count of the old file, it is that a future logical change touches one module instead of several.

InterviewCyclomatic versus cognitive complexity — what does each measure, and why do guard clauses improve one far more than the other?

Cyclomatic complexity counts independent paths through the code, roughly one plus the number of branch points. What it really approximates is test burden and machine analysability, since every path is a behaviour that needs a case.

Cognitive complexity estimates human reading cost. It charges similar increments but adds a penalty for nesting, so a branch inside a loop inside a branch costs progressively more, and it forgives long flat sequences that a reader can walk through once.

Guard clauses barely move the cyclomatic number, because you still have the same three conditions and therefore roughly the same number of paths. They collapse the cognitive number, because a three-level pyramid becomes a flat list of preconditions. The mechanism is working memory: in the nested version the reader has to hold "I am inside isPaid, inside a non-empty check, inside a not-shipped check" the whole time, whereas in the flat version each condition is discharged and forgotten on its own line. By the last line, three facts are guaranteed and none of them are still occupying the reader.

The same shape helps TypeScript's control-flow narrowing for the same underlying reason: the compiler is also walking branches and tracking what is known at each point, and flat is cheaper for both of you. In practice: set a lint threshold on cyclomatic complexity, around ten, to find the functions worth looking at, then use cognitive complexity to decide how to restructure them.

InterviewDuplication is cheaper than the wrong abstraction. Defend that claim, then say where it stops being true.

The defence. Merging two similar pieces of code couples their futures — from that moment they change together whether the business wants them to or not. If the resemblance was real, that is a win. If it was coincidence, you have built a trap. The first divergence arrives, the shared function is right there, so someone adds a boolean parameter. That is control coupling living inside your abstraction. Another flag follows. Eventually every change to either caller requires understanding both callers and every flag between them.

Why the asymmetry. Removing duplication later is visible, local and mechanical. Removing a wrong abstraction later is not, because consumers have shaped themselves around its interface, so unwinding it means changing all of them at once. You are choosing between a cost you can pay any time and a cost that grows the longer you wait.

Where it stops. The claim licenses waiting for evidence, not refusing to abstract. Two tests decide it. The rule of three says extract on the third occurrence, since two points can look like a line by accident. The better test is semantic: do these change for the same reason? A tax formula copy-pasted into two files is a real defect, because it has exactly one reason to change and somebody will update one copy and miss the other. Two forms that happen to have three fields today share no reason at all and should stay apart. Abstract the knowledge, never the syntax.

StaffYou inherit a 400,000-line codebase and one quarter to make it materially safer to change. Where do you aim, what do you measure, and what do you refuse to do?

Aim by evidence, not by taste. Build the churn × complexity map: pull change frequency per file from git log, multiply by a complexity score, and sort. Change cost concentrates heavily in a small set of files, and refactoring complex code that nobody touches is effort with no return. Cross-check that list against the team's fear list by asking which files people dread opening, because that experiential signal cannot be gamed and often surfaces hidden coupling the metrics cannot see.

Then work the top of the list in this order. First, characterisation tests around the current behaviour, because you cannot safely rearrange code whose behaviour you have not pinned down (Chapter 9.8). Second, split modules along reasons to change, which dissolves the big files rather than merely shrinking them. Third, surface the hidden channels — the "must call init() first" rules, the tables two services both write — into explicit interfaces, because those are what cause the breakages that come with no warning. Fourth, flatten the hotspot functions with guard clauses and rename the names that are lying.

Institute compounding habits. The boy-scout rule, where every file you touch leaves slightly better than you found it, improves the codebase at zero scheduling cost. Freeze new debt with lint rules: complexity thresholds on new code and a ban on new exports to the junk drawers, so the hole stops getting deeper while you fill it.

Measure trends, not absolutes. Track lead time for changes in the hotspot files, defects introduced per change there, and drift in the hotspot scores. Absolute numbers on a legacy codebase mean nothing, direction means everything.

Refuse four things. The big-bang rewrite, which throws away years of hard-won behaviour embedded in the existing code in exchange for unbounded risk. Mass mechanical refactors that move numbers without moving channels, since splitting big functions into fragments that share state converts visible complexity into hidden coupling. Coverage or complexity targets, since any metric used as a goal gets gamed. And any refactor of code with no churn, because by definition it is not costing anything today.

Scenario Drill

DrillA payments team reports that adding any new payment method takes three weeks and touches 14 files, and that last quarter two releases broke refunds even though refunds were never touched. The code involved: PaymentProcessor.ts at 2,100 lines and the number one complexity hotspot, a utils.ts, a checkout() that switches on the method type in six separate places, and a payments database table that the invoicing service also writes to. Diagnose with this page's vocabulary and lay out the fix.

This is the ticket from the top of the page, and every symptom maps onto something named here.

"Fourteen files per new method" is shotgun surgery. The idea "payment method" has no home. Its knowledge is spread across six switch statements in checkout(), plus PaymentProcessor, plus whatever leaked into utils.ts. Six parallel switches on the same discriminator is the textbook signature that a Strategy is missing (9.4.12): adding a method means finding every fork, and missing one still compiles and still deploys and fails at a real checkout with a real customer standing there.

"Refunds broke although untouched" is hidden coupling, and there are two separate channels here. A 2,100-line PaymentProcessor almost certainly holds both charging and refunding, which is low cohesion — one file, several reasons to change — so an edit to the charge path shifts shared internals that the refund path was quietly relying on. Separately, the payments table is written by two services, and that is a hidden channel to invoicing: change the meaning of a column and you break a consumer that no compiler is watching.

The fix, ordered by risk rather than by satisfaction:

1. Lock the current behaviour first. Characterisation tests around checkout, charge and refund. You cannot safely rearrange what you have not pinned down, and on a payments system the cost of being wrong is money moving incorrectly.

2. Give the concept a home. Define the role the code has been missing:

typescript
interface PaymentMethod {
  authorize(order: Order): Promise<AuthResult>;
  capture(auth: AuthResult): Promise<Receipt>;
  refund(receipt: Receipt, amount: Money): Promise<Refund>;
}

Implement it once per method — CardPayment, UpiPayment, PayPalPayment — and collapse all six switches into a single lookup. Adding a method becomes adding one file plus one registry line, which is also the deletion test passing: killing a method means deleting its file. Type the registry so the compiler enforces completeness:

typescript
const methods: Record<MethodKind, PaymentMethod> = {   // (1)
  card:   new CardPayment(gateway),
  upi:    new UpiPayment(gateway),
  paypal: new PayPalPayment(gateway),                  // (2) missing entry = compile error
};

Line (1) uses Record<MethodKind, PaymentMethod>, where MethodKind is a union of the allowed method names. Record requires a key for every member of that union, so line (2) is not optional: add a new value to MethodKind and forget to register it, and the build fails immediately instead of throwing at a customer's checkout. That is the exhaustiveness idea from 3.7.3 doing real work — a class of production bug converted into a compile error.

3. Split PaymentProcessor along its reasons to change. Charging, refunding and reconciliation are three concerns with three different owners and three different change triggers. Split them into three modules and the 2,100-line file dissolves instead of merely getting smaller, and a charge-path edit stops being able to reach refund internals.

4. Turn the hidden channel into a visible one. The shared table gets exactly one owning service. The other reads it through an explicit contract, which means an API call or published events (Part 10.8 covers the event route). If that is too big a step for this quarter, the interim move is still worth it: a versioned view plus a contract test with the invoicing team, so a breaking change fails somebody's build rather than somebody's month-end report.

5. Stop the regression. Lint complexity thresholds on the new modules, ban new exports to utils.ts, and track the two numbers that made this ticket exist: files touched per new payment method, target one or two, and unrelated breakages per release, target zero.

The one-sentence version for your write-up: a domain concept with no home, plus two hidden coupling channels; fixed by giving the concept an interface, splitting the module along its reasons to change, and converting the hidden channels into explicit contracts.