Shotgun Surgery
One requirement, seven modules, none of which is about that requirement. The code is not badly written — the knowledge has no owner, so every consumer had to learn it.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
Why does a one-sentence business change reliably touch six unrelated packages, none of which is named after it?
Sellers may now be based in a second country. "Just add the country" turns into edits in checkout, invoicing, tax, the CSV export, the seller dashboard, the search indexer and two scheduled jobs.
Each module needs the country for its own purpose, so each one reads it and applies the rule it needs. That keeps the modules independent — nobody has to depend on anybody else.
They are not independent; they are coupled through a rule nobody wrote down. Independence in the import graph and independence under change are different properties, and only the second one is worth anything.
- They are not independent; they are coupled through a rule nobody wrote down. Independence in the import graph and independence under change are different properties, and only the second one is worth anything.
- The rule was copied when there was one country, when it was three lines and obviously correct. Copying was cheap precisely because the knowledge was small — the cost arrives when it grows.
- Nothing tells you the list of seven is complete. Discovery is by grep, memory, and finding the eighth one in production a fortnight later.
- Because the modules deploy separately, there is a window where invoicing knows about the second country and tax does not. The invariant is violated by the deployment order, which no module can see.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The seven modules belong to four teams with different release trains, so the change cannot ship atomically.
- One of the consumers is a nightly job whose failure is discovered the next morning, not in CI.
- The export format is consumed by an external partner and cannot change without notice.
- Every place that computes tax must use the same rule for the same order, or the invoice and the charge disagree and finance reconciles it by hand.
- The seven sites must agree at every moment, including during the deploy window, because orders keep arriving.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- One module must own "what tax applies to this order", and own it as behaviour rather than as data other modules interpret.
- The seven consumers are responsible for *asking*. None of them is responsible for knowing how the answer is produced (Dependency Direction).
- Whoever owns the rule also owns its compatibility: adding a country must not require the seven consumers to redeploy in a particular order.
- The seam goes around the rule, not around any of the seven technical concerns. This is the case where drawing boundaries by layer or by team actively prevents the right answer (Package by Feature).
- The boundary must be wide enough to answer the question completely. A module that returns a tax *rate* leaves rounding, exemption and category logic outside it, and shotgun surgery comes straight back for the next requirement.
- During migration the boundary is temporarily doubled — old inline code and new module — and the rule for which one wins must be explicit rather than positional (Expand and Contract).
What you see, and when it is fine
The observable is version history, not code shape. Nothing about any one of the seven modules looks wrong; the pattern only exists across commits, which is why code review — which sees one diff at a time — is structurally bad at catching this.
- The evidence lives in
git log, not in the file. Look at the last three changes of this kind and count the modules each touched. - A recurring wide change is the finding; a one-off wide change is usually just work (What Refactoring Actually Is).
- If the sites disagree today, you have already had the bug and someone fixed it in one place only.
looks like A commit implementing one sentence of business change, touching modules whose names have nothing to do with that sentence. Reviewers from four teams. A pull request description that has to explain the same rule several times because each module states it differently.
suggests A concept in the business has no corresponding owner in the code, so every module that needed it learned it. The number of edits per rule change now grows with the number of consumers of the concept.
fix Follow the requirement, list every site, separate the sites that share knowledge from the ones that merely look alike, give the shared set an owner with a domain-level interface, and migrate consumers behind it one at a time.
The shape of it
Drawn out, the before-picture is a requirement with seven arrows leaving it and no node in the middle. The after-picture has one node in the middle and seven arrows into it — the same dependency, now declared, testable and versionable instead of implied.
The important part of the second picture is what it does *not* change: the consumers are still coupled to tax, and always were. Extraction did not remove coupling, it moved it from invisible-and-duplicated to visible-and-single, which is the trade this whole domain keeps making.
1// Too narrow: consumers still own rounding, exemptions, categories.2interface Tax { rateFor(country: string): number }3 4// Wide enough: the whole question has one answer.5interface Tax {6 applicableTo(order: Order): TaxBreakdown // components, rounding,7} // exemptions, all insideThe first version will produce shotgun surgery again on the next requirement, because the parts of the rule that changed were never inside the boundary. Interface width is the design decision here; the extraction itself is the easy part (Designing a Module Interface).
Converging seven sites without a big-bang release
The migration matters more than the design, because the dangerous state is the one in the middle. The loop below keeps both implementations live and comparable until the last consumer moves, which is slower than a cut-over and is the only version that is safe when four teams deploy independently.
- 1Trace one requirement
Take the last change of this kind and list every file it touched, from version history rather than from memory.
fails by Missing the consumers that were added since — usually a job or a report, and usually the one that fails silently.
- 2Separate same-knowledge from look-alike
For each site ask whether it must change when the rule changes. Only the "yes" set is being merged.
fails by Merging a site that looked identical but answers to a different owner, creating a coupling nobody declared (Duplicate Knowledge).
- 3Build the owner beside the old code
Implement the rule once, with its own tests, and do not call it from anywhere yet.
fails by Designing the interface around the first consumer, so it fits one caller and leaks that caller's shape to the rest.
- 4Shadow one consumer
Call both, compare, log disagreements, keep using the old answer.
fails by Comparing only the happy path, so the disagreements that matter — refunds, zero-value orders, missing country — never appear.
- 5Cut consumers over one at a time
Switch each site to the new answer once its shadow run is clean, deleting the local copy in the same commit.
fails by Leaving the local copy behind "for now", which is how a two-implementation system becomes permanent (Deliberate Debt).
- 6Remove the shadow and the flag
Delete the comparison code and the switch once the last consumer has moved.
fails by Never doing it, so the next reader finds two code paths and cannot tell which is authoritative (Feature Flags and What They Cost).
Every step is reversible except the last, which is the property that makes this safe to do across four release trains.
How to build it
Most important first.
- Follow one requirement through the system and write down every site it touches, before proposing anything. The list is the evidence and it takes an hour (Change Amplification).
- Ask at each site whether it encodes the same knowledge or merely looks similar. Sites that must change together are the real set; the others are a different rule that happens to resemble this one (Duplicate Knowledge).
- Give the real set one owner with an interface in domain terms —
taxFor(order), notgetRate(countryCode)— so that future rule shape changes stay inside (Designing a Module Interface). - Move consumers one at a time behind the new call, keeping both paths alive and asserting they agree, then delete the old one (Incremental Migration).
- Make the ordering problem go away rather than managing it: consumers should tolerate a rule they do not yet know about, so deploy order stops being part of the design (Backward Compatibility as a Constraint).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Before: adding a country costs seven edits across four teams, plus a discovery phase to find them, plus a coordinated release, plus the cost of the site you missed. Discovery does not shrink with practice, because each new consumer added since last time is invisible.
- Before, compounding: every new consumer of tax adds an eighth, ninth, tenth site. The cost of the next rule change grows with the *popularity* of the concept, which is exactly backwards.
- After: adding a country costs one edit, one test file, and one deploy of the owning module. Consumers do not change at all, so the cost stops tracking how many of them there are.
- What stays expensive: a change to the *shape* of the answer — tax becoming a list of line-item components rather than a total — still touches every consumer, because that is an interface change and no boundary can absorb it.
- Centralising the rule creates a coordination point and a queue. Seven teams now wait on one module's review and its test suite.
- The consumers lose the ability to special-case locally without asking. That is the point, and it is a genuine loss of autonomy that teams feel immediately and the benefit of which arrives later.
- The migration period is the most dangerous state the system will be in: two implementations, both live, and a correctness argument that depends on them agreeing.
What can go wrong
- The extraction covers six of the seven sites and the seventh is a scheduled job, so the disagreement appears only in the nightly run and is attributed to data rather than to code.
- The new module's interface is too narrow, so consumers keep a little of the rule locally — "just the rounding" — and the smell survives in miniature.
- The rule is centralised but parameterised per consumer to avoid changing behaviour, and the module becomes a switch statement over its own callers (Speculative Generality).
- The mitigation fails: an interface designed for the anticipated shape of the rule is itself a bet, and if the next requirement changes the shape rather than the values, the boundary has to move anyway.
- Seven consumers now depend on one rule module. That is high fan-in, and it is the intended outcome: the dependency was already there, undeclared (Fan-in and Fan-out).
- The rule module must depend on nothing volatile, or its seven dependents inherit those dependencies transitively at test time.
- A cross-team dependency now exists where there was only a convention, which means it needs a stability promise it did not need before (API Stability).
- "Any change touching several modules is shotgun surgery." No. A change that touches the modules it is *about* is normal. The finding is that the modules are unrelated to the requirement (Divergent Change).
- "So put everything in one module." The dual smell is a module that changes for many unrelated reasons, and over-correcting produces it. These two smells are each other's failure mode.
- "This means our services are wrong." It means a piece of knowledge has no owner. Splitting differently at the service level without giving the rule an owner reproduces the problem over a network, where it is worse (Microservices).
- "A shared constants file fixes it." Sharing the *values* while each consumer keeps its own logic leaves the knowledge distributed and adds a dependency that hides it (The Common Module).
- shotgun-surgery
- duplicate-knowledge
- utility-dumping-ground
Testing it, and how it ages
- A characterization test per consumer, capturing what it computes today, so the migration can assert the new module agrees site by site (Characterization Tests).
- Test the rule module directly and exhaustively, since it is now the single place that has to be right.
- Add one test that is really about the deploy: a consumer running against a rule module that returns a country it has never heard of must behave sanely rather than throwing (Version Coexistence: N and N+1, in Both Directions).
- Shotgun surgery is created by growth in consumers, not by growth in rules. It appears at the moment the concept becomes popular, which is also the moment it looks most successful.
- Once the rule has an owner, the next pressure is the opposite one: the owner starts absorbing near-neighbour concerns because it is convenient, and drifts towards a god object (God Object).
- The design ends when the rule genuinely differs per consumer — marketplace tax and subscription tax really are different rules — at which point one owner is wrong and it should split by domain rather than by consumer.
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThe mechanism — a piece of knowledge with no owner is learned by every consumer — is independent of language and paradigm; what differs is the unit that does the learning, whether that is a class, a module, a package or a service.
- SCALE-SPECIFICAt one team and one deployable, shotgun surgery is annoying and atomic: seven edits, one commit, one deploy. Across four teams and four release trains it becomes a correctness problem, because the sites are inconsistent during the rollout window and nobody owns that window.
- CONTESTEDA serious counter-position, common in service-oriented organisations, holds that each consumer *should* own its own interpretation, because a shared rule module couples independently deployable teams and turns every rule change into a cross-team release. That argument is strong when the consumers genuinely need to diverge and weak when they must agree — and the giveaway is whether an inconsistency between two consumers would be a bug or a feature.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — System Design — when the seven consumers are separate services, the same finding becomes a distributed consistency problem and the fix is a published contract rather than a shared module.