Shotgun Surgery: When One Change Touches Many Files

A pricing rule changes from “free shipping above 50” to “free shipping above 75.” The rule is simple, but the pull request touches checkout, order previews, invoice generation, customer notifications, and tests in several modules. Miss one place and customers see contradictory behavior.

That pattern is called shotgun surgery: one logical change requires many small edits scattered through the codebase. The problem isn’t the number of files by itself. The problem is that one decision has many owners.

This article shows how to recognize shotgun surgery, find the decision that has been duplicated or leaked, and refactor so future changes have a smaller and more predictable impact.

Use change impact as the mental model

Code structure is often discussed in terms of classes, modules, or layers. For shotgun surgery, a more useful question is: when one business or technical decision changes, how far does that change travel?

Imagine a system with this shipping rule:

free shipping when order total >= 50

If five components independently contain total >= 50, the rule has five implementation sites. Changing the threshold means finding and updating all five. The components may look separate in the directory tree, but they are coupled by the same decision.

The maintenance risk follows directly:

one decision
    -> many edit sites
    -> more places to discover
    -> more chances for inconsistent edits

This is why shotgun surgery is a change-coupling problem rather than simply a duplication problem. Two identical lines aren’t necessarily harmful if they represent unrelated decisions that happen to share a value today. Two different-looking blocks can be harmful if both encode the same policy and must change together.

Start with the smallest useful example

Suppose checkout decides whether to charge shipping:

if order.total >= 50:
    shipping = 0
else:
    shipping = 8

The invoice code uses different syntax:

label = "Free shipping" if order.total >= 50 else "Shipping: 8"

The text isn’t duplicated, but the decision is. Both places know that 50 is the free-shipping threshold.

A first improvement is to give that decision one owner:

ShippingPolicy.isFree(order)

Then the callers become:

shipping = 0 if shippingPolicy.isFree(order) else 8

and:

label = "Free shipping" if shippingPolicy.isFree(order) else "Shipping: 8"

Now a threshold change can happen inside ShippingPolicy. The callers still decide what free shipping means for their own output, which is appropriate: checkout calculates a charge while the invoice chooses a label.

This example is intentionally small. Production code may need destination, weight, customer tier, promotions, or effective dates. Those details strengthen the case for one policy owner because the decision is more likely to evolve.

Find the decision, not just the repeated text

A search for the literal value 50 can help, but it isn’t enough. The same rule might appear as a constant in one file, a helper call in another, and a fixture expectation in a third.

When a change has spread widely, trace it in three steps.

First, state the change in domain language: “the condition for free shipping changed.” This prevents the investigation from becoming a search for one syntax pattern.

Second, list the places that need knowledge of that condition. Ask what each place is trying to decide, not merely which file it lives in.

Third, separate policy owners from policy consumers. A consumer may need the answer to “is this order eligible for free shipping?” It usually doesn’t need to know how eligibility is calculated.

That distinction points toward the refactoring boundary. Move the rule behind an operation that answers the consumer’s real question.

Refactor toward one authoritative decision

The goal isn’t to put every related line into one giant module. It is to make one component authoritative for one reason to change.

For the shipping example, the policy might expose:

ShippingPolicy.quote(order) -> ShippingQuote

with a result such as:

ShippingQuote {
    charge: 0
    isFree: true
    reason: "order-threshold"
}

A checkout can use charge, while an invoice can use isFree to choose presentation. If operational reporting needs to distinguish threshold-based free shipping from a promotion, it can use reason without reimplementing the eligibility rule.

The result object matters when consumers need several facts that must agree. Returning only a boolean and asking each caller to recalculate the charge would leave part of the decision scattered.

Keep the interface no broader than necessary, though. If every caller only needs a boolean, isFree(order) is simpler and communicates the contract better than a large result object.

Move behavior before moving files

A common response to shotgun surgery is to reorganize directories. That can improve navigation, but proximity doesn’t create ownership.

Five files in the same folder can still implement the same rule independently. Conversely, callers in different packages can share one authoritative policy through a stable interface.

Refactor the knowledge first: decide which component owns the rule and route consumers through it. Physical organization can follow if it makes that ownership easier to see.

Tests should reinforce the ownership boundary

Scattered production logic often produces scattered policy tests. One test verifies checkout at the threshold, another verifies an invoice label, and another verifies a notification. When the threshold changes, all of them may fail because each test encoded the old rule directly.

After centralizing the policy, test the policy’s boundary conditions where the policy lives:

threshold = 75

total 74.99 -> not free
total 75.00 -> free
total 80.00 -> free

Consumer tests should focus on what the consumer does with the policy result. For example, an invoice test can provide a free-shipping quote and verify that the invoice renders the correct label. It doesn’t need to retest the threshold calculation unless that integration itself is the behavior under test.

This division makes failures more informative. A threshold bug fails policy tests. A rendering bug fails invoice tests. An integration test can still cover the complete path, but it no longer needs to carry every edge case for every component.

Don’t centralize things that only look similar

The opposite mistake is false unification: forcing unrelated rules behind one abstraction because their current implementations happen to match.

Suppose free shipping starts at 75, and orders above 75 also require manager review for an unrelated accounting reason. Both rules contain the same number today:

shipping threshold = 75
review threshold   = 75

Replacing both with ORDER_THRESHOLD = 75 creates coupling that the domain doesn’t require. If accounting later changes its review threshold to 100, the shared constant becomes misleading.

The test for shared ownership is not “do these values match?” Ask instead: would a change to this decision logically require both places to change? If yes, centralization may reduce shotgun surgery. If no, keep the decisions independent even when their code looks similar.

Watch for abstractions that only relocate the scattering

A helper can hide duplication without solving ownership. Consider this API:

isAtLeast(order.total, FREE_SHIPPING_THRESHOLD)

Callers no longer write the comparison themselves, but they still know which threshold controls free shipping. The policy has merely been split between a generic helper, a constant, and its consumers.

Prefer an interface that names the domain decision:

shippingPolicy.isFree(order)

Another failure mode is a “god policy” module that accumulates unrelated rules. Centralizing ownership doesn’t mean centralizing the whole application. Shipping eligibility, refund approval, and invoice numbering can each have their own owner because they change for different reasons.

A third mistake is introducing a framework-sized abstraction for a rule that is stable, local, and used once. If a condition appears in one small function and changes with that function, extracting a policy object may add indirection without reducing change impact. The simpler code is then the better design.

Use change history as evidence

Shotgun surgery is easiest to see while making a real change. A pull request that repeatedly touches the same cluster of files for one reason is useful design evidence.

When you notice that pattern, ask:

  • What single decision explains these edits?
  • Which component should be authoritative for that decision?
  • What answer do the other components actually need from it?
  • Which tests belong with the decision, and which only test consumers?

Version history can strengthen the signal. If the same files repeatedly change together for the same reason, the current module boundaries may not match the system’s change boundaries. Co-change alone doesn’t prove a design problem; generated files, coordinated releases, or broad mechanical refactors can also produce it. Use history to guide investigation, then inspect the reason behind the edits.

When a wide change is reasonable

Not every change across many files is shotgun surgery. A deliberate API migration may require updating many callers once. Renaming a public concept can legitimately touch documentation, tests, and implementation. Cross-cutting requirements such as adding a trace identifier may also span layers because the information genuinely travels through them.

The warning sign is repeated dispersal of one decision, especially when correctness depends on every site staying synchronized.

If a wide edit is mechanical, compiler-assisted, and unlikely to repeat, centralizing it may offer little value. If the same policy keeps producing hand-edited changes across unrelated components, the cost is structural and worth addressing.

Make the next change smaller

When a simple requirement produces a surprisingly wide diff, don’t treat the diff only as work to finish. Treat it as information about the design.

Name the decision that changed, identify every place that knows how that decision is made, and choose one authoritative owner. Give consumers an interface that exposes the answer they need without leaking the rule itself. Then place the detailed boundary tests with that owner.

The practical target isn’t “one file per change.” Real systems have legitimate cross-cutting changes. The target is narrower: one engineering decision should have one clear source of truth, so changing that decision doesn’t require a scavenger hunt through the codebase.