Skip to main content
Mohammad Shehadeh — home (MSH monogram, letter M filled with the Palestinian flag)

Duplication Is Far Cheaper Than the Wrong Abstraction

Published on
5 min read

You see two blocks of code that look similar. Your instinct says "DRY it up," so you extract a shared function. A week later, a third use case appears that's almost the same but needs a flag. Then a fourth that needs another flag. Now your "reusable" function has four parameters, three conditional branches, and nobody understands what it does anymore.

You didn't save complexity. You moved it somewhere harder to see.

The DRY Trap

DRY (Don't Repeat Yourself) is one of the first principles developers learn. It's a good principle, but it's often applied too early. The problem isn't DRY itself. It's treating similar-looking code as the same code.

Two functions can look identical today and evolve in completely different directions tomorrow. When you merge them too early, you're betting that they'll always change together. That bet usually loses.

similar-but-different.ts
1// These look the same today
2const createUserEmail = (user: User) => {
3  const subject = `Welcome, ${user.name}`;
4  const body = `Thanks for signing up, ${user.name}.`;
5  return { subject, body, to: user.email };
6};
7
8const createOrderEmail = (order: Order) => {
9  const subject = `Order ${order.id} confirmed`;
10  const body = `Thanks for your purchase, ${order.customerName}.`;
11  return { subject, body, to: order.customerEmail };
12};

Both build an email object with an identical structure. Should you extract a createEmail helper? Probably not. These two emails will evolve independently — different templates, different data, different rules. Merging them now means splitting them later, and splitting is always harder than merging.

What the Wrong Abstraction Looks Like

Here's what happens when you abstract too early. It starts clean:

step-1-clean.ts
1// Two components need a card layout. You extract it.
2const Card = ({ title, children }: CardProps) => (
3  <div className="rounded-lg border p-4">
4      <h3 className="font-semibold">{title}</h3>
5      {children}
6  </div>
7);

Then a third use case needs a footer:

step-2-footer.ts
1const Card = ({ title, children, footer }: CardProps) => (
2  <div className="rounded-lg border p-4">
3      <h3 className="font-semibold">{title}</h3>
4      {children}
5      {footer && <div className="mt-4 border-t pt-2">{footer}</div>}
6  </div>
7);

Then someone needs a variant without the border. Then a clickable version. Then one with an icon in the header:

step-3-mess.ts
1const Card = ({
2  title,
3  children,
4  footer,
5  variant = "default",
6  onClick,
7  icon,
8  headerAction,
9  noPadding = false,
10  className,
11}: CardProps) => (
12  <div
13      className={cn(
14          "rounded-lg",
15          variant !== "borderless" && "border",
16          !noPadding && "p-4",
17          !!onClick && "cursor-pointer hover:bg-gray-50",
18          className,
19      )}
20      onClick={onClick}
21      role={!!onClick ? "button" : undefined}
22  >
23      <div className="flex items-center justify-between">
24          <div className="flex items-center gap-2">
25              {icon}
26              <h3 className="font-semibold">{title}</h3>
27          </div>
28          {headerAction}
29      </div>
30      {children}
31      {footer && <div className="mt-4 border-t pt-2">{footer}</div>}
32  </div>
33);

This component is now harder to understand than the three separate components it replaced. Every caller pays the cost of understanding all the options, even though each uses only a fraction of them.

The Real Cost

The wrong abstraction doesn't just add complexity to one file. It couples every caller together. Changing behavior for one use case risks breaking all the others.

Why Duplication Is Cheaper

Duplicated code has a clear cost: to change the logic, you change it in multiple places. That's real, but it's a known, linear cost. You can find every instance with a search and update them.

The wrong abstraction has a hidden, compounding cost:

  • Every new use case makes it worse. Each flag or parameter adds a conditional branch, and the function grows in ways the original author never intended.
  • Nobody dares to simplify it. By the time it's messy, five callers depend on it. Refactoring means understanding and retesting all of them.
  • Bugs affect everyone. A fix for one caller's edge case can break another caller's expected behavior.
  • It's harder to delete. Three similar functions can each be deleted independently. One shared abstraction is load-bearing for the entire feature.

How to Know When to Abstract

The key question isn't "do these look similar?" It's "will these change for the same reason?"

Wait until you've seen the same code duplicated three or more times and all instances have evolved in the same direction. At that point, you have evidence, not a guess.

good-abstraction.ts
1// Three API endpoints all need the same error handling.
2// They've been copy-pasted and have stayed identical for months.
3// Now it makes sense to extract.
4
5const withErrorHandling = async <T>(
6  handler: () => Promise<T>,
7): Promise<T | ErrorResponse> => {
8  try {
9      return await handler();
10  } catch (error) {
11      if (error instanceof ValidationError) {
12          return { status: 400, message: error.message };
13      }
14      if (error instanceof NotFoundError) {
15          return { status: 404, message: "Not found" };
16      }
17      return { status: 500, message: "Internal server error" };
18  }
19};

This abstraction works because the three callers share the same error-handling logic and will keep changing together. If one endpoint needed different error codes, you'd know not to abstract.

Recovering From the Wrong Abstraction

If you're already stuck with one, the fix is to inline the abstraction back into each caller, reintroducing duplication. Then look at the duplicated code with fresh eyes. You might find that only two of the five callers are actually similar. Abstract those two and leave the rest alone.

inline-and-reassess.ts
1// Before: one overloaded function serving 3 different needs
2// formatDisplay(value, { type: "currency" })
3// formatDisplay(value, { type: "percentage" })
4// formatDisplay(value, { type: "date" })
5
6// After: three simple, independent functions
7const formatCurrency = (value: number) =>
8  new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(value);
9
10const formatPercentage = (value: number) =>
11  `${(value * 100).toFixed(1)}%`;
12
13const formatDate = (date: Date) =>
14  new Intl.DateTimeFormat("en-US").format(date);

Three functions, zero flags, zero conditional branches. Each one is obvious, testable, and independent. If two of them later meet, you'll see it clearly and can merge with confidence.

Key Takeaways

  • Similar code is not the same code. Two functions that look alike today might evolve in opposite directions tomorrow.

  • Wait for the third instance. One duplicate is a coincidence. Two is a pattern. Three is a reason to abstract.

  • The cost of duplication is linear and visible. The cost of the wrong abstraction is exponential and hidden.

  • When in doubt, don't abstract. You can always extract a function later. Handling a bad abstraction is much harder than merging duplicates.

The best code isn't the driest code. It's the code that's easiest to change when requirements shift.

Related Articles

GET IN TOUCH

Let's work together

I build fast, accessible, and delightful digital experiences for the web. Whether you have a project in mind or just want to connect, I'd love to hear from you.

Get in touch

or reach out directly at hello@mohammadshehadeh.com