Skip to content
intermediate Phase 2 · SOLID Principles and Clean Architecture

Code Smells as Architecture Signals

Read recurring code smells as evidence that module boundaries are drifting.

45m
0 problems
Topic Progress 0%

Shotgun Surgery: One Change, Eleven Files, None of Them Wrong Alone

The request is small: 'tax for digital goods should be zero-rated in Ireland.' A competent engineer opens the pricing code, edits three files, and submits. The reviewer asks why the notification templates changed. They did not — a second engineer, implementing the same tax rule in the invoicing service, edited those files an hour later, and the two changes will merge into a release neither of them tested together. This is shotgun surgery: where one change touches many files signals a misplaced responsibility — a single logical concept is smeared across the codebase, so implementing it means firing edits in every direction.

The smell is invisible in any single file; each module reads as reasonable in isolation. It only shows up in the relationship between a change request and the diff it produces. The diagnosis is therefore mechanical:

# For the last feature, which files did the commits touch?
git show --stat <feature-merge-commit>

# Which files are habitually edited together? (change coupling)
git log --since=6.months --name-only --format= | sort | uniq -c | sort -rn | head

# What is the blast radius of the pricing concept today?
grep -ril 'taxcategory\|digitalgoods' --include='*.java' | wc -l

Eleven files across four modules for one tax rule is the signature. The underlying design defect is usually one of three shapes, and the fix follows the shape. Duplicated concept: the notion 'tax category' exists as a field, a util method, a string constant set, and a validation regex, each copy drifting independently — the fix is a single TaxCategory value object owning its parsing and rules, replacing the copies over time. Missing aggregation: the logic is conceptually one operation but scattered across helpers — the fix is moving the methods next to the data they serve (move method / extract class from the refactoring catalog). Leaky layering: a domain concept lives partly in controllers, partly in services, partly in SQL — the fix is the boundary cleanup the clean architecture rings describe, with the concept expressed once at its proper depth.

Why architects care more about this smell than about naming nits: shotgun surgery is a scaling tax paid per change, compounding with team size. With two engineers, the collisions are occasional; with twelve, near-daily, and the cost hides inside 'estimates feel high' rather than appearing as any single incident. It also produces the most demoralizing failure mode in a codebase — two engineers implementing the same rule with slightly different readings of the scattered copies, discovering their divergence in production reconciliation.

The interview framing: interviewers often probe with a past-project question — 'what made that codebase hard to change?' The strong answer names the smell, shows you measured it rather than felt it, and connects the measurement to the structural fix and its effect on change lead time. The companion smell, where one class is the target of unrelated changes, is the mirror image and gets equal treatment in the next section — Divergent Change. A worked remediation for the tax example fixes the duplicated-concept shape. Introduce a TaxCategory value object that owns the parsing of the string constants, the validation regex, and the mapping to jurisdiction rules; then replace each scattered copy with uses of the object, one module per pull request. The grep blast radius drops from eleven files to the value object plus its callers, and the next zero-rating rule lands as one class plus its tests. The visible metric afterwards: the change for the next similar rule touches two files instead of eleven, and no cross-team coordination is needed to keep the copies consistent.

Divergent Change: One Class, Mutating for Three Unrelated Reasons

Shotgun surgery spreads one concept across many files. Divergent change compresses many concepts into one file, and its signature is in the commit history rather than the structure: the same class is edited for entirely different reasons, by different teams, in different months.

A concrete specimen — the CustomerReport class that grew three lives:

class CustomerReport {
  // edited by data team:   new column, new aggregation (Apr)
  // edited by finance:     VAT display rules (Jun)
  // edited by frontend:    JSON field renames for the new UI (Jul)
  // edited by data team:   another column (Aug)  <-- and it repeats
  String build(Customer c) { /* formatting + tax + serialization */ }
}

Divergent change, where one class mutates for different reasons, demands a split — and the split follows the change sources, exactly like the single responsibility principle defines them: data team's concern becomes a report-section component, finance's VAT presentation becomes its own formatter, the frontend's wire shape becomes a dedicated DTO or serializer. After the split, the April and August changes would each touch one small file, and the June and July changes would not collide with either.

Two techniques make the split tractable in legacy code, where big-bang rewrites are fantasy. First, identify the seams from the history before touching the code: group the last dozen commits by which methods they edited, and the clusters reveal the natural components — the commit themes name the collaborators the way they did in the SRP evidence exercise. Second, wrap and peel: add a façade that preserves the old class's API for existing callers, move one concern behind it into a new class, rerun the suite, and repeat per concern. Each iteration is small, reversible, and shippable, and the callers migrate lazily rather than in one coordinating change.

