ConstructiCat Logo
CodeBust.
Browse section ▾

Duplication Instead of Extraction.

When writing new code, AI assistants tend to paste or re-derive a slightly tweaked copy of logic that already exists instead of calling or extracting a shared function, so duplicated code grows with every feature.

##Signs and Symptoms

A reviewer recognizes this smell when an AI-authored diff adds a block that already exists somewhere in the repo, lightly renamed, instead of calling the function that's already there. The tell-tale shape is two or more near-identical bodies that differ only in a noun (user/order), an endpoint, or a constant — the kind of copy that a human would have factored out.

Common signals:

  • New handlers/services that repeat the same validation, retry, error-mapping, or fetch boilerplate verbatim.
  • Magic strings and config literals re-typed in each new block rather than referenced from one place.
  • Each agent turn re-implements logic the previous turn already wrote (the agent has no memory that a helper exists).
  • A clone detector (jscpd, PMD CPD, Sonar's duplication metrics) reports new duplicated blocks — or a jump in duplicated_lines_density — on the PR that added the feature.
// AI added this for the new route — but getUser already exists 30 lines above
async function getOrder(id: string) {
  const res = await fetch(`${API}/orders/${id}`, { headers: authHeaders() });
  if (!res.ok) throw new Error(`Request failed: ${res.status}`);
  const json = await res.json();
  if (!json?.data) throw new Error("Malformed response");
  return json.data;
}

async function getUser(id: string) {                 // <-- 95% identical body
  const res = await fetch(`${API}/users/${id}`, { headers: authHeaders() });
  if (!res.ok) throw new Error(`Request failed: ${res.status}`);
  const json = await res.json();
  if (!json?.data) throw new Error("Malformed response");
  return json.data;
}

This is the classic Duplicate Code smell, but produced systematically rather than by accident.

Related but distinct: Refactoring Avoidance is about never restructuring code that already exists — new behavior gets bolted on top instead of reshaping the design. This smell is narrower and happens at creation time: the model re-derives logic that already has a home instead of calling or extracting it.

##Reasons for the Problem

Why models produce it

  • Next-token locality. An LLM completes the current span from the most similar text in its context window. Reproducing a nearby, known-good block is the highest-probability continuation; "stop, go define a helper, then call it" is a longer, lower-probability detour that spans multiple files.
  • No repo-wide model. The assistant rarely knows a suitable helper already exists outside its context. Without retrieval it can't reuse what it can't see, so it regenerates. Eno Reyes of Factory ties AI's payoff to the codebase itself — "The higher-quality of the code base, the more the AI accelerated the organization" (Stack Overflow Q&A) — and clones already in the repo become the pattern the next generation copies.
  • Per-turn amnesia + prompt scope. Each request is treated as a self-contained unit ("make this work"), so the agent over-fits a narrow, single-use solution instead of generalizing. OX Security calls this Over-Specification — "hyper-specific, single-use solutions that can't be adapted for other purposes" — and found it in 80–90% of the AI-generated code it studied (OX report).
  • Sycophancy / least-friction. Models optimize for an output that visibly satisfies the ask without touching unrelated files. Copying a block stays inside the file being edited; calling a shared helper — or extracting one — means reaching into another module the model hasn't fully seen, so the local copy wins.
  • Tooling makes it cheap. As DevClass's summary of GitClear's report points out, accepting a suggestion with the Tab key makes inserting a fresh block nearly free, removing the friction that used to push developers toward reuse (DevClass).

Why it hurts

  • Measured quality erosion. GitClear's 2025 analysis of 211 million changed lines (2020–2024) found that refactoring-associated ("moved") lines fell from 25% of changed lines in 2021 to under 10% in 2024, while copy/pasted lines rose from 8.3% to 12.3% over the same period; 2024 was the first year copy/pasted lines exceeded moved lines, and code blocks with five or more duplicated lines grew roughly eightfold during 2024 (GitClear 2025).
  • Correctness debt — "Bugs Déjà-Vu." OX Security found this in 70–80% of AI code: a bug fixed in one clone silently survives in all the others, so identical defects recur and each needs a redundant fix. Duplication turns one bug into N bugs.
  • Maintainability & review load. Every behavior change must be found and edited in many places; diffs balloon with near-identical blocks that reviewers must diff line-by-line to confirm they're actually identical (and not subtly, dangerously different).
  • Security surface. A vulnerable pattern (missing auth check, unescaped input) propagates into every copy, and a later hardening fix can miss clones — duplication multiplies the patch surface.

##Treatment

Review & prompting tactics

  • Tell the model where to look first: "Before writing new code, search the repo for an existing helper that does this and reuse it; do not duplicate." Paste the relevant module so it's in context.
  • Demand consolidation explicitly: "If two blocks differ only by parameters, extract one parameterized function and call it from both." That is Extract Method followed by Parameterize Method.
  • Make it run the tools: "Run the linter / jscpd / the duplication check and resolve any clone it reports before returning the diff." Agents self-correct well when given a deterministic signal.
  • In review, treat a pasted-looking block as a prompt to grep: search for one distinctive line; if it already exists, send it back for extraction.

The refactor — Extract Method + Parameterize Method

// after: one source of truth, called from both
async function getResource<T>(path: string): Promise<T> {
  const res = await fetch(`${API}/${path}`, { headers: authHeaders() });
  if (!res.ok) throw new Error(`Request failed: ${res.status}`);
  const json = await res.json();
  if (!json?.data) throw new Error("Malformed response");
  return json.data as T;
}

const getOrder = (id: string) => getResource<Order>(`orders/${id}`);
const getUser  = (id: string) => getResource<User>(`users/${id}`);

Now a fix to error handling or response parsing lands once. Where the duplicates differ by a step rather than a value, prefer Form Template Method or pass the varying step as a callback. The canonical moves here are Extract Method, Extract Variable or Replace Magic Number with Symbolic Constant (for re-typed literals), and Pull Up Method when clones live in sibling classes — all standard cures for Duplicate Code.

Guardrail, not just cleanup: wire a clone detector into CI with a low threshold so the next AI-generated duplicate fails the build instead of merging. Pair it with a one-line house rule in CLAUDE.md/AGENTS.md ("reuse existing helpers; never paste a near-duplicate") so the constraint rides in the model's context on every turn.

##Detected by