Skip to content
aipatterns.dev

Code smellDispensables

Duplicate Code

The same logic appears in two or more places, so every fix and change must be made several times and the copies drift apart.

Also known as
Clones, DRY violation

Signs

  • Two functions are identical except for a name, a constant or a field.
  • A bug fix in one place needs to be repeated in another, and sometimes was not.
  • Sibling subclasses contain the same method body.
  • Two methods compute the same result by different algorithms.
  • Both branches of an if end with the same lines.
  • Two components render near-identical markup, and only the shared data was deduplicated.

Why it happens

Copying working code is the fastest way to ship a similar feature. Different people solve the same problem without knowing the other solution exists. Deadlines discourage the extra hour it takes to find a shared shape. And sometimes code looks the same by coincidence today and is supposed to diverge tomorrow; that is not duplication.

Example

Two report exporters in an analytics service share the same date filtering and totaling logic.

exports.ts
interface Sale { at: Date; region: string; cents: number }

function exportCsv(sales: Sale[], from: Date, to: Date): string {
  const inRange = sales.filter((s) => s.at >= from && s.at < to);
  const total = inRange.reduce((sum, s) => sum + s.cents, 0);
  const rows = inRange.map((s) => `${s.at.toISOString()},${s.region},${s.cents}`);
  return ["at,region,cents", ...rows, `total,,${total}`].join("\n");
}

function exportJson(sales: Sale[], from: Date, to: Date): string {
  const inRange = sales.filter((s) => s.at >= from && s.at <= to); // drifted: <= vs <
  const total = inRange.reduce((sum, s) => sum + s.cents, 0);
  return JSON.stringify({ rows: inRange, total });
}

The copies already disagree on the end boundary. After Extract Method:

exports.ts
interface Sale { at: Date; region: string; cents: number }

function salesInRange(sales: Sale[], from: Date, to: Date) {
  const rows = sales.filter((s) => s.at >= from && s.at < to); // half-open, one rule
  const total = rows.reduce((sum, s) => sum + s.cents, 0);
  return { rows, total };
}

function exportCsv(sales: Sale[], from: Date, to: Date): string {
  const { rows, total } = salesInRange(sales, from, to);
  const lines = rows.map((s) => `${s.at.toISOString()},${s.region},${s.cents}`);
  return ["at,region,cents", ...lines, `total,,${total}`].join("\n");
}

function exportJson(sales: Sale[], from: Date, to: Date): string {
  return JSON.stringify(salesInRange(sales, from, to));
}

Decide which boundary is correct before merging. Merging silently changes behavior for one caller.

Treatment

Payoff

  • One place to fix bugs and change rules.
  • Less code to read, test and maintain.
  • Hidden drift between copies surfaces and gets decided once.

When to ignore

  • The code looks alike but represents different concepts that will change for different reasons. Merging couples them; wait for a third copy to confirm the pattern.
  • The copies are in separate services or packages, and sharing would add a dependency that costs more than the duplication.
  • Test code, where explicit repetition often reads better than a clever shared helper.
  • Two or three trivial lines, where the helper would be harder to read than the copy.

Agent checklist

  • You compared every copy line by line and listed the differences before merging.
  • Each difference was either confirmed as a bug and fixed, or kept as a parameter.
  • The copies change for the same reason; otherwise you left them separate.
  • You checked for markup duplication, not only logic duplication.
  • You did not add a cross-package dependency to remove a few lines.
  • Tests for every former caller pass, including a case on each boundary that differed.

Treatments

See also

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