The quantitative case for acting, because this is the argument an architect must make to get the sprint funded: divergent change taxes every release that touches the class, and it forces regression scope wider than the change. Measure it before proposing — commits per month on the class, distinct teams in its authorship, and release incidents whose root cause named it. A class with 14 commits, 5 authors, and two incident postmortems in two quarters is not a style complaint; it is a measurable drag on delivery, and the split has a defensible payback period in reduced collision and regression cost.

One caution against the over-eager reading: frequent edits for the same reason are not divergent change — that is a hot module, and it usually wants performance attention or nothing at all. The smell is specifically multiple unrelated reasons. And the discipline that keeps you honest: after any split, the evidence should change. If six months later the same reason still edits three files, the split chose the wrong seams, and that review closes the loop between smell, fix, and measurement — the portfolio view of which teams' seams keep drifting is the final chapter's subject. The measurement that proves the split afterwards mirrors the diagnosis: commit-theme clustering on the new components should show each team editing only its own file, and the monthly edit count on the façade should fall to wiring concerns only. If the data team still opens the frontend DTO for column work two releases later, the seams were drawn along the wrong axis — perhaps the real split is by report (operations vs finance) rather than by mechanism (columns vs formatting) — and that observation goes into the next iteration rather than into a postmortem.

Arguing the Refactor With Data: Churn, Coupling, and Payback

An architect does not refactor code; an architect funds refactors across teams, which means the work must be argued with the language leadership already uses — frequency, cost, and payback. Architects use smell frequency across teams to decide when a structural refactor pays off, and the data lives in the version control system.

The three metrics that carry the argument:

Metric Query shape What a high value means
Churn (edits per period) commits per file, per quarter change pressure concentrated on few files
Change coupling files repeatedly committed together across authors hidden shared responsibility or duplicated concept
Conflict frequency merge conflicts per file in the tracker two workstreams colliding on one artifact

Build the shortlist from churn (top 10% of files by edits), then overlay coupling: a high-churn file whose companions are unrelated modules is a shotgun-surgery hub; a high-churn file edited by many teams for different themes is divergent change. Then attach the cost story in units leadership already tracks — lead time and incidents. The argument reads: 'eight of our top-ten churn files live in the pricing cluster; the last two release delays both involved conflict resolution there; the split proposal reduces the cluster's edit surface from one 3,000-line module to four components with single owners; we estimate the payback at two release cycles.' That sentence has numbers, a mechanism, and a horizon — it outperforms any appeal to craftsmanship, and it survives the inevitable 'can't we just add tests instead?' (tests reduce regression risk but do not reduce the collision cost, which is the dominant term here).

Match the intervention to the evidence tier, because not every smell deserves a sprint:

Tier 1 (fund now):    measurable delivery drag — repeated release delays,
                      incidents traceable to the cluster, cross-team conflicts
Tier 2 (ticket + watch): rising churn, no incidents yet — track the metric;
                      refactor when the trigger fires
Tier 3 (leave):       low churn regardless of structure — elegance without
                      a payer; do not spend a release cycle here

Tier discipline prevents the classic failure: refactoring the code that annoys you rather than the code that costs the organization. A beautifully refactored low-traffic module is indulgence; a coarse, ugly module that four teams queue behind may be the correct first target.

Run the loop post-refactor, because the evidence is the grade: churn concentration should flatten (the top file drops out of the top ten), conflict counts on the cluster should fall, and incident citations should stop naming it. If the metrics do not move within a release or two, the split chose wrong seams — record that honestly, because it calibrates the next proposal. With the smell vocabulary and its economics in hand, the next phase moves from reading structure to shaping it deliberately: Creational Patterns and Factories begins that work where object creation is the seam. One scheduling note: the best window for a Tier 1 refactor is immediately after a release, when the collision cost is freshest in everyone's memory and the next cycle has not committed its scope. Bringing the proposal the week after a conflict-driven delay converts an abstract improvement into a specific answer to a specific pain, and the sprint planning conversation changes from whether to spend the capacity to when. Architects who keep the churn dashboard handy can make that move within a day of the triggering event, which is a practical reason the metrics habit is worth the ten minutes a week it costs.

Quiz

1. A feature 'zero-rate digital goods tax' required edits in 11 files across 4 modules, and a concurrent change by another engineer touched 6 of the same files. What is the diagnosis?

Question 1 options

2. `CustomerReport` is edited by the data team (columns), finance (VAT display), and frontend (JSON field names) — 14 commits, 5 authors, two quarters. What is the correct structural response?

Question 2 options

3. A file is edited 20 times per quarter, always by the same team and always for the same feature area. Does this justify a structural refactor?

