FoundationsGENERALLIFETIME-SPECIFICCONTESTED

What Makes Software Hard to Change

Not size, not age, and not ugliness. A change is expensive when the knowledge it touches is spread across places that do not know about each other.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

Two codebases are the same size and one is a nightmare to change. What is actually different about it?

The requirement

The business wants VAT applied to European orders. It is one sentence, and the estimate comes back at three weeks.

The obvious build

The problem is that the codebase is messy. If we clean it up — better names, smaller functions, consistent style — changes will get easier.

Why it breaks

Tidy code with the price rule written out in nine places is still nine edits, and the tidiness makes them harder to find, not easier: they all look reasonable.

How it breaks as requirements change
  • Tidy code with the price rule written out in nine places is still nine edits, and the tidiness makes them harder to find, not easier: they all look reasonable.
  • The three weeks are not spent typing. They are spent discovering where the rule lives, deciding whether each site is the same rule or a different one that happens to look identical, and proving nothing else moved.
  • A codebase can be beautiful and still have no seam at the place the requirement lands, which means the change has to be threaded through code that was never asked to know about tax.
  • Meanwhile a genuinely ugly module with one owner and one entry point absorbs a change in an afternoon. Ugliness and changeability are close to uncorrelated, which is the observation that starts this domain.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • The code already exists and is in production; nothing can be rebuilt from scratch.
  • The team that wrote most of it has partly moved on, so intent has to be recovered from the code.
  • There is a release every week, so any change has to be shippable in slices.
Invariants
  • Existing orders must keep pricing exactly as they did — a change to new behaviour must not silently reprice history.
  • Every price shown to a customer must equal the price they are charged.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • Some single place must own "what does this order cost", so that a pricing change has one address.
  • Whatever owns pricing must not also own persistence, notification or formatting, or it acquires their reasons to change as well.
  • The rest of the system is responsible for *asking* for a price, never for computing one.
Boundaries
  • The seam belongs where the knowledge is: a boundary around the price rule, not around a technical layer.
  • Boundaries drawn by technical type — controllers, services, repositories — do not help here, because a tax change crosses all three and stays inside none.
  • The test for a boundary is whether a likely change lands inside it. That is the only test that matters (Changeability Is the Goal).

Price the change, not the code

The instinct when a codebase feels bad is to describe the code: it is messy, it is old, the functions are long, the names are poor. None of those predict what a change will cost, and reasoning from them leads to cleanups that make the code nicer and the next requirement no cheaper.

The measurable thing is different and much more useful: take a requirement change that is actually likely, and count what it touches. That number is the design's score, and it is the only score in this domain that is not made up.

Add VAT to European orders
The change

Prices shown and charged must include VAT for customers in the EU, at a rate that depends on the country and the product category.

Price computed inline wherever it is needed
CheckoutControllerCartViewOrderSummaryEmailInvoicePdfAdminOrderListRefundServiceExportJobPriceApiSubscriptionRenewal
testscheckout_testcart_testemail_testinvoice_testadmin_testrefund_testexport_testprice_api_testrenewal_test
9 modules · 9 test files

Nine sites, nine test files, and no way to know they now agree except by reading all nine. The expensive part is not the edit — it is establishing that the ninth site was the last one.

A single pricing module owns "what does this cost"
Pricing
testspricing_testcheckout_integration_test
1 module · 2 test files

One edit, one focused test suite, plus one end-to-end check that the displayed price still equals the charged price. Discovery cost is zero because the rule has an address.

what it cost Every caller now depends on the pricing module, so it has nine dependents and becomes a coordination point: two teams changing pricing in the same sprint now conflict where before they edited different files. The extraction also cost a risky refactor of working code, and it does nothing for a change to how prices are *stored*.

Duplicated code and duplicated knowledge are different things

The nine sites above are expensive because they encode the same *decision* — how a price is computed — in nine places. That is duplicated knowledge, and it is the thing that costs.

Two blocks of identical code are not automatically that. Two validation functions that both happen to check a string is non-empty are the same lines and different knowledge; merging them creates a shared thing that must now satisfy two unrelated reasons to change, and the next requirement pulls it in two directions. Meanwhile two blocks that look nothing alike can encode the same rule — one in a SQL WHERE, one in a form validator — and those genuinely are one thing in two costumes.

The test is not textual similarity. It is: when this decision changes, must both places change together? If yes, it is one piece of knowledge. If no, leave them alone (DRY: Knowledge, Not Lines).

Identical lines, different knowledge
Merged because they looked the same
// shared/validate.ts
export function isValidLength(s: string) {
  return s.length >= 3 && s.length <= 50
}

// used for: usernames, and separately for product tags
// six months later: "usernames must allow 2 characters"
// -> changing it silently changes tag validation too
Left separate because they change separately
// users/username.ts
const MIN = 3, MAX = 50   // account policy

// catalog/tag.ts
const MIN = 3, MAX = 50   // search index constraint

// identical today. They answer to different owners,
// so when one moves the other must not.

The duplication costs two lines. The merge costs a coupling between account policy and search indexing that nobody declared and nobody will remember — and the bug it produces is silent, because tag validation changing is not what anyone was testing. Duplication is visible; a wrong abstraction is not.

What the rest of this domain is for

