Duplication Is Cheaper Than the Wrong Abstraction

We built a beautiful, flexible exporter framework to avoid writing three similar classes. Two years later, everyone was afraid to touch it. On the rule of three and earning your abstractions.

duplicationBaseThingHandlerthe wrong abstraction
Bob
Senior Backend Engineer
Jun 10, 2026
6 min read

Early in my career, I noticed that two of our report exporters shared about thirty lines of similar code. I did what every conscientious engineer is trained to do: I extracted a BaseExporter with template methods and configuration hooks. Clean. DRY. I was proud of it.

Two years later, BaseExporter had eleven subclasses, nine configuration flags, four if respond_to? escape hatches, and a method called pre_process_hook_v2. New exporters took longer to write than the original copy-paste ever had, because you first had to understand the framework — and the framework existed to serve requirements from exporters three, five, and eight that had nothing to do with yours. When someone finally needed streaming export, they wrote it from scratch outside the hierarchy, apologized in the PR description, and got the fastest approval I've ever given.

Sandi Metz put the lesson into words I've quoted ever since: duplication is far cheaper than the wrong abstraction. It took me years — and that exporter — to believe her.

Why we abstract too early

Nobody sets out to build pre_process_hook_v2. Premature abstraction comes from good instincts firing too soon.

We're taught DRY as a moral value, so duplication feels like a defect, something to be eliminated on sight. Two similar blocks of code produce an almost physical itch. But DRY was never about textual repetition — it's about not repeating knowledge, a single business rule living in two places where one update will inevitably miss the other. Two pieces of code can look identical today and represent completely different decisions that merely coincide right now.

That's the crux: an abstraction is a bet about the future. When you unify two similar things, you're wagering they'll keep changing together. Win the bet and you update one place forever. Lose it — the two things start diverging — and every divergence becomes a parameter, a flag, a conditional inside the shared code. The abstraction doesn't fail loudly; it accretes. Each flag is individually reasonable. Compound them and you get a thing with nine configuration options that nobody can safely modify, because every change must be verified against eleven callers with different needs.

Duplication's costs are real but linear and legible: you fix a bug twice, and grep tells you where. A wrong abstraction's costs are compounding and hidden: every future reader pays a comprehension tax, every future change pays a blast-radius tax, and unwinding it means touching every caller at once — which is why nobody ever does, and why these things live forever.

The rule of three, and what to do while you wait

The old heuristic still earns its keep: tolerate the second occurrence; consider extracting at the third. Not because three is magic, but because of what a third example gives you — evidence. With two instances, you're guessing which parts are essential and which are coincidence. With three, the true shape shows itself: the parts that vary across all three are parameters; the parts identical in all three are the abstraction; the parts that vary in only one are that instance's private business and should never have entered the shared code.

Abstracting at two means designing from one data point of variation. That's not architecture; it's astrology.

While you wait for the third example, duplication doesn't have to be sloppy. Copy deliberately: keep the copies structurally parallel so a future diff is easy, and leave a breadcrumb —

# NOTE: intentionally similar to InvoiceCsvExport (see PR #482).
# Two instances so far — extract if we grow a third. Rule of three.

That comment does real work. It tells the next reader the duplication is a decision, not an oversight, and it hands them the trigger condition for revisiting it.

And when the third example does arrive, extract from the concrete cases — the shape falls out of evidence rather than prediction:

# Three exporters later, the ACTUAL shared shape turned out to be tiny:
module CsvStreaming
  def stream_csv(rows, headers:)
    Enumerator.new do |y|
      y << CSV.generate_line(headers)
      rows.find_each { |r| y << CSV.generate_line(yield(r)) }
    end
  end
end

Notice how modest that is. Real abstractions extracted from evidence are usually small and boring. It's the predicted ones that arrive with hooks and options for futures that never come.

One more permission slip, since somebody may need it: when you find yourself fighting an existing abstraction — passing flags to skip half its behavior — the brave move is often to inline it back into its callers, let them diverge honestly, and re-extract later if a true shape emerges. Metz's point cuts both ways: the sunk cost of an abstraction is not a reason to keep feeding it.

The modern accelerant

Two current trends pour gasoline on this old fire.

First, code generation has made both failure modes cheaper to produce. Assistants happily emit near-duplicate code at scale — but ask one to "refactor these two similar classes" and you'll get an eager, plausible abstraction built from exactly two data points, often with speculative parameters for variation that doesn't exist yet ("I added a format: option in case you need JSON later"). The model can't know whether your two exporters coincide or share knowledge — that distinction lives in your domain, in conversations the model wasn't in. Reviewers now need to push back on premature unification as often as on duplication. "You aren't gonna need it" applies with full force to generated flexibility.

Second, and it's the same lesson wearing platform clothes: speculative generality — the plugin system with one plugin, the "strategy pattern" with one strategy, the multi-tenant abstraction for the second tenant that's "definitely coming in Q3." Every one of these is the exporter framework again: a bet placed before the evidence arrived.

None of this makes abstraction bad. The good ones — the ones extracted from three honest examples, with small surfaces and no escape hatches — are the best code in any system. The skill isn't avoiding abstraction. It's sequencing it: concrete first, evidence second, abstraction third.

The questions I ask before extracting

My actual pre-extraction checklist, refined by that exporter and several of its cousins:

  1. Do these copies share knowledge or just shape? Would a business-rule change necessarily hit both? If not, the similarity is coincidence — leave it alone.
  2. Do I have three examples? With two, I write the comment and wait. The third example is cheap; the wrong abstraction is not.
  3. Can I name it without "and," "manager," or "helper"? If the honest name is ProcessorHelperManager, I don't have an abstraction — I have a pile with a roof.
  4. Does the extraction need a flag on day one? A boolean parameter at birth means I'm unifying two things that already disagree. That's the bet announcing it's lost.
  5. Would inlining be easy later? Good abstractions are cheap to unwind. If unwinding would touch twelve files, the commitment deserves more evidence.
  6. In review of generated refactors: did anyone ask for this generality? Speculative options get deleted, warmly and without apology.

Duplication is a debt with a visible balance and a fixed interest rate. The wrong abstraction is a debt that compounds quietly and sends the bill to whoever touches the code next. When in doubt, stay concrete a little longer — the third example always shows up faster than you think, and it arrives carrying the answer.

designrefactoringabstractionmaintainabilitycode-review
Written by
Bob
Senior Backend Engineer · Firetrail review team
More Backend