>_devkit
maintainability
skills/maintainability/

references/change-cost.md

Change cost: coupling, locality, and blast radius

Everything in this file serves one question: when you change this, what else has to change?

The two forces

Only two properties of a system determine change cost, and they pull against each other in one specific way.

Coupling is how much one thing must know about another. Cohesion is how much the things inside one unit belong together.

The rule everyone quotes is "low coupling, high cohesion", which is true and useless on its own, because the interesting part is that they are the same decision seen twice. Splitting a module always lowers cohesion and raises coupling across the new boundary. Merging always does the reverse. So the real question is never "should these be coupled" but "where do I want the boundary, given what changes together?"

That gives the working principle:

Things that change together belong together. Things that change for different reasons belong apart.

The tell you got it wrong in one direction: a routine change touches five distant files. The tell you got it wrong in the other: one file is edited by three unrelated streams of work and every edit conflicts.

Kinds of coupling, cheapest to worst

Not all coupling is equal. This ordering is the one to argue from in review.

KindExampleCost
DataPassing a value as a parameterFine. This is just calling a function
StampPassing a whole object when two fields are usedMild. Widens the interface, hides the real dependency
ControlPassing a flag that selects behaviour inside the calleeBad. The caller knows the callee's internals. Usually two functions wearing one name
TemporalB must be called after A, and nothing enforces itBad. Invisible in the type signature, discovered at runtime
CommonTwo modules sharing mutable global stateWorse. No local reasoning is possible in either
ContentOne module reaching into another's internalsWorst. The other module can never change

Two of these are worth naming in review because they are so common and so rarely called out:

Control coupling. render(item, isCompact) where the flag selects one of two layouts is two components sharing a body. Split it. The version with the flag looks smaller and costs more, because every reader must now hold both behaviours at once.

Temporal coupling. client.connect() then client.send(), where calling send first fails at runtime. The fix is structural, not documentary: make connect return the thing that has send on it. Then the ordering is not a rule anyone can forget, it is the only thing the types allow. This is the enforcement ladder applied to sequencing.

Locality

The strongest available lever, and it is nearly free.

Put the code as close as possible to the only thing that uses it. A helper used by one function goes in that file, not in utils. A component used by one feature goes in that feature, not in the design system. A type used by one module is not exported.

Two things follow.

Move code up only on the third consumer, and move the thing they share, not the thing you have. The first extraction is a guess about what is common. By the third case you can see it. mongodb-production says the same thing about denormalized fields, react-native-expo about promoting a domain enum to lib/. It is the same rule.

Scope is a commitment. Every export is a promise. A private function can be changed in an afternoon by whoever needs to. A public one is a negotiation with every caller, and if it crossed a package boundary or an API, it is a negotiation with people you cannot contact. Default to the narrowest scope that works, because widening is easy and narrowing is not.

This is the whole argument for feature folders over layer folders once a codebase is real. Grouping by technical kind puts every file that changes together as far apart as the tree allows. It reads well and works badly.

Blast radius, in practice

Before starting a change, write the list of files you expect to touch. After finishing, compare.

The gap is the signal:

  • Touched fewer than expected. Good, and worth understanding why. Usually a

boundary did its job.

  • Touched roughly what you expected. The system is legible. This is the goal.
  • Touched far more. Something is coupled in a way that was not visible from

the outside. That is the finding, and it is worth more than the change itself. Write it down before you forget it.

For code you did not write, get the radius mechanically before trusting your reading of it:

# who depends on this module
rg -n "from ['\"].*payments" --type ts
rg -n "import .*payments" --type py

# what this module depends on
rg -n "^import|^from" src/payments/

# how often it changes, and with what
git log --oneline -- src/payments/ | head -30
git log --format='' --name-only --since='6 months ago' -- src/payments/ | sort | uniq -c | sort -rn | head

That last command is the useful one and almost nobody runs it. Files that keep appearing in the same commits are coupled, whether or not they import each other. It finds the coupling that static analysis cannot see: the config constant and the parser that reads it, the migration and the model, the two services that share an undocumented contract. If two files change together every time and live in different corners of the repo, that is a boundary in the wrong place, and the git history proved it.

Seams

A seam is a place where you can change behaviour without editing the code around it. Seams are what make code testable, and testability is the most reliable proxy for maintainability there is, because both are the same property: can this be used in a context other than the one it was written for?

The everyday seams, in order of preference:

  1. Parameters. Pass the dependency in. Boring, total, no framework.
  2. Constructor or factory injection. The same thing, for objects.
  3. A dependency-injection mechanism. FastAPI's Depends, React context.

Good when the wiring is genuinely cross-cutting.

  1. Module boundary substitution. Test doubles at the import level. Works,

but couples the test to the module layout.

  1. Monkeypatching. A seam you found rather than made. Sometimes the only

option in code you did not write. Never the design.

The diagnostic: if a piece of code is hard to test, it is not a testing problem. It is telling you it has no seam, which means it also cannot be reused, replaced, or run in a different environment. Reaching for a heavier mocking tool treats the symptom and preserves the cause. This is why fastapi-backend insists the service layer imports no FastAPI: not testing purity, but the guarantee that the same logic runs from a worker or a CLI.

Hidden inputs

The most underrated source of change cost. A function whose behaviour depends on something not in its signature cannot be understood locally, which means it cannot be understood at all.

The usual culprits: reading os.getenv deep in a service, the system clock, random, a module-level mutable singleton, the current working directory, an ambient locale or timezone.

Pass them in. now: datetime as a parameter is not ceremony, it is the difference between a test that is deterministic and a suite that fails after midnight. Both fastapi-backend and react-native-expo land on the same fix from different directions: validate configuration once at boot into one typed object, then inject it.

Refactoring without breaking things

The order matters, and skipping the first step is how refactors become rewrites.

  1. Get a test around the current behaviour first. Even a crude one. If you

cannot, that is the actual first task.

  1. Separate moves from edits. A commit that moves a file and a commit that

changes its contents. A diff that does both is unreviewable, so it does not get reviewed, so the bug ships inside it.

  1. Keep it running at every step. Add the new path, move callers over,

delete the old path. Three commits, each shippable. The big-bang branch that is green only at the end is where refactors go to be abandoned.

  1. Change the shape or the behaviour, never both. If you must, do the shape

first, merge it, then do the behaviour.

For data, the shape change is a migration and the same discipline applies with one extra constraint: write both, backfill, read new, drop old. That sequence appears in mongodb-production, fastapi-backend, and naming-conventions because it is the only sequence that survives a rolling deploy and stays rollback-safe.

When not to fix it

Coupling in code that never changes costs nothing. A gnarly module that has not been touched in two years and has no bugs is not a problem, it is finished.

Spend effort where change is actually happening. git log tells you where that is, and it is usually a much shorter list than a static analysis report.

The exception: coupling you are about to inherit. If the roadmap says that untouched module is next quarter's work, its history stops predicting its future.