DEV Community

Cover image for Prefer duplication over the wrong abstraction
NorfolkD
NorfolkD

Posted on

Prefer duplication over the wrong abstraction

Sandi Metz wrote The Wrong Abstraction in 2016. It keeps resurfacing — in high-scoring HN threads, architecture Slack channels, and code review comments — whenever a team is staring at a function that started as a clean DRY consolidation and has since acquired seven parameters, three feature flags, and a comment that says # don't touch this.

The core argument is simple: duplication is far cheaper than the wrong abstraction. Once you've built the wrong abstraction, you're not starting from a clean slate — you're inheriting someone else's sunk cost, and every new requirement gets jammed into a shape that doesn't quite fit.

I've spent a lot of time at the intersection where this matters most: design systems, editor foundations, and cross-team API contracts. These are exactly the contexts where the abstraction temptation is strongest — and where getting it wrong is most expensive.

Why the wrong abstraction is so easy to create

Abstractions don't usually start wrong. They start reasonable.

You have two components that render a card. They share 80% of their structure. You extract a Card component, parameterize the differences, and ship it. Six months later, Card has a variant prop with eight values, a withBorder flag, a compact prop, a headerSlot, and a suppressDefaultPadding escape hatch added by someone who needed the component to do something the abstraction never anticipated.

The problem isn't that the original extraction was wrong. The problem is what happened next: every new use case was treated as a reason to extend the existing abstraction rather than a signal to question whether the abstraction still fit.

Metz identifies the specific failure mode: the abstraction is not the thing itself, it's a theory about the thing. When the theory is wrong, new data doesn't correct it — it gets explained away by adding parameters.

The cost of inline duplication is lower than it looks

When two components share markup, the instinct is to extract immediately. But consider what you actually know at extraction time:

  • You know what the two existing callers need.
  • You don't know what the third caller will need.
  • You don't know which parts of the shared structure are incidentally similar versus structurally equivalent.

Incidental similarity is the trap. Two things can look identical for completely different reasons. A StatusBadge and a CategoryTag might render the same pill shape today. That doesn't mean they'll evolve together. Extracting them into a shared Pill component couples their evolution even if their domains have nothing in common.

Duplication, in this case, is not laziness — it's preserving optionality. Each component can change shape independently when its domain requirements diverge, which they will.

// Two components that look identical today
const StatusBadge = ({ status }: { status: OrderStatus }) => (
  <span className={`badge badge--${status}`}>{STATUS_LABELS[status]}</span>
);

const CategoryTag = ({ category }: { category: ProductCategory }) => (
  <span className={`badge badge--${category}`}>{CATEGORY_LABELS[category]}</span>
);

// Six months later, StatusBadge needs a tooltip and an ARIA live region.
// CategoryTag needs a remove button and a count indicator.
// The shared abstraction would now be a liability.
Enter fullscreen mode Exit fullscreen mode

If you'd extracted a Pill component on day one, you'd now be untangling it. Instead, two independent components diverge cleanly.

When shared primitives are genuinely correct

None of this means "never abstract." The argument is about wrong abstractions, not abstractions generally. There's a category of shared code that genuinely belongs together: primitives that are structurally equivalent by design, not incidentally similar by coincidence.

In a design system, a Button component is a real abstraction. It exists because button behavior, accessibility semantics, focus management, and visual consistency are supposed to be uniform across every surface. The shared implementation isn't an accident — it's the point. When the design system updates the focus ring to meet WCAG 2.2 criteria, you want every button in the product to update from one change.

The test for a real abstraction is whether its callers are supposed to be coupled. If coupling them is a feature — consistent behavior, enforced constraints, centralized correctness — the abstraction earns its place. If coupling them is just an artifact of current visual similarity, it's a liability.

A useful heuristic for architecture reviews:

Does this abstraction encode a rule, or does it encode a coincidence?

A Button encodes a rule: interactive elements with this semantic role should behave this way. A Pill extracted from StatusBadge and CategoryTag encodes a coincidence: these two things look the same right now.

The parameter accumulation signal

You rarely see a wrong abstraction clearly at creation time. The signal appears later, in how the abstraction responds to pressure.

Right abstractions absorb new requirements gracefully. You add a new button variant: <Button variant="ghost">. The existing API handles it cleanly. The abstraction was modeling something real, and the new case fits the model.

Wrong abstractions resist new requirements. You need the card component to suppress its default padding in one specific context. There's no clean way to express that, so you add suppressDefaultPadding={true}. The prop name itself is a confession — it's not modeling a concept, it's punching an escape hatch through the existing model.

Parameter accumulation — especially boolean flags or parameters that only matter to one caller — is the clearest signal that an abstraction has outlived its theory.

// This function signature is a warning sign
function formatUserName(
  user: User,
  options: {
    includeTitle?: boolean;
    shortForm?: boolean;
    uppercaseLastName?: boolean; // added for the export feature
    omitMiddleName?: boolean;    // added for the badge component
    legalFormat?: boolean;       // added for contract generation
  }
): string
Enter fullscreen mode Exit fullscreen mode

This function is doing five jobs. It's no longer a formatting function — it's a dispatch table that routes to five different formatting strategies based on flags. The right move is to inline the relevant logic at each call site, or to create five clearly-named functions that each do one thing.

The recovery path: inline before you re-extract

Metz's prescription is specific: when you recognize a wrong abstraction, don't refactor it — inline it. Copy the abstraction's code back to each call site, restore the parameters to their concrete values, delete the dead branches, and see what you actually have.

This is uncomfortable. It feels like moving backward. But what you get after inlining is the truth: the actual logic each caller needs, without the mediation of a theory that no longer fits.

From that position, you can see whether a new abstraction is warranted. Sometimes the call sites look very different after inlining — which tells you the old abstraction was hiding real divergence. Sometimes they look nearly identical — which means a better abstraction is now obvious, because you're working from real requirements rather than accumulated historical ones.

I've done this with editor extension configurations, design token resolution logic, and API response normalization layers. Every time, the inline step was the uncomfortable-but-necessary precondition for getting the abstraction right.

The cross-team dimension

At staff level, wrong abstractions have a social dimension that makes them harder to fix. When an abstraction is owned by one team and consumed by three others, inlining it requires coordination. The owning team has to be willing to let go of something they built. The consuming teams have to accept temporary duplication. Everyone involved has to resist the pull toward "let's just add another parameter."

This is where architecture reviews and RFCs earn their keep. The time to debate whether an abstraction is modeling the right thing is before three teams build on top of it — not after. A good RFC process forces the question: is this abstraction encoding a rule that should govern all callers, or is it encoding one team's current needs in a shape that will constrain everyone else later?

The answer isn't always to avoid the shared abstraction. Sometimes the shared primitive is genuinely the right call. But asking the question explicitly, with concrete examples of what future callers might need, catches a lot of wrong abstractions before they get adopted.

The actual takeaway

Metz's essay persists because it names something that's easy to see in retrospect and hard to see in the moment. The wrong abstraction feels like good engineering when you create it. It reduces line count, eliminates repetition, and looks clean. The cost shows up later, incrementally, as each new requirement gets shoe-horned in.

The discipline it requires is specific: resist the extraction until you understand what the shared structure actually represents. Duplicate freely when the similarity is incidental. Extract confidently when the abstraction encodes a real rule. And when you inherit something that's accumulated enough parameters to be unreadable, have the resolve to inline it before you try to fix it.

Duplication is recoverable. The wrong abstraction is a debt that compounds.

Top comments (0)