Skip to content
aipatterns.dev

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.

invoice.ts
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:

invoice.ts
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

See also

Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.