The Case for Structural Simplicity: Why Code Duplication Often Beats Complex Abstractions
An abstraction that fuses two things which only look alike costs more than the duplication it removes. A worked example from a billing system, and the trade I take instead.
See the same shape twice and the reflex kicks in: extract it, name it, ship the helper. The third copy of anything feels like proof you failed to factor properly. I carried that reflex for years. It cost me more than the duplication ever would have.
What DRY actually warns against is duplicated knowledge — one business rule encoded in two places, so a change has to land twice and someone forgets the second copy. That is a real bug waiting to happen. But most extracted helpers aren't deduplicating knowledge. They're deduplicating appearance. Two blocks look alike today, so we fuse them and inherit a promise nobody checked: that they will stay alike.
They rarely do.
The invoice that wasn't an invoice
A billing system I worked on had two flows that each produced a PDF with line items, a subtotal, tax, and a total. Customer invoices and internal credit notes. Maybe forty lines of overlap apiece. The obvious move, the one I made, was a DocumentBuilder that both called.
For about six months it held. Then tax rules diverged: credit notes needed reverse-charge handling that invoices didn't. So a type flag went in. Then credit notes needed a reference back to the original invoice, so another parameter. Then the layouts drifted and the builder grew a branch for header rendering. Inside a year that shared class was a thicket of if ($type === 'credit_note'). Every change to invoices meant reading credit-note logic to be sure I wasn't breaking it. The abstraction hadn't removed work. It had welded two things together so that touching one risked the other.
The flows were never the same rule. They were two rules that briefly rhymed.
Pulling them apart took an afternoon and felt like setting something down. Two plain classes, forty similar lines each, zero flags. When invoice tax changed, I opened one file and changed one thing. Nothing else could break, because nothing else was attached.
The trade I actually accept
Duplication has a cost and I won't pretend otherwise. If the shared rule does change, I now have to find both copies and edit each. That's real. The honest comparison is between two failure modes.
A wrong abstraction is a standing tax. You pay it on every read, every change, every onboarding, for as long as the code lives. A duplicated rule is a one-time risk: the day it changes, you fix it in two places. With the copies sitting in greppable form, that day is cheap. The wrong abstraction stays expensive until someone is brave enough to tear it out, and by then it has roots.
So my rule of thumb is blunt. I let code repeat twice, sometimes three times, before extracting anything. Repetition shows me the real axis of variation instead of the one I guessed on day one. By the third occurrence the genuinely stable shape has revealed itself, and the abstraction I build then is small and boring and right. Boring is the target.
The test I apply now: would these two callers change for the same reason, or do they just render the same on screen? Only the first earns a shared abstraction. The second earns a copy and a clear conscience.
Where I still get stuck is the moment of the decision itself. Standing in front of two blocks that rhyme, before either has had time to drift, how am I supposed to know whether they'll change for the same reason or not?