Refactoring Avoidance.
AI assistants rarely restructure existing code: they bolt new branches, flag parameters and wrapper layers onto what is already there, so functions and conditionals keep growing while refactoring — the rework that keeps a codebase healthy — quietly disappears.
##Signs and Symptoms
A reviewer sees a diff that is almost entirely additions to code that already exists. The model solved the prompt by stacking one more branch, flag or layer onto the function it was pointed at instead of reshaping that function so the change fits. Tell-tale signs:
- Additions-only diffs: no lines moved, renamed or deleted; the touched function only ever gets longer.
- Flag parameters:
isWholesale,skipValidation,legacy = falseappended to an existing signature, each switching behavior deep inside the body. - Branch sprawl: each new requirement becomes another
else iforcasein the same function instead of a lookup table, a strategy or a polymorphic call. - A wrapper on top:
processOrderV2callsprocessOrderand patches its result instead of changing it; old and new paths live side by side. - Metrics rising in one place: cognitive complexity, length and parameter count of the same functions climb commit after commit — a growing hotspot.
- Dead flags never removed: parameters that every caller now passes the same way, kept "just in case".
// Every request added a parameter and a branch; nothing was ever restructured.
function priceOrder(
items: Item[],
discount?: number,
isWholesale = false,
applyVat = true,
couponCode?: string,
) {
let total = items.reduce((s, i) => s + i.price * i.qty, 0);
if (discount) total = total * (1 - discount);
if (isWholesale) {
if (total > 1000) total = total * 0.9;
else if (total > 500) total = total * 0.95;
}
if (couponCode === "SPRING") total = total - 10;
else if (couponCode === "VIP") total = total * 0.85;
if (applyVat) total = total * 1.2;
return total;
}
Each request added one parameter and one branch, and every diff touched only the bottom of the function. Nothing is duplicated, so a clone detector stays quiet; the smell shows up as complexity and size growth.
##Reasons for the Problem
Why models produce it
- Additive next-token bias. An LLM completes the prompt in front of it. Appending a branch or a parameter at the point of change is the locally highest-probability, lowest-risk continuation; reshaping the function requires holding the whole module and its callers in working context and predicting a globally consistent edit, which is harder and not what the immediate prompt rewards.
- Thin repo context. Coding assistants rarely load every caller and test. Without seeing who depends on a function, the model cannot tell whether restructuring it is safe, so it leaves the structure alone and adds an
ifwhere it can see. OX Security describes AI as "an army of talented but inexperienced junior developers" — productive locally, without an eye on the architecture. - Refactoring is risky and unrewarded. Restructuring touches code the model wasn't asked to change and can break callers and tests. A sycophantic, "just make it work" assistant minimizes blast radius by not touching working code — the behavior OX measured as "Avoidance of Refactors" in 80–90% of AI-generated code: AI "excels at generating new code but never goes back to improve architecture."
- Generation is cheap, deletion is scary. Producing 40 new lines costs the model nothing; convincing it to delete and reshape 40 existing lines fights its instinct to preserve.
Why it hurts
- Refactoring disappears from the history. GitClear's 2025 analysis of 211 million changed lines found refactoring-associated ("moved") lines falling from 25% of changed lines in 2021 to under 10% in 2024, while copy/pasted lines rose from 8.3% to 12.3% and, in 2024, exceeded moved lines for the first time.
- Complexity compounds. Every bolted-on flag doubles the combinations a function must handle and a test suite must cover; every extra
else ifmakes the next change riskier — so the next prompt also adds instead of restructuring. - Tech-debt accrual. Refactoring is the rework that keeps entropy down; suppressing it means debt is created but never repaid. The codebase grows faster than it improves.
- Review load. Reviewers get diffs that are all additions to functions that are already long; judging how a new branch interacts with the dozen above it is harder than reviewing a function that was reshaped to absorb the change.
##Treatment
Prompting / review tactics
- Make the change easy first. Kent Beck's advice — "for each desired change, make the change easy (warning: this may be hard), then make the easy change" — translates directly into a prompt: "Before adding the coupon rule, restructure
priceOrderso the new rule is a one-line addition; keep behavior identical and run the tests. Then add the feature." - Ban new flag parameters. "Do not add boolean or mode parameters to existing functions; if behavior must vary, propose a restructure first."
- Budget complexity, not just correctness. Require the assistant to run ESLint
complexity,max-paramsandmax-lines-per-functionand Sonar's cognitive-complexity rule, and to report before/after numbers for every function it touched; any increase needs a justification. - Ask for the refactor as a separate step. Generation and restructuring are different tasks: first "make it work", then a separate, behavior-preserving "now restructure this function so the branches become data or separate functions", with tests green before and after.
- Watch hotspots. Track churn × complexity (Code Maat or plain git history) for files that grow in every AI-authored commit, and schedule them for restructuring before the next feature lands there.
The actual refactor — Decompose Conditional and Extract Method name each branch; Replace Parameter with Explicit Methods or Introduce Parameter Object replace flag parameters; and Replace Conditional with Polymorphism (or a lookup table) takes over when branches pick behavior by type or code.
// After: each rule is a small function; priceOrder only composes them.
type PriceRule = (total: number) => number;
const subtotalOf = (items: Item[]) =>
items.reduce((s, i) => s + i.price * i.qty, 0);
const VAT_RATE = 1.2;
const discount = (rate: number): PriceRule => (t) => t * (1 - rate);
const wholesale: PriceRule = (t) => (t > 1000 ? t * 0.9 : t > 500 ? t * 0.95 : t);
const COUPONS: Record<string, PriceRule> = {
SPRING: (t) => t - 10,
VIP: (t) => t * 0.85,
};
const vat: PriceRule = (t) => t * VAT_RATE;
function priceOrder(items: Item[], rules: PriceRule[]): number {
return rules.reduce((total, rule) => rule(total), subtotalOf(items));
}
// The caller states which rules apply, in order:
// priceOrder(items, [discount(0.1), wholesale, COUPONS.SPRING, vat]);
A new pricing rule is now one more entry, not one more parameter and branch, and each rule can be tested on its own.
Not to be confused with Duplication Instead of Extraction: there the model copies existing logic into a new near-identical block, and clone detectors catch it. Here nothing is copied — the model keeps growing the same function with branches, flags and layers, and complexity and size metrics reveal it.
##Detected by
- SonarQube / SonarSource S3776 — Cognitive Complexity of functions should not be too high — flags functions that keep absorbing new branches and flags
- ESLint complexity — Cyclomatic complexity limit per function (max option)
- ESLint max-lines-per-function — Function length limit — catches functions that only ever grow
- ESLint max-params — Parameter count limit — catches flags and options bolted onto an existing signature
- ESLint max-depth — Block nesting depth limit — catches if/else ladders stacked inside old branches
- Code Maat entity-churn — Per-file churn from git history — combine with complexity to find hotspots that grow commit after commit