Code smellChange Preventers
Divergent Change
One class gets edited for many unrelated reasons, so every kind of change lands in the same file and collides with every other kind.
Signs
- The commit history of one file mixes unrelated topics: a tax rule change, a new export format, a database column rename.
- You can describe the class only with "and": it "validates invoices and renders PDFs and writes to Postgres".
- Different teams or people edit the same class for different reasons, and their changes conflict in review.
- Methods form clusters that never call each other and touch disjoint fields.
- Adding a new database requires changing the same methods you'd change to add a new currency.
Why it happens
A class starts with one job. Each later feature needs "somewhere to go", and the class that already holds the data is the closest home. Nobody decides to merge persistence, formatting and business rules; it accretes one reasonable-looking pull request at a time. Copy-paste of an existing method inside the class reinforces the cluster.
Divergent Change is the mirror of Shotgun Surgery: here one class absorbs many kinds of change; there one kind of change is spread across many classes.
Example
An Invoice class changes whenever finance updates tax rules, whenever the PDF layout changes, and whenever storage moves.
class Invoice {
constructor(
public id: string,
public lines: { sku: string; qty: number; unitCents: number }[],
public region: "EU" | "US",
) {}
// Reason to change #1: tax policy
taxCents(): number {
const net = this.netCents();
return this.region === "EU" ? Math.round(net * 0.21) : Math.round(net * 0.07);
}
netCents(): number {
return this.lines.reduce((sum, l) => sum + l.qty * l.unitCents, 0);
}
// Reason to change #2: document layout
toPdfLines(): string[] {
return [
`Invoice ${this.id}`,
...this.lines.map((l) => `${l.sku} x${l.qty} ${(l.unitCents / 100).toFixed(2)}`),
`Tax: ${(this.taxCents() / 100).toFixed(2)}`,
];
}
// Reason to change #3: storage
async save(db: { query(sql: string, args: unknown[]): Promise<void> }): Promise<void> {
await db.query("INSERT INTO invoices (id, region, net) VALUES ($1, $2, $3)", [
this.id,
this.region,
this.netCents(),
]);
}
}After Extract Class, each reason to change has its own home:
class Invoice {
constructor(
readonly id: string,
readonly lines: { sku: string; qty: number; unitCents: number }[],
readonly region: "EU" | "US",
) {}
netCents(): number {
return this.lines.reduce((sum, l) => sum + l.qty * l.unitCents, 0);
}
}
class TaxPolicy {
taxCents(inv: Invoice): number {
const rate = inv.region === "EU" ? 0.21 : 0.07;
return Math.round(inv.netCents() * rate);
}
}
class InvoicePdf {
constructor(private readonly tax: TaxPolicy) {}
lines(inv: Invoice): string[] { /* layout only */ return [`Invoice ${inv.id}`]; }
}
class InvoiceRepository {
async save(inv: Invoice): Promise<void> { /* storage only */ }
}Treatment
- If the class has clusters of methods and fields that change together, use Extract Class to move each cluster out.
- If one method sits in the wrong cluster, use Move Method to put it next to the data it uses.
- If several classes share the same divergent behavior, use Extract Superclass to give the shared part one home.
- If the divergence is a variant of the object (a special kind of invoice), use Extract Subclass.
- Split along the axes that actually change in your history, not along every axis you can imagine.
Payoff
- Each change touches one small class, so diffs are focused and reviews are fast.
- Merge conflicts between unrelated features drop.
- Tests for tax rules no longer need a database or a PDF renderer.
When to ignore
- The class is small and the "reasons to change" are each one short method. Splitting a 60-line class into four files adds navigation cost for no gain.
- The axes have not actually changed in practice. Wait for the second real change along an axis before extracting it.
- The code is a throwaway script or a prototype that will be rewritten.
Agent checklist
- You named each distinct reason the class changes, backed by commit history or the current task, not speculation.
- Each extracted class owns one reason to change and the data that reason needs.
- Callers were updated, and no method is left delegating back and forth between the old and new classes.
- Existing tests pass unchanged before you add new focused tests per class.
- You did not split along axes that have never changed.
Treatments
- Extract ClassSplit a class that does two jobs by moving one job's fields and methods into a new class it holds a reference to.
- Extract SuperclassCreate a shared superclass for two classes with overlapping fields and methods, and pull the common parts up into it.
- Extract SubclassMove features used only by some instances of a class into a new subclass, replacing the flag or nullable fields that marked them.
- Move MethodMove a method to the class whose data it uses most, and leave a delegating call or update the callers.
See also
- Shotgun SurgeryOne logical change forces small edits in many classes, so one missed edit ships an inconsistent system.
- Large ClassA class with too many fields, methods or responsibilities, so every change risks breaking something unrelated inside it.
- Extract ClassSplit a class that does two jobs by moving one job's fields and methods into a new class it holds a reference to.
Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.