A requirement can sound small and still produce a surprisingly large patch. Add one order state, rename one business concept, or change one validation rule, and suddenly five modules, several tests, and a serializer all need coordinated edits.
The problem is not simply that the codebase is large. It is change amplification: one conceptual change causes many implementation changes because knowledge about that concept is spread across the system.
This article shows how to recognize change amplification, trace it back to duplicated knowledge, and reduce it without forcing unrelated code into one giant abstraction.
Count decisions, not edited lines
Suppose an application represents an order status in several places:
validation: status in ["pending", "paid", "shipped"]
UI label: "pending" -> "Pending"
serializer: accepts pending, paid, shipped
workflow: if status == "shipped": ...A new cancelled status requires edits in three or four places. None of those edits is difficult by itself. The risk comes from having to remember all of them.
The useful question is not “how many lines changed?” It is:
How many places must know the same engineering decision?
If the valid set of order states is one domain decision, maintaining separate lists of those states is duplicated knowledge even when the code is not textually identical.
That distinction matters. Copy-paste duplication is easy to see. Knowledge duplication can hide behind different syntax, different data structures, and different modules.
Start with the smallest useful refactoring
Assume validation and serialization both maintain their own lists:
validateStatus(status):
return status in ["pending", "paid", "shipped"]
parseStatus(value):
if value not in ["pending", "paid", "shipped"]:
return error
return valueThe first improvement does not require a framework or a new architecture. Give the shared decision one source:
ORDER_STATUSES = ["pending", "paid", "shipped"]
validateStatus(status):
return status in ORDER_STATUSES
parseStatus(value):
if value not in ORDER_STATUSES:
return error
return valueNow adding cancelled changes the definition once, and both consumers see the same set.
The important result is not fewer lines. It is that two behaviors can no longer disagree about which status values exist unless one deliberately adds another rule.
This is a simplified teaching example. In production code, a status may be represented by an enum, value object, type, schema, or another mechanism. The design principle is independent of that representation: keep one decision authoritative where practical.
Separate shared knowledge from different policies
Centralizing every related-looking condition can create the opposite problem.
Suppose the application also decides which statuses a customer may cancel:
CANCELLABLE_STATUSES = ["pending", "paid"]It would be a mistake to replace this with ORDER_STATUSES. The two sets answer different questions:
ORDER_STATUSES -> Which states can an order have?
CANCELLABLE_STATUSES -> From which states may a customer cancel?They overlap today, but they are not the same decision. A shipped order remains a valid order state even though it cannot be cancelled by the customer.
This gives a practical test for deciding whether knowledge should be unified:
If one rule changes, must the other change for the same reason?
If yes, they may represent one duplicated decision. If no, keeping them separate protects independent policies from becoming accidentally coupled.
This is why removing change amplification is not the same as applying DRY mechanically. The goal is to reduce duplicated knowledge, not merely repeated tokens.
Trace a scattered change back to its reason
Large patches often reveal where knowledge is leaking.
Imagine a request to change the maximum attachment size from 10 MB to 20 MB. The patch modifies:
upload validation
error message
API documentation
background import validation
test fixturesSome of those edits may be legitimate. Documentation and tests often need to change when behavior changes. The interesting question is whether multiple production paths independently define the 10 MB rule.
A useful investigation is:
- Name the decision in plain language: “the product accepts attachments up to 20 MB.”
- Find each production location that encodes that decision.
- Ask why each location needs to know it.
- Choose the component that should own the rule.
- Make other components ask that owner or consume an authoritative representation.
For example:
AttachmentPolicy.maxSizeBytes
AttachmentPolicy.accepts(sizeBytes)The HTTP upload path and background importer can both use the same policy without sharing transport-specific code.
Notice what should not necessarily move into AttachmentPolicy: HTTP status codes, multipart parsing, queue message formats, or user-interface wording. Those belong to different concerns even though they react to the same rule.
Prefer an authoritative model over synchronized copies
Sometimes duplication appears because each layer creates its own representation of the same concept.
Consider a shipping method defined in three places:
backend: STANDARD, EXPRESS
API schema: standard, express
frontend: standard, expressAcross independently deployed systems, eliminating every copy may be impossible or undesirable. The API contract itself is a boundary, and consumers may need their own representation.
In that situation, the goal changes from “one copy in the entire organization” to one authority plus deliberate synchronization.
For example, an API schema can be the published contract while server and client code are generated or validated against it. Alternatively, compatibility tests can verify that independently maintained representations agree.
The engineering decision is explicit: copies exist because a boundary requires them, and a mechanism detects drift.
Without that mechanism, every copy becomes another place a developer must remember during a change.
Use change history as a design signal
Change amplification is often easier to see across several patches than in one snapshot of the code.
If unrelated developers repeatedly edit the same group of files for the same kind of requirement, those files may share knowledge that has no clear owner. If one business rule repeatedly causes edits across many distant modules, the abstraction boundary may cut through that rule rather than contain it.
This does not mean files that change together must always be merged. They may represent valid layers or independently deployable components. Instead, use co-change as a question generator:
Why do these files keep changing together?
Do they encode one decision or several independent decisions?
Could one expose a stable concept that the others consume?
Is the duplication required by a system boundary?The answer should come from the responsibilities of the code, not from change frequency alone.
Watch for abstractions that only move the amplification
A common response to scattered edits is to introduce a generic helper:
RuleRegistry.get("order.status.values")Now the values live in one place, but every caller depends on a string key and a generic registry. The number of edited definitions may fall while the design becomes harder to understand.
Prefer abstractions that name the concept being protected:
OrderStatus.isValid(value)
AttachmentPolicy.accepts(size)
ShippingMethod.parse(value)A useful abstraction does more than centralize data. It gives the decision a meaningful owner and presents operations that match how other code needs to use it.
Another failure mode is a “shared” module that accumulates unrelated rules solely because many modules use them. That reduces physical duplication but increases conceptual coupling. A change to one domain concept should not require understanding a bag of unrelated utilities.
Accept some amplification when boundaries require it
Not every multi-file change is a design smell.
Changing a public API may legitimately require updating implementation code, contract documentation, compatibility tests, and client examples. A database migration may require a schema change plus code that can operate during a mixed-version deployment. A user-visible terminology change may intentionally touch many presentation surfaces.
The key distinction is whether those edits represent different responsibilities reacting to one change or multiple copies of the same decision.
The first can be healthy. The second creates opportunities for inconsistent behavior.
Trying to force all consequences of a requirement into one file can produce abstractions that cross natural boundaries and make local reasoning worse. The aim is not a one-file patch. The aim is to make each engineering decision authoritative and make its necessary consequences explicit.
Reduce amplification where mistakes are expensive
Change amplification has a cost even when every edit is straightforward. More coordinated locations mean more opportunities to miss one, more review surface, and more assumptions a developer must hold at once.
Prioritize rules that change often, affect correctness, or have already drifted between implementations. A stable constant duplicated in two isolated places may not justify a new abstraction. A pricing rule copied across five transaction paths probably deserves closer attention.
Use the smallest mechanism that gives the decision a clear owner. Sometimes that is a constant. Sometimes it is a function, value object, policy component, schema, or compatibility test across a real system boundary.
Make one reason for change have one clear home
When a small requirement repeatedly creates wide patches, do not begin by asking how to make the edits faster. Ask why so many places need to know the same thing.
Trace the change back to the decision that caused it. Centralize that decision when the locations must change for the same reason. Keep genuinely independent policies separate. Where deployment or architectural boundaries require copies, establish an authority and a way to detect drift.
The practical goal is not zero repetition. It is change locality: when one concept changes, developers should be able to find its owner, understand its consequences, and update the system without relying on memory to keep scattered knowledge synchronized.