open atlas
↑ Back to track
Code patterns & craft CP · 12 · 03

The wrong abstraction

Duplication is cheaper than the wrong abstraction. A premature shared method grows a flag per caller until it is a tangle. The fix is to inline it back, then re-derive the right abstraction from the now-visible differences.

CP Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

You open a function that six callers depend on. It takes eight parameters, four of them booleans — isPreview, skipValidation, forEmail, legacyMode — and the body is a thicket of if (forEmail) … else if (legacyMode) …. Every branch was added by a different person solving a different problem, all funnelled through one method because, once upon a time, two of them looked alike. Now nobody can change it without reading all six call sites and praying.

This is the wrong abstraction, and it is one of the most expensive shapes code can take — more expensive than the duplication it was meant to cure. The senior move here is counterintuitive and a little scary: you are going to make the code more duplicated on purpose, by inlining the abstraction back into its callers, so the real abstraction can finally show itself.

Goal

After this lesson you can recognise the lifecycle of a wrong abstraction (premature merge → flag accretion → unreadable tangle); explain Sandi Metz’s claim that “duplication is far cheaper than the wrong abstraction” in cost-of-change terms; execute the un-abstraction move in TypeScript — inline back to each caller, then re-derive the genuine shared concept from the now-visible differences; and resist the sunk-cost reflex that keeps a clearly-wrong abstraction alive because deleting it “feels un-DRY.”

1

A wrong abstraction is born when you merge two things that were only coincidentally alike. Two functions had similar bodies, so someone extracted a shared method. But similar code is not the same as shared meaning. The two callers happened to need the same steps today; they were never the same concept. The extraction couples two requirements that have no reason to change together — and now they are forced to.

// Day 1: these looked identical, so they got merged.
function renderItem(item: Item) {
  return `<li>${item.name}: ${item.price}</li>`;
}
// "invoiceLine looks the same!" → extract a shared formatter.

The mistake is treating structural similarity as evidence of a shared abstraction. It is not. The only real evidence is that the two things change together for the same reason — and you usually cannot know that on day one.

2

The abstraction then rots by accretion: a boolean (or enum) parameter per new caller. A third caller needs almost the same thing — but with a tweak. The path of least resistance is to add a parameter and an if. Then a fourth. Each addition is locally reasonable and globally catastrophic: the function’s branches multiply, its meaning blurs, and its signature becomes a checklist of unrelated flags.

// Each new requirement bolts on a flag instead of forking.
function render(
  item: Item,
  forEmail: boolean,
  isPreview: boolean,
  legacyMode: boolean,
  skipPrice: boolean,
) {
  if (legacyMode) return renderLegacy(item);
  const price = skipPrice ? "" : `: ${item.price}`;
  if (forEmail) return `<tr><td>${item.name}${price}</td></tr>`;
  if (isPreview) return `<li class="preview">${item.name}${price}</li>`;
  return `<li>${item.name}${price}</li>`;
}

A boolean parameter that selects between two behaviours is a reliable signal: the function is doing two jobs. Four of them means four jobs wearing one name. The flags are the tree-rings of a wrong abstraction.

3

“Duplication is far cheaper than the wrong abstraction.” DRY serves changeability — not the reverse. Sandi Metz’s rule is the heuristic that resolves this unit’s central tension. Duplication has a known, bounded cost: if the rule changes, you edit it in N places, and the worst case is you miss one. The wrong abstraction has an unbounded, compounding cost: every caller is now coupled to every other caller’s edge cases, every change risks all six call sites, and the tangle gets worse with each “small” addition.

DRY is not a goal in itself. It is a means to the real goal — cheap change. When a “DRY” abstraction makes change more expensive than the duplication would have, DRY has been applied against its own purpose. The senior reading of DRY is: remove duplication of knowledge that changes together. Code that merely looks the same but changes for different reasons is not the kind of duplication DRY is about.

4

The fix is to inline the abstraction back into every caller — deliberately re-duplicate — then re-derive the real abstraction from the differences you can now see. You cannot find the right seam while the wrong one is hiding the data. So you reverse the extraction: copy the relevant branch of the shared function into each caller, delete the flag that selected it, and let each call site become a small, honest, standalone function again.

// Step A — inline back. Each caller gets just its own branch.
function renderListItem(item: Item) {
  return `<li>${item.name}: ${item.price}</li>`;
}
function renderEmailRow(item: Item) {
  return `<tr><td>${item.name}: ${item.price}</td></tr>`;
}
function renderPreview(item: Item) {
  return `<li class="preview">${item.name}: ${item.price}</li>`;
}

Now the duplication is visible and local. With all three side by side, the genuine shared concept appears: they all format ${name}: ${price} and wrap it differently. That — the inner “name + price” string — is the real abstraction; the wrapping is what legitimately varies.

// Step B — re-derive the TRUE shared concept (the inner content, not the wrapper).
function nameAndPrice(item: Item) {
  return `${item.name}: ${item.price}`;
}
const renderListItem  = (i: Item) => `<li>${nameAndPrice(i)}</li>`;
const renderEmailRow  = (i: Item) => `<tr><td>${nameAndPrice(i)}</td></tr>`;
const renderPreview   = (i: Item) => `<li class="preview">${nameAndPrice(i)}</li>`;

