Code smellChange Preventers
Shotgun Surgery
One logical change forces small edits in many classes, so one missed edit ships an inconsistent system.
Signs
- A single requirement ("add a
CADcurrency", "rename thetrialplan") produces a diff that touches eight files with two lines each. - You find the places to change with a global text search, not by following the code.
- Bugs appear because one of the many copies was missed.
- The same small decision (a threshold, a format, a list of allowed values) is encoded in several classes.
- Several classes each hold a fragment of one concept, and none of them owns it.
Why it happens
Responsibility for one concept got split across layers, often by a rule like "controllers format, services validate, repositories map" applied mechanically. Each layer then encodes its own copy of the same knowledge. Copy-paste of a small snippet into several places has the same effect. Over-eager splitting to cure Divergent Change can also cause it.
Example
A SaaS app supports subscription plans. Adding a plan means editing the validator, the pricing code, the feature gate and the email template.
// validation.ts
export function isValidPlan(plan: string): boolean {
return plan === "free" || plan === "pro" || plan === "team";
}
// pricing.ts
export function monthlyPriceCents(plan: string): number {
if (plan === "pro") return 1200;
if (plan === "team") return 4900;
return 0;
}
// features.ts
export function maxProjects(plan: string): number {
return plan === "team" ? 100 : plan === "pro" ? 20 : 3;
}
// emails.ts
export function planLabel(plan: string): string {
return plan === "team" ? "Team" : plan === "pro" ? "Pro" : "Free";
}Adding "enterprise" needs four edits in four files. After moving the knowledge into one place:
export const PLANS = {
free: { label: "Free", priceCents: 0, maxProjects: 3 },
pro: { label: "Pro", priceCents: 1200, maxProjects: 20 },
team: { label: "Team", priceCents: 4900, maxProjects: 100 },
} as const;
export type PlanId = keyof typeof PLANS;
export function isValidPlan(plan: string): plan is PlanId {
return plan in PLANS;
}
// Callers read PLANS[plan].priceCents, PLANS[plan].maxProjects, PLANS[plan].label.A new plan is now one entry, and the type checker flags any caller that cannot handle it.
Treatment
- If behavior about one concept is spread across classes, use Move Method to gather it in the class that owns the concept's data.
- If the data itself is scattered, use Move Field first, then move the methods that use it.
- If gathering leaves behind classes that now do almost nothing, use Inline Class to fold them away.
- If the scattered pieces are copies of the same logic, treat it as Duplicate Code as well.
- Where the scattered code is a type switch, consider Replace Conditional with Polymorphism or a lookup table so a new case is one addition.
Payoff
- One change, one place. Reviews show the whole change at once.
- Missed-copy bugs disappear.
- The concept gets a name and a home that new contributors can find.
When to ignore
- The edits are spread across genuinely separate deployables (a client and a server, two services) where sharing code would couple release cycles. Use a shared schema or contract test instead.
- The repeated knowledge is trivial and stable, such as an HTTP status code.
- Generated code (protobuf, ORM models) produces the spread. Fix the generator input, not the output.
Agent checklist
- You searched for every place that encodes the concept, including tests, templates and config.
- The concept now has one owning module, and other code reads from it.
- Adding a new case requires one edit, and you verified that with a dry run or the type checker.
- You did not create a new cross-service dependency to centralize code.
- All tests pass after each move, not only at the end.
Treatments
- Move MethodMove a method to the class whose data it uses most, and leave a delegating call or update the callers.
- Move FieldMove a field to the class that uses it most, updating every reader and writer to reach it there.
- Inline ClassFold a class that does too little into the class that uses it, moving its fields and methods across and deleting it.
See also
- Divergent ChangeOne class gets edited for many unrelated reasons, so every kind of change lands in the same file and collides with every other kind.
- Duplicate CodeThe same logic appears in two or more places, so every fix and change must be made several times and the copies drift apart.
- Feature EnvyA method reads and computes on another object's data more than its own. Move the logic to where the data lives.
Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.