Skip to content

Instantly share code, notes, and snippets.

@mpaleo
Created June 16, 2026 04:16
Show Gist options
  • Select an option

  • Save mpaleo/ca3f84179c2f85f661d6c651ef859cb0 to your computer and use it in GitHub Desktop.

Select an option

Save mpaleo/ca3f84179c2f85f661d6c651ef859cb0 to your computer and use it in GitHub Desktop.

The Wrong Abstraction Costs More Than Duplication

Every codebase I've worked on has a function that everyone is quietly afraid of. It started small and reasonable, two or three callers, a clear job. Then it grew a boolean parameter. Then another. Then an options object, a couple of early returns keyed on who was calling it, and a comment that says // don't touch unless you know what you're doing. Nobody knows what they're doing. That function is what a good intention looks like three years later.

I want to make a case that cuts against one of the first instincts we train into engineers, that duplication is the enemy. It usually isn't. The wrong abstraction is the enemy, and it's a lot more expensive than the duplication it was meant to remove.

How the wrong abstraction forms

The failure mode is almost never recklessness. It's diligence. You see two pieces of code that look alike, two components, two handlers, two mappers, and you do the responsible thing and pull out the shared part. At that moment the abstraction is correct. The two callers really are the same.

Then requirements diverge, the way requirements always do. Caller A needs the behavior to differ in one small way, and the cheapest local change is to pass a flag, so you add isCompact. Next quarter caller B needs its own variation, so you add variant. Then a third caller shows up that's mostly like A but not quite, and now there's a branch inside the abstraction that exists to serve exactly one caller. Every step is reasonable on its own. The sum is a function whose body is a maze of conditionals encoding the differences between callers that were never actually the same. They just looked the same on the day you merged them.

The part worth noticing is that the abstraction stopped being true at some point, and nobody was assigned to notice.

Why it's worse than duplication

Here's the asymmetry that matters.

Duplication is honest and local. If the same fifteen lines live in three components and a requirement changes, the worst case is you update three places. And you can see all three, each one stands alone, and changing one can't break the other two. The cost of duplication is visible, it's bounded, and it's paid by the person who chose it.

The wrong abstraction is the opposite on every axis. The coupling is invisible. A change you make for caller A silently rides out to callers B and C, and you find out in production, or in a test you're lucky to have. The cost is unbounded, because every new caller makes the conditional thornier. And the cost lands on the next person, not the one who created it, usually someone six months later who needs to make a small change and discovers they have to understand five callers' worth of context to safely touch ten lines.

Duplication you can consolidate later, once the shape is clear. The wrong abstraction you have to un-build first, under load, with everything depending on it, before you can even start the change you came to make.

The shape it takes

It tends to look like this. A component that began life unified and slowly grew a control panel of props:

// Six months in. Almost every prop here exists to paper over a
// difference between callers that were never the same component.
interface UserCardProps {
  user: User;
  variant: 'compact' | 'full' | 'inline';
  showAvatar?: boolean;
  showActions?: boolean;
  isSelectableRow?: boolean;        // only the table uses this
  onSelect?: (id: string) => void;  // ...and this
  hideEmail?: boolean;              // only the export preview uses this
  density?: 'comfortable' | 'tight';
}

function UserCard({ user, variant, showAvatar = true, /* ... */ }: UserCardProps) {
  if (variant === 'inline') {
    // a completely separate render path that shares nothing with
    // 'full' except the word "user"
  }
  // ...and a few hundred more lines of branching
}

The three call sites have almost nothing in common except that they all display a user. The "abstraction" is displays a user, which is too thin to justify coupling three unrelated screens together. Pulling them apart into three honest components, each duplicating the ~15 lines of avatar markup, is less total complexity than one component forced to be all three at once:

// The boring version that's actually easier to live with.
function UserTableRow({ user, onSelect }: UserTableRowProps) { /* ... */ }
function UserProfileCard({ user }: UserProfileCardProps) { /* ... */ }
function UserInlineMention({ user }: UserInlineMentionProps) { /* ... */ }

// Yes, the avatar JSX appears three times. That's fine. Each one
// can change without a second thought about the other two.

The duplicated markup looks like the mess. It isn't. The single component with eight props was the mess. It was just better disguised.

The discipline

So here's what I actually do.

Wait for three. Two examples don't tell you the shape of the line, and if you extract on two you'll guess the seam wrong. Three is usually enough signal to see where the real boundary is.

Prefer inlining and duplication while a feature is still moving fast and its edges are unknown. Extract when the abstraction is discovered, not when it's predicted, meaning when you can point at the real shared thing instead of the thing that just looks shared today.

And the part people find hardest: be willing to delete an abstraction. When a shared function has accreted three flags, the answer is almost never a fourth flag. It's to inline it back into its callers, let the duplication be visible again, and then see whether a smaller, truer abstraction wants to emerge from the parts that genuinely are shared. Re-duplicating feels like walking backwards. Most of the time it's the fastest way forward.

What DRY actually meant

DRY was never about text that looks alike. The original idea, that every piece of knowledge should have a single unambiguous representation, is about knowledge, not characters on a screen. Two functions that happen to contain the same lines today, for different reasons, aren't knowledge duplication. They're a coincidence. Merge them and you've coupled two things that were only ever accidentally similar, and you'll pay for that coincidence on every change for as long as the code lives.

The senior instinct isn't never repeat yourself. It's being able to tell the difference between code that's the same because it represents one idea and code that's the same by accident, and having the patience to wait until you can tell which is which.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment