Shotgun Surgery: Reduce Scattered Change

A small requirement can produce a surprisingly large patch. Adding one order state means editing validation, formatting, notification, audit, and reporting code in different modules. Each edit may be simple, yet missing one can leave the system inconsistent.

This recurring shape is often called shotgun surgery: one conceptual change forces small edits across many places. The practical problem isn’t the number of files by itself. It is that a single responsibility has been scattered, so developers must reconstruct the full change surface each time that responsibility evolves.

This article shows how to spot that pattern, identify the concept that is actually changing, and reduce scattered change without creating an oversized abstraction.

Think in terms of change surfaces

A useful mental model is the change surface of a concept: the set of places that normally need editing when that concept changes.

Suppose an order can be pending, paid, or cancelled. Several parts of an application interpret those values independently:

validator:   paid orders cannot be edited
formatter:   paid -> "Payment complete"
notifier:    paid -> send receipt
audit:       paid -> record payment event

Now the product adds refunded. The developer must discover every place that interprets order state and decide what refunded means there.

Some spread is legitimate. Formatting and notification are different responsibilities, and both may need a response to a new state. The smell appears when many places repeat knowledge that belongs to one concept, such as valid states, transition rules, or the meaning of a particular state.

The key diagnostic question is not “How many files changed?” It is:

How many places had to change because they independently knew the same rule?

That distinction prevents a useful refactoring heuristic from becoming a file-count rule.

The smallest useful example

Consider a subscription system that stores plans as strings. Several services know which plans receive premium support:

function supportQueue(plan):
    if plan == "pro" or plan == "enterprise":
        return "priority"
    return "standard"

function responseTarget(plan):
    if plan == "pro" or plan == "enterprise":
        return 2
    return 24

Later, the business adds a business plan with premium support. Both functions need edits. A third copy in reporting or account setup would add another edit.

The duplicated condition is the useful signal. The rule “this plan receives premium support” has no clear owner.

One small refactoring gives that rule a home:

class Plan:
    hasPremiumSupport():
        return name in ["pro", "business", "enterprise"]

Callers can now express their own responsibility without repeating plan classification:

function supportQueue(plan):
    if plan.hasPremiumSupport():
        return "priority"
    return "standard"

function responseTarget(plan):
    if plan.hasPremiumSupport():
        return 2
    return 24

Adding another premium-support plan still may affect several parts of the product, but the classification rule changes in one place. The refactoring has reduced the change surface of that rule without merging unrelated responsibilities.

This example is intentionally small. Production code may represent plans through enums, value objects, configuration, or another mechanism. The engineering principle is independent of that choice: knowledge that changes for the same reason should have an explicit owner when doing so improves the design.

Find the concept before moving code

Shotgun surgery is easy to misread as a folder problem. Moving several functions into one file can make a patch look smaller while leaving the same knowledge scattered inside that file.

Start by naming the concept behind the edits.

Imagine a feature request that changes a trial period from 14 days to 21 days. The patch touches:

signup eligibility
trial expiration calculation
upgrade reminder scheduling
account status display

These edits don’t all belong in one module. The display code should still format account status, and reminder code should still schedule messages. But if each place embeds the number 14, the shared concept is the trial duration policy.

A better shape might expose that policy explicitly:

trialPolicy.durationDays()

The expiration calculation can use it to compute a date. The reminder scheduler can derive its own timing from it. The display layer can receive already-computed status information rather than reproducing policy rules.

The goal isn’t to put every affected behavior behind one object. It is to centralize the knowledge that actually changes together.

Separate shared policy from distinct consequences

A common refactoring mistake is to notice scattered change and build a large manager object that owns every related operation. That can replace shotgun surgery with a different problem: unrelated responsibilities become coupled through one central class.

Consider order cancellation. A cancellation may involve several consequences:

change order status
release reserved inventory
request payment reversal
send customer notification
record audit information

These actions can legitimately live in different components. They use different dependencies and may fail in different ways. Putting all implementation details into OrderManager merely to reduce file count doesn’t improve cohesion.

What should be centralized is the part that represents one policy or decision. For example, an order object or cancellation policy may decide whether cancellation is permitted:

cancellationPolicy.canCancel(order)

An application service can then coordinate the distinct consequences. The rule has one owner, while operational work stays with components suited to it.

This distinction is central to a good fix:

centralize shared knowledge
keep distinct responsibilities separate

Reducing scattered change is about responsibility boundaries, not physical proximity.

Use change history as evidence

Static code can suggest shotgun surgery, but repeated maintenance work provides stronger evidence. If the same group of rules repeatedly changes together, the design may be missing a concept that would make those changes local.

During a code review, look beyond the current patch. A change that touches six files isn’t automatically a problem. Ask what caused each edit.

For example, a new payment method might naturally require:

  • a display label in a user interface;
  • a routing choice in payment orchestration;
  • a reporting classification;
  • test fixtures for supported methods.

Those are distinct concerns responding to one feature. Centralizing all of them could make the architecture less clear.

By contrast, if four modules each contain their own list of supported payment methods, the repeated list is shared knowledge. A single source for capability or classification may reduce future omissions.

Change history also helps expose patterns that are hard to see in isolation. If every adjustment to retry policy touches the same scheduler, worker, metrics label, and configuration parser because each independently interprets retry counts, the system may benefit from a retry policy abstraction. If those files merely consume a policy through stable interfaces, the spread may be expected orchestration rather than a smell.

Refactor in small steps

A broad change surface tempts developers to perform a broad redesign. That increases the amount of behavior that can accidentally change at once. A safer sequence is usually incremental.

First, identify one duplicated rule with clear semantics. Give it a name before changing architecture. A named function such as isEligibleForExpressHandling(order) can be enough to establish the concept.

Next, route existing callers through that single definition. Keep caller behavior unchanged. At this stage, tests should still describe the same outcomes as before.

Then choose an owner based on responsibility. The helper may belong on a domain object, in a policy object, or behind a module interface. Placement should follow the data and rules it needs, not a desire to minimize files.

Finally, remove obsolete copies. Leaving old conditions beside the new abstraction preserves the original risk because future edits may still target the wrong copy.

A compact sequence looks like this:

spot repeated rule
        |
        v
name the concept
        |
        v
create one definition
        |
        v
route callers through it
        |
        v
remove duplicate knowledge

Each step can be reviewed for behavior preservation. If the refactoring also changes product rules, separate those changes when practical so reviewers can distinguish structural edits from semantic ones.

Watch for hidden forms of scattered knowledge

Shotgun surgery isn’t limited to repeated if statements. The same concept can be encoded in different forms.

One module may use a string list, another a switch statement, and another a numeric threshold. They can still represent the same rule.

Suppose free shipping starts at an order value of 50. The checkout service checks subtotal >= 50, the banner says “Free shipping from 50”, and analytics labels orders above 50 as shipping-qualified. The text and code look different, but all three depend on one threshold.

A policy value can make that dependency explicit:

shippingPolicy.freeShippingThreshold()

That does not mean every consumer should display or interpret the raw value. A checkout rule may ask qualifiesForFreeShipping(order), while a presentation layer may receive a formatted message from an appropriate boundary. The right interface depends on which knowledge should be shared and which behavior belongs to the caller.

Configuration can also scatter knowledge. Moving a constant from source code into configuration creates one stored value, but consumers may still duplicate assumptions about units, valid ranges, defaults, or fallback behavior. Central storage and centralized semantics are related but not identical.

Common fixes that miss the real problem

Moving code without changing ownership

Putting several helpers in a utils module can reduce navigation but often leaves responsibility unclear. If the helpers represent a domain concept, name and place that concept directly instead of creating a generic container.

Creating one global source for every value

A constants file can remove literal duplication, but it can also become a catalogue of unrelated facts. Prefer ownership that communicates context. RetryPolicy.maxAttempts says more than Constants.MAX_ATTEMPTS because it identifies the rule the value belongs to.

Generalizing after a single coincidence

Two conditions can look similar today and evolve independently tomorrow. Extracting a shared abstraction too early couples their future changes. Before centralizing, check that the code represents the same concept, not merely the same current value or syntax.

Measuring success by files changed

A healthy feature can cross several architectural boundaries. A request handler, application service, domain component, persistence adapter, and test may all need legitimate edits. The useful target is duplicated knowledge, not a one-file patch.

Hiding orchestration behind an oversized object

Some workflows inherently coordinate several components. Forcing every consequence into one class can obscure failure handling and dependency boundaries. Keep coordination explicit when the sequence itself is a responsibility.

Trade-offs of a smaller change surface

Centralizing a rule creates a dependency on its owner. That is often useful because callers now agree on one definition, but the dependency still has costs.

A shared policy module can become widely referenced. Changing its interface may affect many callers even if changing the underlying rule becomes easier. This is a reasonable trade when the shared concept is stable enough to deserve an explicit boundary.

Indirection also has a reading cost. Replacing a simple local comparison with several layers of delegation can make code harder to follow. If a rule appears once and has no sign of reuse or independent meaning, leaving it local can be clearer.

There is also a deployment boundary to consider. In a distributed system, two independently deployed services may both need knowledge of a business concept. Sharing a source-code library isn’t automatically the right answer; it can create release coupling. Depending on the system, the rule may belong behind an API, in an event contract, in duplicated but intentionally independent logic, or in another explicit boundary. The right choice depends on ownership, consistency needs, failure behavior, and deployment constraints.

The aim is not perfect centralization. It is a design where the cost of changing a concept matches the places that genuinely own its consequences.

When shotgun surgery is a useful signal

Treat shotgun surgery as evidence, not a verdict. It deserves attention when small conceptual changes repeatedly require edits across unrelated places, especially when those edits duplicate rules, classifications, thresholds, or transition logic.

It is less concerning when a feature intentionally crosses distinct responsibilities and each location contributes unique behavior. A new user-facing capability can reasonably affect domain logic, presentation, persistence, observability, and tests without indicating poor design.

A practical review test is to inspect each edit and ask what knowledge caused it. If several edits answer with the same rule, look for a clearer owner. If each edit reflects a different responsibility, the spread may be an accurate picture of the feature rather than a design flaw.

Make the next change easier to locate

The strongest benefit of reducing shotgun surgery is predictability. A developer facing the next policy change should be able to identify the owner of that policy without searching the entire codebase for every representation of it.

When a patch spreads widely, don’t begin by compressing files or inventing a broad abstraction. Name the concept that changed. Separate shared knowledge from distinct consequences. Centralize only the rule that truly belongs together, then route callers through that owner.

That keeps the refactoring proportional to the problem and gives future changes a smaller, clearer place to start.