Everything that follows is a way of controlling that number. Responsibilities decide who owns a piece of knowledge. Boundaries decide where a change can be contained. Coupling decides how far a change travels. Abstraction decides what a caller has to know. Testing decides whether you can tell that nothing else moved.

It is worth being clear that none of this is free and none of it is always right. Each move buys change locality with some combination of indirection, coordination cost and risk, and this domain's job is to make that exchange explicit rather than to recommend structure by default.

One requirement, and what decides its cost
responsibilitiescouplingboundariestestsand this is the number that compoundsRequirement changeWhich knowledge does it touch?How many places own that knowledge?How far does the change travel?Can you tell what else moved?Cost of this changeCost of the NEXT change
UserLLMAgentToolDataDecisionHumanGuardrail

How to build it

Most important first.

  • Find the knowledge, not the code. Ask what the requirement is really about — a rule, a shape, a lifecycle — and where that knowledge currently lives (Duplicate Knowledge).
  • Give it one owner. A change is cheap when it has exactly one address, and expensive roughly in proportion to how many addresses it has (Change Amplification).
  • Make the rest depend on that owner rather than reimplementing it, so the dependency is visible and directional (Dependency Direction).
  • Protect the invariant at the boundary you just created, so nothing downstream can bypass it (Where Invariants Live).
  • Do this for the knowledge that actually changes. Applying it everywhere is over-design, and it costs the same as the problem it prevents (Over-Design and Under-Design).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Before: a pricing change costs nine edits, a full-system regression, and a discovery phase longer than the edit. The cost is roughly linear in the number of places that know the rule, and the discovery cost is superlinear because each site has to be compared with every other.
  • After: a pricing change costs one edit and one focused test run. The cost of the *next* pricing change is now bounded and roughly constant.
  • What did not get cheaper: a change to how prices are *stored* still crosses the boundary, because we drew it around the rule and not around persistence. That is the honest limit of this move.
What the recommended approach costs
  • One owner means one point of contention: every team that touches pricing now queues behind the same module and the same tests.
  • The indirection is real. A reader tracing a price now goes through a call they did not have to before, and for someone reading the code once that is a cost with no benefit.
  • The extraction itself is risk. It is a change to working code, made to make future changes cheaper — which is a bet, and it loses if the future change never comes.

What can go wrong

Failure modes
  • The rule gets one owner and the nine old copies stay, so now there are ten and one of them is authoritative in a way nobody has written down.
  • The boundary is drawn around the wrong thing — around "tax" rather than "price" — and the next requirement, a discount, is outside it again.
  • The extraction is done for knowledge that never changes, buying indirection and no flexibility (Premature Abstraction).
Dependencies, and their direction
  • Everything that displays or charges a price now depends on the pricing owner — a deliberate, visible fan-in.
  • The pricing owner must depend on nothing volatile: not the database, not the payment provider, not the HTTP layer (Volatile Dependencies).
Misreads
  • "So we should extract everything." No. Extraction pays only where change actually arrives, and everywhere else it is cost with no return (YAGNI, With Its Bill Attached).
  • "Clean code is changeable code." They are different properties. Clean code is easier to read; changeable code is code where the knowledge has one home. You can have either without the other.
  • "The problem is that the codebase is old." Age is not the variable. A ten-year-old module with one owner and good tests is easy to change; last month's code with a rule copied six times is not (What "Legacy" Actually Means).
  • "This is what layering is for." Layers separate technical concerns. A tax rule crosses every layer and lives in none of them, which is precisely why layering does not answer this (Package by Layer).
Smells this explains
  • duplicate-knowledge
  • shotgun-surgery

Testing it, and how it ages

What to test, and at which boundary
  • Characterize existing pricing before touching it, so "history did not reprice" is an assertion and not a hope (Characterization Tests).
  • Test the pricing owner directly, with no database and no HTTP — it should be the easiest thing in the system to test, and if it is not, the extraction is not finished (Testing as Design Feedback).
  • One integration test that a real order end to end charges what it displays, because that is the invariant.
How this design ages
  • The next four pricing requirements — discounts, currency, promotions, rounding rules — all land inside the boundary, which is why the extraction pays for itself around the second one.
  • Eventually pricing grows its own internal structure and the single owner becomes a module with several types. That is success, not drift.
  • It stops being right if pricing becomes genuinely different per product line, at which point one owner is the wrong shape and the boundary has to move.

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • GENERALThat change cost tracks the number of places knowledge lives follows from having to find and update each of them, so it holds across languages, paradigms and decades.
  • LIFETIME-SPECIFICFor code with a known short life — a migration script, a campaign page being deleted in six weeks — nine copies are genuinely cheaper than one abstraction, because the tenth change never arrives. The argument here is about code that must keep absorbing requirements.
  • CONTESTEDA serious counter-position holds that most extraction is speculative and that duplicated, locally-obvious code beats a shared abstraction whose callers have diverging needs — the "wrong abstraction is more expensive than duplication" argument. It is right often enough to take seriously; the difference is whether the duplicated sites encode the *same* knowledge or merely look alike (Duplicate Knowledge).

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Architecturemodular-monolith
Domains that do not exist yet
  • Testing & Reliability Engineering — a change is only as safe as your ability to detect what else it moved, which is a coverage and confidence question this domain assumes rather than answers.