Software Architecture · Technical Debt

Change Amplification: How Coupling Sets the Price of an Edit

Why a one-line rule change can require edits in seven files, how to tell legitimate breadth from amplification, and which couplings multiply the cost of every future change.

A support engineer reports that invoices show the wrong total for customers billed in a currency other than the default. The fix, once diagnosed, is to multiply by an exchange rate that is already stored on the account. Implementing it touches the invoice renderer, the PDF template, the monthly summary job, the CSV export, the reconciliation report, and two test fixtures. The rule is one line in each place, and the seven places exist because the same arithmetic was re-derived wherever a total was displayed.

That number — how many files a single intended behaviour change requires touching — is the most direct measure of architectural debt available, because it is the quantity that interest is charged on. Every future change to that rule pays the same multiplication, and the multiplication is a property of the structure, not of the people editing.

The mechanics of propagation

Coupling, in the sense that matters here, is shared knowledge. Two components are coupled when a change to a decision made in one requires a corresponding change in the other, because both encode the same fact. The fact can be a data layout, a rule, an ordering assumption, or an error convention. The coupling exists because the fact is represented twice, or because one component’s correctness depends on the other’s internal timing.

The propagation path is transitive. If InvoiceRenderer knows the total format, and InvoiceRenderer is used by both the PDF path and the email path, then knowledge of the format now constrains both paths. The cost of editing the format is proportional not to the number of direct dependents but to the size of the transitive closure of components whose behaviour would become wrong. In a graph where the average component has a handful of dependents, that closure grows quickly with depth: a fact known at the bottom of a five-level call chain can constrain dozens of components.

Parnas made the argument in 1972 that this closure should determine module boundaries. His point was not that modules should hide data, but that they should hide the decisions most likely to change — and that the correct decomposition is therefore different for every system, because the likely changes differ. Decomposing by processing step (parse, validate, format, write) hides almost nothing, because every step must know the shape of the record. Decomposing by the decision that varies hides the format behind a single interface, and the change becomes one edit plus a recompile.

Distinguishing amplification from legitimate breadth

Not every multi-file change is debt. Some changes are genuinely about the whole system: renaming a domain concept, adding a mandatory audit record to every write path, or changing the authentication scheme. Those touch many files because the fact they change really is global. The damage of treating them as amplification is that the team builds an abstraction layer around a concept that has exactly one legitimate shape, and then maintains that layer forever.

The distinguishing test is what the change is about. A change is amplified when the edit is to one rule and the other edits are to copies of that rule. It is legitimate when the edit is to a policy that applies everywhere and each site implements a genuinely different part of it. Concretely: changing “round half up” to “round half even” is one rule and should be one edit. Adding a tax jurisdiction to the calculation is a new input to a policy, and each site that supplies that input does different work.

A second test is whether the sites would be allowed to disagree. If two call sites could legitimately diverge — one is a quote, one is an invoice, and quotes round differently — then merging them into one function is not removing coupling, it is creating a false invariant that will be broken by the next requirement. The result is an interface with a boolean parameter whose two values mean “act like the old quote path” and “act like the old invoice path”, which is the original duplication relocated into a worse place.

// Both re-derive the line sum; each applies its own rounding rule to the result.
function invoiceTotal(lines: Line[], currency: Currency): Money {
  const raw = lines.reduce((sum, l) => sum + l.amount.minor, 0);
  return Money.of(raw, currency);                      // exact minor units
}

function quoteTotal(lines: Line[], currency: Currency): Money {
  const raw = lines.reduce((sum, l) => sum + l.amount.minor, 0);
  return Money.of(Math.round(raw / 5) * 5, currency); // rounds to a nickel
}

The correct move here is not to merge the two functions but to name the difference: the rounding policy is the thing that varies, so it becomes a parameter of one calculation, supplied by each caller from a policy table. The change axis is explicit, and a new rounding rule is a data change rather than a code change in two places.

Which couplings multiply, and which merely add

