A small requirement can produce a surprisingly large code change.
Suppose a product changes the rule for displaying customer names. The new rule sounds simple: show the preferred name when one exists, otherwise show the legal name. Yet implementing it requires edits in an API response mapper, an email formatter, an audit message, a report generator, and three tests that each reconstruct the same choice.
The problem is not that five files changed. Some changes legitimately cross many files. The warning sign is that one decision had to be rediscovered and edited in several places.
A useful design goal is change locality: when one concept changes, the code that defines that concept should be concentrated enough that developers can find, understand, and modify it without coordinating many unrelated edits.
This article explains how to recognize poor change locality, improve it without creating unnecessary abstraction, and judge when scattered changes are actually appropriate.
Think in decisions, not files
File count is an imperfect measure of change difficulty.
Adding a new feature may require a route, a service, tests, and documentation. Those files perform different jobs, so touching all of them can be perfectly healthy.
The more important question is:
How many places independently know the decision that is changing?
Consider a shipping rule repeated in several components:
checkout: free shipping when total >= 50
invoice: free shipping when total >= 50
admin preview: free shipping when total >= 50If the threshold changes from 50 to 60, three components must change together. Each component contains knowledge of the same business decision.
Now imagine they all ask one policy:
checkout -----\
invoice ------- > ShippingPolicy -> shipping cost
admin preview -/The number of callers has not decreased. The location of the decision has changed. The threshold now has one authoritative definition, while callers depend on its result.
That is the core mental model:
poor locality:
one decision -> many independent definitions
good locality:
one decision -> one authoritative definition -> many usersThe goal is not to put everything in one file. It is to give each important decision a clear home.
Repeated edits reveal hidden knowledge
Poor change locality often becomes visible through history rather than through a single code review.
A team may notice that every change to a pricing rule touches the same four components. A new status value requires edits to several switch statements. Renaming a domain concept repeatedly breaks serializers, logs, and formatting helpers that each encode their own interpretation.
These patterns are sometimes called shotgun surgery: a conceptually small change requires many small edits spread across the codebase.
The important part is not the label. The repeated edit pattern tells you that knowledge is scattered.
Suppose an order can be cancelled only while it is pending. Several callers implement the rule themselves:
if order.status == "pending":
cancel(order)Later, the business allows cancellation while an order is also awaiting_payment. Every caller must be found and updated.
The risky part is not typing the new condition. It is knowing whether you found every place that owns a copy of the rule.
A missed copy creates inconsistent behaviour:
web checkout: cancellation allowed
support tool: cancellation allowed
scheduled workflow: cancellation rejectedThe code still compiles. Individual tests may still pass. The defect comes from disagreement between duplicated decisions.
Move the decision to the concept that owns it
A direct improvement is to give the rule one explicit home.
Instead of asking callers to interpret status values:
if order.status == "pending" or order.status == "awaiting_payment":
cancel(order)let the order or a domain policy answer the meaningful question:
if order.canBeCancelled():
cancel(order)The exact location depends on the design. The important change is that callers no longer need to know which statuses imply cancellability.
The rule becomes:
canBeCancelled(status):
return status is pending
or status is awaiting_paymentNow a future rule change has a natural starting point. Tests for the rule can also live near its definition.
This improves more than edit count. It changes what callers need to understand.
Before:
caller must know status representation
+ caller must know cancellation policy
+ caller must combine them correctlyAfter:
caller asks a domain-level questionGood locality therefore reduces both change amplification and knowledge required at each call site.
Keep representation knowledge behind a boundary
Scattered change is especially common when many parts of a system understand the same representation details.
Imagine a service represents a money amount as integer cents. If business code repeatedly performs conversions like this:
display = amountInCents / 100then many places know two facts:
- the internal representation uses cents;
- conversion to a major currency unit requires dividing by 100 for this currency.
If representation changes, every knowledgeable caller becomes a migration site.
A boundary can contain that knowledge:
Money
- internal representation
- arithmetic rules
- formatting or conversion operationsCallers then depend on the concept rather than its storage detail.
This does not mean every primitive value deserves a wrapper type. A useful boundary earns its place when it contains knowledge that would otherwise be repeated, misunderstood, or changed together.
A practical question is:
If this representation changed tomorrow, which code should have a legitimate reason to care?
If the answer is “almost the whole application,” the representation may be leaking too far.
Distinguish duplicated knowledge from duplicated text
Two pieces of code can look similar without representing the same decision.
Consider two independent limits:
previewTitleLength = 80
notificationTitleLength = 80The values happen to be equal today. That does not prove they should share a constant.
If product requirements can change the preview limit without changing notifications, they are separate decisions. Combining them would create false coupling:
TITLE_LENGTH = 80A future request for 100-character previews now forces a developer to discover whether changing TITLE_LENGTH is safe for notifications.
By contrast, these copies likely represent one decision:
web: trialDays = 14
mobile: trialDays = 14
email: "Your 14-day trial"If all three must change whenever the trial policy changes, the duplication is semantic even though the surrounding code is different.
So do not ask only:
Does this code look duplicated?
Ask:
Must these places change for the same reason?
That question is much closer to the design problem.
Use change history as design evidence
Developers often try to predict future abstractions from the code alone. Repository history can provide better evidence.
If the same set of files repeatedly changes together for the same conceptual reason, investigate why.
For example:
change A: edit parser + validator + formatter
change B: edit parser + validator + formatter
change C: edit parser + validator + formatterThis does not automatically mean those components should merge. They may represent valid stages of a pipeline.
But if each stage repeats the same mapping between external codes and domain meanings, that shared knowledge may deserve one boundary.
History is useful because it shows actual maintenance pressure rather than imagined reuse.
Useful review questions include:
- Which files repeatedly change together?
- Are they changing because they perform different steps of one feature, or because they duplicate one decision?
- Does one component expose details that force its callers to change whenever its internals change?
- Do bug fixes repeatedly repair the same rule in several places?
- Does adding one case require finding many switches or conditionals across the system?
These questions turn change patterns into design feedback.
Make variation explicit when cases keep spreading
Another form of poor locality appears when every new variant requires edits throughout a workflow.
Suppose a notification system supports email and SMS. Code throughout the application branches on channel type:
if channel == EMAIL:
...
else if channel == SMS:
...Adding push notifications may require changing validation, dispatch, formatting, retry logic, and tests in several unrelated modules.
Some cross-cutting edits are unavoidable because a new channel genuinely affects several responsibilities. But repeated branching can also mean that channel-specific behaviour has no clear home.
A better shape may be:
NotificationChannel
validate(message)
format(message)
deliver(message)with implementations for each channel.
Now adding a channel concentrates channel-specific decisions while shared orchestration remains stable.
This is not an argument to replace every conditional with polymorphism. A two-case conditional that rarely changes can be clearer than a hierarchy of interfaces and classes. The abstraction becomes useful when variation is important, recurring, and otherwise scattered.
Do not optimize for the fewest changed files
Change locality can be misused as a metric.
A developer might try to make every feature change touch one file by building a giant configurable module. That can reduce file count while making the file harder to understand, test, and modify safely.
Healthy systems often require coordinated changes across layers:
new user capability
|
+-> domain behaviour
+-> application workflow
+-> external interface
+-> tests
+-> documentationThose edits are related to one feature, but they are not duplicated definitions of one decision. Each layer has a different responsibility.
The better goal is:
Minimize the number of places that independently know each decision, not the number of files in every change.
A change touching six focused files can be easier to reason about than a change touching one 4,000-line module.
Avoid the global-helper trap
When developers first notice scattered logic, a common response is to move it into a generic utility module.
That can centralize code without improving design.
Suppose several features need to determine whether an account can place an order. Moving the rule into this function:
utils.isAllowed(account)removes textual duplication, but the name and location say little about the decision. Over time, utils may become a collection of unrelated policies used from everywhere.
A more meaningful boundary communicates ownership:
OrderingPolicy.canPlaceOrder(account)or, when appropriate:
account.canPlaceOrder()The difference is not cosmetic. A named concept tells future developers where related rules belong and what kinds of changes should affect it.
Locality improves when code has an obvious home, not merely a shared address.
Watch for abstractions that collect unrelated reasons to change
Centralization can go too far.
Imagine one BusinessRules module contains pricing, cancellation, account eligibility, shipping, and notification rules. Each rule is now “in one place,” but the module changes for many unrelated reasons.
This creates a different maintenance problem. Developers working on independent concepts contend with the same component, and understanding one rule may require navigating many others.
Good locality has two sides:
keep one decision together
and
keep unrelated decisions apartThis is closely related to cohesion: a module is easier to reason about when its contents belong together for a clear reason.
A useful boundary is therefore not simply central. It is conceptually focused.
Improve locality incrementally
You do not need a large redesign to improve scattered decisions.
Suppose a rule exists in five callers. A safe sequence can be:
- Identify the exact decision that is duplicated.
- Add focused tests that describe its current expected behaviour.
- Give the decision a clear name and authoritative location.
- Move one caller to the new boundary.
- Confirm behaviour remains unchanged.
- Move the remaining callers.
- Remove obsolete copies once nothing depends on them.
This sequence separates two concerns: preserving behaviour and improving structure.
For risky legacy code, that separation matters. Trying to change the rule and relocate it at the same time makes failures harder to diagnose because a test failure could come from either the behavioural change or the refactoring.
First establish one source of truth. Then change the truth.
Measure the cost that matters
Change locality is valuable because scattered knowledge creates practical costs.
Search cost
Developers must discover every place that knows the rule. Missing one can create inconsistent behaviour.
Coordination cost
A small conceptual change may require reviews from several owners because the knowledge crosses module or team boundaries.
Verification cost
When a rule is implemented independently in many places, each copy needs confidence that it still agrees with the others.
Reasoning cost
A developer cannot understand the rule by reading one focused component. They must assemble it mentally from multiple locations.
Regression risk
The more independent edits required, the more opportunities there are for one implementation to remain stale.
Centralizing a decision does not eliminate these costs entirely. It reduces the number of places where correctness depends on synchronized knowledge.
Know when scattered changes are acceptable
Not every repeated change deserves abstraction.
Leaving code separate can be the better choice when:
- two rules currently look alike but evolve independently;
- the duplication is small and unlikely to change;
- centralization would introduce a dependency between otherwise independent modules;
- the abstraction would need many flags or special cases to serve its callers;
- the concept is not yet understood well enough to name a stable boundary.
Premature centralization can be as harmful as duplication. If two concepts are forced behind one abstraction and later diverge, the shared code accumulates conditions that make both cases harder to understand.
A practical rule is to prefer evidence over prediction. Repeated coordinated changes, repeated bugs, and repeated explanations are stronger signals than a one-time visual similarity.
Use a simple review test
When reviewing a design or pull request, choose one important decision and ask:
If this decision changes,
where would I start?
Can I identify one authoritative place?
Which other edits are consequences of that change,
and which places independently redefine it?The answer does not need to be “one file.” Tests, adapters, documentation, and interfaces may legitimately change too.
What you want is one clear source of meaning.
If several components independently interpret the same status, threshold, mapping, policy, or lifecycle rule, the system is asking developers to keep those interpretations synchronized manually. That is a strong candidate for better locality.
Conclusion
Maintainable software does not make every change small. It makes the reason for a change easy to locate.
When one engineering decision is scattered across many independent implementations, routine changes become search-and-synchronize exercises. Developers must find every copy, update them consistently, and verify that none was missed.
Improve the design by giving important decisions clear, focused homes and letting callers depend on meaningful operations instead of duplicated knowledge. Use change history as evidence, distinguish duplicated knowledge from merely similar text, and avoid abstractions that combine concepts which can evolve independently.
The practical target is simple: when one idea changes, a developer should be able to find where that idea lives, change it with confidence, and understand why the remaining edits—if any—are necessary.