Change Amplification
One requirement changes; count the modules, interfaces, tests and deployments that must move with it. Lower is usually better, and not always.
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.
One sentence changed in the requirements. How many places in the code have to change, and is that number a design failure?
Finance says prices must be stored and displayed in the customer's own currency. It is one sentence in a ticket. The estimate comes back as six weeks and nobody can explain the six weeks convincingly.
Six weeks is just what a big change costs. The codebase is fine — currency is genuinely everywhere, so of course it touches everything.
It is not everywhere because currency is cross-cutting; it is everywhere because the *representation of money* was never given an owner, and forty modules each made their own decision about it.
- It is not everywhere because currency is cross-cutting; it is everywhere because the *representation of money* was never given an owner, and forty modules each made their own decision about it.
- The next money change — rounding rules, minor units for currencies that do not have two decimal places, a display format — costs six weeks again. The number does not fall with practice, which is what tells you it is structural.
- The estimate cannot be defended because nobody can enumerate the forty places, so the six weeks is really "a fortnight of edits plus four weeks of finding out what we missed".
- The team ships it, and eighteen months later a rounding bug appears in the two modules that were missed, in a code path nobody exercised. High amplification is not just expensive, it is silently incomplete.
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.
- The system already stores money as a bare number of cents in about forty places, some of which are database columns that cannot be changed without a data migration.
- Two other teams read those columns directly, so any change to representation is a cross-team coordination, not a refactor.
- Historical orders must keep their original amounts exactly — this is an accounting record, not a cache.
- An amount and its currency travel together. An amount without a currency is not a smaller version of the truth; it is a bug waiting for the first customer outside your home market.
- No arithmetic ever mixes currencies without an explicit, dated conversion.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- One type owns what money is: amount, currency, and the operations that are legal on it.
- Every other module owns *using* money and owns none of the decisions about representing it.
- The persistence layer owns the mapping between that type and its stored columns, and is the only place that knows the storage shape (State Ownership).
- The seam is around the concept, not around a layer: everything that knows what a currency is sits inside, everything that merely passes money around sits outside (Finding Seams).
- The database columns are on the boundary, which is why this change needs a migration strategy and not just a refactor (Expand and Contract).
- The cross-team read of those columns is a contract you did not know you had, and it has to be treated as one (Internal Module Contracts).
Price the currency change under both designs
This is the device the whole domain leans on: take one real requirement change, and list what it touches under the design you have and the design being proposed. It converts an argument about elegance into an argument about a count, which can be checked.
It also forces the uncomfortable half of the comparison. The improved design always gives something up — here, a coordination point and a data migration — and a proposal that cannot name what it gave up has not been examined.
Every amount in the system acquires a currency. Arithmetic must refuse to mix currencies, rounding must respect each currency's minor units, and historical orders must keep the amounts they were charged.
Twelve modules and twelve suites, plus a schema change read by two other teams. The expensive part is not the edits — it is that nothing tells you when you have found the last site, so the change ends when someone decides to stop looking.
One type, one mapping, and one integration check that a real order still charges what it displays. Adding the third currency after this costs nothing at all, because the decision has an address.
The amplification you should not remove
A high count is a prompt to look, not a verdict. Some requirements genuinely span many capabilities, and the honest response is to make each edit mechanical and obvious rather than to invent a module that pretends the requirement is local.
The tell is whether the places that must change share a *decision* or merely share a *requirement*. Twelve modules that each decided how to represent money share a decision, and that is a design failure. Twelve modules that each must record who performed an action share a requirement, and pulling their audit logic into one module gives that module twelve reasons to change.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Add a currency to every amount | Twelve modules edit | Each module made its own representation decision | Genuine amplification. Give the decision one owner (Value Objects). |
| Add an audit record to every state-changing action | Twelve modules edit | The requirement really does apply to twelve different actions | Honest cost. Make it uniform and hard to forget — one call at one enforced boundary — but do not centralise the twelve actions themselves (Effect Boundaries). |
| Add a field to an API response | Handler, DTO, mapper, schema, client type and two tests edit | Layering multiplies a single change by the number of layers it passes through | Usually acceptable if each edit is mechanical. If the edits require judgement, the layers are not just passing data and the boundary is in the wrong place (Package by Layer). |
| Change one business rule | Nine modules edit and each edit is slightly different | The rule was reimplemented, not reused, and the copies have drifted | The worst case in this table: the copies no longer agree, so the change is also a reconciliation (Duplicate Knowledge). |
| Rename a domain concept | Sixty files edit | A name is not a decision — it is a mechanical, tool-assisted change | Not amplification in the sense that matters. Count judgement, not keystrokes (Rename). |
| Add a tenant boundary to a single-tenant system | Everything edits | A structural assumption was baked in everywhere at the start | Real, and largely unavoidable retroactively. This is the class of change worth building for before the evidence arrives (The Cost of Change). |
Getting the count honestly
The count is only useful if it comes from what actually happened. Merge history is the cheapest evidence in software engineering and almost nobody looks at it: which files changed together, how often, and for which kind of ticket.
What you do with the output matters more than the query. It tells you where to look. It does not tell you that a module is bad, and it must never be turned into a published score — "coupling: 7.2" tells a reader nothing they can act on and gives an argument a precision it has not earned.
1# For one recurring requirement kind, list the files that changed.2git log --since="1 year ago" --grep="tax\|vat\|pricing" \3 --name-only --pretty=format: \4 | sort | uniq -c | sort -rn | head -205 6# Rough co-change: how many commits touched BOTH files?7git log --since="1 year ago" --name-only --pretty=format:%H \8 | awk '/^$/{next} /^[0-9a-f]{40}$/{c=$0; next} {print c, $0}' \9 > /tmp/commit-file.txt10# then count commit ids that appear with both paths of interestRead the first output as "here is where this requirement kind lands", not as a ranking of code quality. A file at the top may be the correct single owner of something that changes often — which is the design working, and looks identical in this output to the design failing (Stability and Dependency Direction).
How to build it
Most important first.
- Introduce the owning type first, alongside the existing numbers, so nothing breaks yet. A
Moneyvalue with amount and currency, and arithmetic that refuses to mix them (Value Objects). - Convert callers in slices, starting with the ones where a currency bug would be most expensive, not the ones that are easiest.
- Expand the schema before contracting it: add the currency column, dual-write, backfill, verify, then stop reading the old shape (Expand and Contract).
- Make the illegal case unrepresentable where the language allows it, so the remaining forty sites cannot compile with a bare number (Making Illegal States Unrepresentable).
- Do not chase the amplification number for its own sake. Fix it where the change actually recurs; leave it where it does not.
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.
- Before: any change to how money is represented, rounded, formatted or compared costs roughly forty edits plus a discovery phase that is longer than the edits, plus a cross-team negotiation for the columns.
- After: the same change costs one module and its tests. Adding a third currency after the second costs approximately nothing, which is the actual shape of the win — the second one is expensive, the tenth is free.
- What did not get cheaper: a change to how money is *displayed per locale* still touches every view, because we drew the boundary around the value and not around presentation. Naming that limit is part of the design.
- The migration itself is a permanent cost you pay once: dual-write, backfill and verification code that must be written, run and then deleted, and the deletion is the step teams skip.
- A high-fan-in owning type becomes a coordination point: everyone queues behind the same module and the same test suite.
- Value types cost allocations and unwrapping, which matters in a hot loop and does not matter in a checkout (Allocation and Copies).
- The migration is risk applied to working code, and the risk is realised on production data rather than in a test — which is a different and worse kind of risk than an ordinary refactor carries.
What can go wrong
- The type is introduced and the forty raw numbers stay, so the codebase now has two representations of money and no rule about which is authoritative.
- The type is introduced with an implicit "default currency", which makes every un-migrated site compile and silently mean the wrong thing — the worst possible outcome, because it converts a compile error into a financial one.
- Amplification is reduced by putting money handling in a shared utility module that also grows date handling and string helpers, which trades one problem for a different one (The Utility Dumping Ground).
- The mitigation itself fails: the team measures co-change, sees a high count for a module, and splits it — when the count was high because the module is where a genuinely volatile requirement lives, which is correct.
- Everything that touches money now depends on one module — deliberate, visible, high fan-in (Fan-in and Fan-out).
- That module must depend on nothing: no database, no formatting locale, no configuration. A type that everyone depends on and that depends on something volatile propagates that volatility to everyone (Volatile Dependencies).
- The migration depends on the two downstream teams, which is a schedule dependency and the real critical path.
- "Lower amplification is always better." No. A requirement that genuinely spans five capabilities — an audit trail, a tenancy boundary, a consent rule — must touch five modules, and forcing it into one produces a module with five reasons to change (Divergent Change).
- "So count co-changing files and split the top of the list." Co-change also measures modules that are simply where the work is happening this quarter. It is a prompt to look, never a verdict (Shotgun Surgery).
- "Amplification is the same as coupling." Related, not identical: a module can have many dependents and low amplification, if what they depend on is stable. Amplification is about what must change together, not about who calls whom (Afferent and Efferent Coupling).
- "This is what DRY means." DRY is about duplicated *knowledge*, and the forty sites here duplicate a representation decision, which is knowledge. Two functions that both happen to round to two decimals for unrelated reasons are not the same case (DRY: Knowledge, Not Lines).
- shotgun-surgery
- primitive-obsession
- duplicate-knowledge
Testing it, and how it ages
- Property tests on the money type: addition never mixes currencies, rounding is total, and conversion is explicit and dated (Property-Based Testing).
- Characterization tests on the existing totals before the migration, so "historical orders are unchanged" is asserted rather than assumed (Characterization Tests).
- A migration test that runs the backfill against a copy of real data and diffs the result, because the failure mode here is data, not code (Data Migration).
- Once money has an owner, the next four money requirements land inside it, which is when the extraction repays. If they do not arrive, it did not.
- The type will grow: allocation and splitting, minor-unit rules, exchange-rate provenance. That growth is the boundary working, not the boundary failing.
- It stops being right if the business acquires genuinely different money semantics per product line — crypto alongside fiat, say — at which point one type is the wrong shape.
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 one requirement change forces N edits, and that N is set by how many places own the same decision, holds regardless of language or paradigm. What changes is the mechanism — a value type, a module, a trait — not the arithmetic.
- SIMULATEDThe module and test counts in the change-impact panels here come from an Engineer Atlas model of a small teaching codebase, not from measuring a real system. The shape transfers — a representation decision made in forty places costs forty edits — and the specific numbers do not; do not quote them as a benchmark.
- CONTESTEDThe strongest opposing view: co-change analysis and amplification counting are lagging indicators that mostly reveal where a team happened to be working, and acting on them reorganises code around last quarter's project. Proponents of that view prefer to reorganise only when a specific change is actually painful, and they are right that many amplification-driven refactors are premature. The counter is that "wait until it hurts" systematically defers the fix past the point where it is cheap, because pain arrives distributed across many small tickets rather than as one visible event.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — System Design — at service grain the same count becomes the number of deployments that must be coordinated and the number of teams that must agree, which is why cross-cutting change is the argument against splitting early.