Amplification costs are dominated by couplings with high fan-in. A helper used in three hundred places has a large blast radius but a small per-edit cost, because its interface absorbs the change. A helper whose output format leaks into three hundred places has both a large radius and a per-site cost. The distinction is whether the shared component is a boundary or a conduit.

Three couplings are worth naming because they are common and each has a different remedy.

Shared mutable state. Two components that read and write the same record cannot be reasoned about independently, and any change to the record’s invariants requires auditing every access. The count of accesses is the multiplier. The remedy is to move all mutation behind one owner, which converts an audit into an interface change.

Shared data format without a type. A JSON payload consumed by four services is coupled at the field level; adding a required field breaks all four at once. The remedy is not usually a schema registry but a rule about which side may add fields, and whether unknown fields are preserved. This is where compatibility promises constrain architecture, as covered in Compatibility Promises as Borrowed Time.

Call-order dependence. If init() must precede load(), every caller knows the sequence, and any change to initialization requires changing all callers. This coupling is invisible in type signatures and only surfaces at runtime, which makes it expensive in a different way: the multiplication is discovered during debugging rather than during planning. Constructors that return fully initialized objects eliminate the class, at the cost of pushing I/O into construction — a real trade, not a free win.

A useful direction rule: dependencies should point from components that change often toward components that change rarely. A stable, heavily used module that depends on a volatile one transmits every change in the volatile one to all of its own dependents. Inverting that edge — via an interface owned by the stable side — does not remove the dependency but stops the propagation, because the stable side chooses when to implement a new version.

Measuring the multiplication honestly

The amplification of a specific rule can be counted directly: change the rule in one place, run the test suite, and see what fails and what silently keeps the old behaviour. The second category is the important one, and it is invisible to a green build.

For a whole codebase, the available proxy is co-change. Version history records which files were edited in the same commit or by the same change request, and pairs that recur together far more often than their individual edit frequencies would predict are logically coupled regardless of whether they share a symbol. A utility module and a configuration file that are edited together in most commits are one component wearing two filenames. The caveat is that co-change measures what people did, not what they should have done: a team that always edits two files together may be following a convention rather than compensating for a flaw. It is a signal to investigate, not a verdict.

Static structure supplies the other half. Import graphs, fan-in counts, and cyclic dependency detection are cheap and unambiguous, and cycles are the one structural property with a clear verdict: a cycle between two modules means neither can be understood, tested, or replaced without the other, so the pair behaves as a single module with two names. The cost of reasoning about a cycle grows with the square of its size in the worst case, since every member may need every other member’s state.

The failure mode of over-correction

The obvious response to amplification is to centralize everything that appears twice. That produces a different debt: a shared module whose interface must serve consumers with incompatible needs, growing a parameter for each. The symptom is a function with eight arguments, four of which are ignored by most callers, and a change to any one consumer’s needs that forces a change in a module used by all of them. Amplification has been traded for rigidity, and rigidity is paid in the same currency — every future change to any consumer now requires negotiating the shared interface.

The test that discriminates: centralize when the duplicated fact must hold identically everywhere and its changes are driven by one authority. Duplicate when the fact is coincidentally similar and the sites answer to different requirements. Duplicated rules that then drift are a separate problem with its own detection method, described in Divergent Duplicates and the Cost of Two Truths.

What remains after both corrections is the honest position: some coupling is inherent in the problem, and the architectural work is to choose which couplings are cheap. A system where the volatile decisions are behind narrow interfaces will have low amplification even if it is large, and a system where the volatile decisions are smeared across every component will have high amplification even if it is small and beautifully formatted.

References

  1. On the Criteria To Be Used in Decomposing Systems into Modules — D. L. Parnas, Communications of the ACM, 1972
  2. Structured Design — Stevens, Myers, and Constantine, IBM Systems Journal, 1974
  3. Refactoring: Improving the Design of Existing Code — Martin Fowler, 2nd edition, Addison-Wesley, 2018
  4. Agile Software Development: Principles, Patterns, and Practices — Robert C. Martin, Prentice Hall, 2002