The new abstraction is small, has no flags, and each caller is readable on its own. You did not “give up on DRY” — you found the duplication that actually shares meaning and extracted only that.

5

Be willing to delete an abstraction. Sunk cost is not a reason to keep the wrong one — and “removing it feels un-DRY” is the failure mode to name and resist. The cost already paid to build the bad abstraction is gone whether you keep it or not; the only question is the cost from here. If the abstraction makes every future change more expensive, keeping it out of loyalty to past effort just pays the cost forever.

The specific trap is identity-based: engineers internalise “DRY = good,” so inlining triggers a “this feels wrong” reflex even when it is plainly correct. Notice the feeling and reframe it: you are not abandoning DRY, you are serving the goal DRY exists for. When NOT to inline: if the abstraction is genuinely right (callers do change together for one reason) and merely awkward, fix it in place — don’t tear down a load-bearing seam because it has a bad name. The skill is telling a coincidental merge from a real one; the tell is whether the callers’ requirements change together.

Worked example

Un-abstracting a flag-driven notification builder. Three features — order confirmation, password reset, and a marketing blast — were funnelled through one buildNotification because their early versions overlapped.

// BEFORE — the wrong abstraction, three jobs under one name.
function buildNotification(
  user: User,
  kind: "order" | "reset" | "promo",
  opts: { includeUnsubscribe?: boolean; trackOpens?: boolean } = {},
) {
  const greeting = `Hi ${user.firstName},`;
  let body: string;
  if (kind === "order")  body = `Your order ${user.lastOrderId} is confirmed.`;
  else if (kind === "reset") body = `Reset your password: ${resetLink(user)}`;
  else body = promoCopy(user.segment);

  let footer = "";
  if (opts.includeUnsubscribe) footer += unsubscribeFooter(user);
  if (kind === "reset") footer += " This link expires in 1 hour.";   // leaked into the shared path
  const pixel = opts.trackOpens ? trackingPixel(user) : "";          // only promo wants this
  return `${greeting}\n${body}\n${footer}${pixel}`;
}

The smell: kind plus two option flags select between three unrelated workflows, and special cases (reset expiry, promo pixel) have leaked into the shared body. A change to promo tracking forces you to reason about password-reset emails. Now inline back to each caller, then keep only what truly recurs:

// AFTER — inline to honest functions; extract only the real shared concept.
const greeting = (u: User) => `Hi ${u.firstName},`;   // ← the one thing that genuinely recurs

function buildOrderEmail(user: User) {
  return `${greeting(user)}\nYour order ${user.lastOrderId} is confirmed.`;
}
function buildResetEmail(user: User) {
  return `${greeting(user)}\nReset your password: ${resetLink(user)}\nThis link expires in 1 hour.`;
}
function buildPromoEmail(user: User) {
  return `${greeting(user)}\n${promoCopy(user.segment)}\n${unsubscribeFooter(user)}${trackingPixel(user)}`;
}

Each builder is now readable top-to-bottom, owns its own special cases, and changes without touching the others. The genuine duplication — the greeting — was extracted; the coincidental overlap was duplicated back, on purpose. The flag parameters are gone, and so is the cross-talk between unrelated features.

Why this works

Why does inlining — temporarily increasing duplication — lead to better structure? Because the wrong abstraction actively hides the information you need to find the right one. While three behaviours are collapsed behind one flagged function, you cannot see which parts are genuinely shared and which are coincidental; the if branches obscure the real shape. Inlining lays the three cases out side by side, in full, so the actual axis of variation becomes visible to the eye. Only then can you extract along the seam that reflects how the code changes rather than how it happened to look on the day someone merged it. The duplication is a diagnostic step, not the destination.

Common mistake

The failure mode this lesson exists to prevent: refusing to inline a clearly-wrong abstraction because “removing it feels un-DRY.” This dresses a sunk-cost bias in the language of principle. DRY is a tool for making change cheap; an abstraction that makes every change more expensive has already failed at DRY’s actual job, so keeping it is the un-principled choice, not the principled one. Two tells you’re rationalising: (1) you’re defending the abstraction by how much work it was to build, not by what the next change will cost; (2) you can’t state a single reason all the callers would change together. If neither future-cost nor shared-reason survives scrutiny, inline it — the discomfort is the bias talking, not the design speaking.

Check yourself
Quiz

A shared function takes three boolean flags, each added by a different caller, and its body is a tangle of ifs. A teammate wants to add a fourth flag for a new case. What's the senior move, and why?

Recap

A wrong abstraction is born by merging two things that were only coincidentally alike, then it rots by accretion — a boolean (or enum) flag per new caller until the shared method is an unreadable tangle that couples unrelated requirements. Sandi Metz’s heuristic resolves it: “duplication is far cheaper than the wrong abstraction,” because duplication’s cost is bounded and the wrong abstraction’s cost compounds across every caller. The fix runs against the grain: inline it back into each caller, deliberately re-duplicating, so the real differences become visible — then re-derive the genuine shared concept (often something smaller and inner) from what truly changes together. The senior disposition is to delete an abstraction without loyalty to sunk cost, and to name the failure mode when it appears: refusing to inline because it “feels un-DRY” is sunk-cost bias in principle’s clothing. DRY serves changeability; when it stops doing so, it stops applying.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.