A product rule changes from “free delivery above $50” to “free delivery above $60.” The code change sounds small, but the developer has to edit a checkout service, an order validator, a receipt formatter, and two unrelated utility modules. Missing one location leaves the system internally inconsistent.
The problem is not simply that several files changed. Some changes legitimately cross many files. The warning sign is that one conceptual decision is represented in several places that must change together.
This article develops a practical way to reason about that problem: change locality. A design has good change locality when a single engineering decision can usually be changed in one coherent area, with other parts of the system depending on its result rather than repeating its knowledge. You will learn how to recognize poor locality, improve it without creating unnecessary abstractions, and decide when a distributed change is actually appropriate.
Think in decisions, not file counts
A useful question during design and review is:
If this rule changes, where would I need to edit the system?
The answer matters more than the raw number of files.
Suppose an application decides whether an order receives free delivery. A simplified version might repeat the rule:
checkout:
if subtotal >= 50:
shipping = 0
receipt:
if subtotal >= 50:
label = "Free delivery"
order validation:
if subtotal < 50 and shipping == 0:
reject orderAll three fragments know the same policy threshold. Changing the policy means finding every representation and changing them consistently.
Now compare a design in which one component owns the decision:
shippingQuote = shippingPolicy.quote(order)
checkout uses shippingQuote.cost
receipt uses shippingQuote.label
validation checks shippingQuoteThe second design may still involve several files when behavior changes. The important difference is that the policy knowledge has one home. Consumers use the policy’s result instead of independently reconstructing the rule.
That is the central mental model: keep knowledge that changes for the same reason close together, and keep consumers dependent on an explicit result or contract.
Distinguish duplication of text from duplication of knowledge
Repeated text is easy to see, but it is not always the maintainability problem.
Consider two unrelated limits:
maxLoginAttempts = 5
maxSearchSuggestions = 5Both contain the number 5, but they represent different decisions. Combining them into a shared DEFAULT_LIMIT would make unrelated policies depend on each other. If one limit changes, the other should not have to change.
The opposite problem can be harder to notice. The same decision can appear in different forms:
eligible = subtotal >= 50
shipping = eligible ? 0 : standardRate
message = subtotal < 50 ? "Delivery charged" : "Free delivery"The lines are not textually identical, yet they encode the same threshold. A search for duplicated code may not identify the relationship.
Change locality is therefore about shared reasons to change, not superficial similarity. When several pieces of code must stay synchronized because one business or engineering decision changes, they are coupled even if their syntax looks different.
Find the source of the ripple
When a small requirement causes a wide edit, do not immediately add an abstraction. First identify what information is leaking across the design.
For each required edit, ask two questions:
- What decision does this line know?
- Does this location need to know that decision, or only its outcome?
Return to the delivery example. Checkout needs to know the shipping cost. A receipt needs enough information to describe the charge. Neither necessarily needs to know that the free-delivery threshold is 50.
That suggests a boundary:
quote = shippingPolicy.quote(order)
quote.cost
quote.description
quote.reasonThe policy owns the conditions that produce a quote. Other code consumes the quote.
This change reduces the number of places that know the policy, but it does not require putting all shipping-related code into one giant module. Calculation, persistence, presentation, and transport can remain separate when they have different responsibilities. The goal is to localize the decision, not to collapse the architecture.
Give each important decision an owner
A decision becomes easier to change when one module, type, function, or configuration boundary clearly owns it.
The appropriate owner depends on the decision. A pricing rule may belong in a pricing policy. A retry schedule may belong in a retry policy. A mapping between internal states and external API values may belong at the integration boundary.
The owner should expose what callers actually need.
For example, this interface leaks policy details:
freeDeliveryThreshold()
standardDeliveryRate()Callers can use those values to reproduce the policy themselves. An interface closer to the decision is:
quoteDelivery(order) -> DeliveryQuoteThe difference is important. The first interface distributes ingredients. The second asks the owner to make the decision.
This does not mean every value must be hidden behind a method. Stable data that genuinely belongs to consumers can remain data. Encapsulation is useful when it prevents multiple components from having to coordinate around knowledge that is expected to evolve.
Use change scenarios to test the design
You can evaluate change locality before the next real requirement arrives. Pick a plausible change and trace its effect.
Imagine these changes to the delivery policy:
- raise the free-delivery threshold;
- make the threshold depend on destination;
- add a temporary free-delivery promotion;
- explain on the receipt why delivery was free.
For each scenario, identify which components would need new knowledge.
If raising the threshold requires editing presentation code, the threshold has probably leaked into presentation. If adding a destination rule requires changing every caller, callers may be performing policy decisions themselves. If only the policy and its focused tests change, while callers continue consuming the same DeliveryQuote, the boundary is absorbing the variation well.
This technique is useful because it tests a design against likely pressure rather than an abstract ideal. You do not need to predict every future requirement. You only need enough plausible scenarios to see whether an important decision has a clear home.
Locality does not mean one-file changes
A common mistake is to treat any multi-file change as evidence of poor design.
Suppose a new order state must be visible in the domain model, serialized through an API, stored in a repository, displayed in an interface, and covered by tests. Those edits reflect different responsibilities. Forcing them into one module would not make the system more coherent.
The useful distinction is between distributed implementation and distributed knowledge.
Distributed implementation is normal when several layers participate in a feature. Distributed knowledge is risky when each layer independently knows the rule that determines behavior.
A change can therefore touch several files and still have good locality. The domain decision might change in one place while adapters and tests change only because their contracts or expected outputs legitimately changed.
Avoid replacing every ripple with indirection
Improving locality has a cost. New boundaries introduce names, interfaces, navigation, and sometimes runtime indirection. If a rule is tiny, stable, and used in one place, extracting a policy object may make the code harder to follow.
Prefer the simplest structure that gives an important decision a clear owner.
A local function may be enough:
function qualifiesForFreeDelivery(order):
return order.subtotal >= 50If the rule later gains destination exceptions, promotions, or several callers, the function can become a richer policy boundary. Starting with a small owner keeps the design proportional to the problem.
Also avoid abstractions that merely move duplication without centralizing the decision. A shared constant such as FREE_DELIVERY_THRESHOLD removes repeated literals, but callers can still reproduce policy logic around it. That may be sufficient when the threshold truly is the whole policy. It becomes insufficient when the decision has multiple conditions or consequences that must evolve together.
Watch for changes that require coordinated edits
Poor locality often becomes visible during ordinary maintenance. Several signals are especially useful:
- a developer must search the repository for every occurrence of a business value before changing it;
- a pull request contains the same conceptual condition edited in unrelated modules;
- forgetting one caller produces inconsistent behavior rather than a compile-time or test failure;
- a new policy case requires adding similar branches in several layers;
- code review repeatedly includes comments such as “this rule also exists over there.”
These signals do not prove that a refactor is needed. They tell you where to investigate ownership of knowledge.
When the pattern is real, a safe improvement is often incremental: choose one decision, establish one owner for it, move one caller at a time to consume the owner’s result, and remove the old duplicated knowledge after all callers have moved.
Preserve independent reasons to change
Locality can also be damaged by centralizing too much.
Imagine a BusinessRules module containing delivery policy, password rules, invoice numbering, refund eligibility, and notification preferences. Each rule is technically in one place, but unrelated decisions now share one module. The module becomes a coordination point for changes that have nothing to do with each other.
Good locality has two directions:
- knowledge that changes together should be close together;
- knowledge that changes independently should remain independently changeable.
This is why “put all rules in one place” is weaker guidance than “give each coherent decision a clear owner.” The second statement preserves both locality and separation.
Use change locality as a design feedback signal
Change locality is not a score that every codebase must maximize. Some requirements are cross-cutting by nature, and some duplication is cheaper than an abstraction. The value of the idea is diagnostic.
When one conceptual change repeatedly causes scattered, coordinated edits, ask what knowledge those locations share. If most of them only need the outcome, move the decision toward a clear owner and expose that outcome through a small boundary. If the locations represent genuinely independent responsibilities, keep them separate.
The practical takeaway is simple: design so developers can answer where does this decision live? without searching the whole system. When that answer is clear, changes are easier to reason about because fewer places must agree on the same hidden knowledge.