Question 3 options

4. How is a cross-team refactor case best argued to leadership?

Question 4 options

Flashcards

Question

Define shotgun surgery, its detection method, and its three underlying shapes.

Answer

One logical change requires edits across many files. Detect mechanically: git show --stat on the feature commit, files habitually co-committed, and grep blast radius of the concept. Shapes: duplicated concept (same notion as field + util + constants + regex — fix with one value object), missing aggregation (move method to the data), leaky layering (concept split across controller/service/SQL — boundary cleanup).

Question

Define divergent change and contrast it with a merely hot module.

Answer

One class is edited for multiple unrelated reasons — commit themes cluster by different teams (data columns, VAT display, JSON renames) — so the split follows the change sources via wrap-and-peel. A hot module is edited frequently for the same reason by one team; that is cohesion under load, not the smell, and usually needs no structural change.

Question

How does wrap-and-peel make a legacy split tractable, and how do you choose the seams?

Answer

Add a façade preserving the old API, move one concern into a new class behind it, rerun the suite, repeat — each iteration shippable and callers migrating lazily instead of a big-bang rewrite. Seams come from history before touching code: group the last dozen commits by edited methods, and the clusters reveal the natural components — commit themes name the collaborators.

Question

What three repository metrics fund a refactor argument, and what does each indicate?

Answer

Churn (commits per file per quarter — change pressure concentrated in few files), change coupling (files repeatedly committed together across authors — shared or duplicated responsibility), and conflict frequency (merge conflicts per file — two workstreams colliding). Overlaying them produces the shortlist: high-churn + cross-team + conflicting = fund the split.

Question

State the three funding tiers for smell-driven refactoring and the discipline each enforces.

Answer

Tier 1, fund now: measurable delivery drag — repeated release delays, incidents traceable to the cluster, cross-team conflicts. Tier 2, ticket and watch: rising churn without incidents; refactor when the trigger fires. Tier 3, leave alone: low churn regardless of structure — no payer, no sprint. The tiers stop you from refactoring what annoys you instead of what costs the organization.

Revision Notes

Key Takeaways

  • 1. Smells are measured in relationships between changes and diffs, not in single files — each module can read fine in isolation while the change path is miserable
  • 2. Shotgun surgery and divergent change are mirror images: one concept many files, or many reasons one file; both diagnoses come from commit-theme and authorship data
  • 3. Splits follow the evidence: commit clusters name the seams, and wrap-and-peel keeps every iteration shippable with callers migrating lazily
  • 4. Refactor funding uses leadership's language — churn, coupling, conflict frequency, priced against lead time and incidents, with a payback horizon in release cycles
  • 5. The loop closes after shipping: churn concentration flattens and conflicts fall, or the seams were wrong and the next proposal is calibrated by that admission

Interview Tips

  • • 'What made a codebase hard to change?' — name the smell, the measurement (11 files for one rule, 5 authors on one class), the structural fix, and the before/after effect on lead time
  • • 'How do you decide what to refactor first?' — churn top-decile overlay change coupling, then tier the funding: delivery drag now, rising churn on a watch list, low churn left alone
  • • 'Refactor or rewrite?' — wrap-and-peel evidence: each concern moved behind a façade with the suite green per iteration; reserve rewrite language for when seams cannot be identified at all
  • • 'How do you know the refactor worked?' — the metrics: top-file churn leaves the top ten, conflict counts on the cluster fall, postmortems stop citing it; if not moved in two releases, the seams were wrong

Cheat Sheet

Code Smells as Architecture Signals

Two directional smells:

Smell Signature Evidence Fix
Shotgun surgery one change, many files git show --stat, grep blast radius dedupe concept into value object; move method; un-leak layer
Divergent change many reasons, one file commit-theme clusters, author spread split by change source, wrap-and-peel

Detection commands:

git show --stat <merge>                      # blast radius of one change
git log --name-only --format= | sort | uniq -c | sort -rn  # co-commit coupling
git shortlog -sn -- path/File.java           # author spread per file

Wrap-and-peel: façade keeps old API → move one concern per iteration → rerun suite → callers migrate lazily. Seams from commit-theme clusters, not from intuition.

Funding case (three metrics + tiers):

  • Metrics: churn · change coupling · conflict frequency; price with lead time and incidents
  • Tier 1 fund now (delivery drag), Tier 2 watch (rising churn), Tier 3 leave (no payer)

Post-refactor grading: top-file churn flattens, conflicts fall, incident citations stop. No movement in 1–2 releases = wrong seams, record it honestly.

Not a smell: same-team, same-theme high churn = hot cohesive module.

Continue Learning