Every developer learns DRY early. “Don’t Repeat Yourself” — it’s practically programming scripture. We’re taught to hunt down duplication like it’s a bug, to abstract at the first sign of repetition. But here’s what they don’t tell you in those early lessons: sometimes duplication is the lesser evil.
The damage that premature abstraction can do is real. It starts innocently enough. You see the same logic in two places, so you extract it into a function. Makes sense, right? Then a third use case appears, but it’s slightly different. No problem — just add a parameter. Then another variation, another parameter. Before you know it, your “simple” function has become a nightmare that nobody wants to touch.
Let me tell you how this actually plays out in the real world.
Picture this: You’re working on a system that calculates bonuses for different types of workers. You notice that employees and contractors both have similar bonus logic — some base calculation plus a performance multiplier. Your DRY instincts kick in immediately. “Let’s remove this duplication” you think.
So you create a nice, clean shared function. It feels good. It looks professional. Code review passes without a hitch. You’ve eliminated duplication like a good developer should.
But then requirements change — as they always do. And here’s the insidious part: they don’t change all at once. They creep in one at a time, often months apart, implemented by different developers who each think they’re making a small, reasonable adjustment.
First, someone needs to add tenure bonuses for employees. “It’s just one little change,” they think. They see your shared function and add a parameter for tenure calculation. Seems harmless enough.
Six months later, a different developer needs to handle contractor proration based on months worked. They see the existing function and think, “I’ll just add my bit.” Another parameter gets added. The function signature grows, but it still works.
Another quarter passes. Now team ratings affect employee bonuses differently than individual ratings. Another developer, another “small” change. Each developer only sees their piece of the puzzle. They’re not trying to create a mess — they’re trying to ship a feature with minimal risk. Touching one function feels safer than refactoring two separate ones.
A year later, you’ve got a function that takes seven parameters, handles five different business cases, and contains a maze of conditional logic that nobody fully understands. Every new requirement makes it slightly worse, but never quite bad enough to justify a complete rewrite.
This is how abstractions actually decay in the real world — not in a dramatic refactoring gone wrong, but one reasonable-looking commit at a time. Each new requirement made it slightly worse, but never quite bad enough to refactor. It’s death by a thousand parameters.
“But wait,” you might say, “you just need better abstractions!” And sure, with perfect foresight, we could design the perfect class hierarchy. But that’s the point — we don’t have perfect foresight. Requirements evolve in unexpected ways. The problem isn’t that we’re bad at abstractions: it’s that we’re trying to abstract a problem we don’t yet understand.
The worst part? This abstraction is now harder to change than the original duplication. When you need to modify behavior for just one type, you risk breaking the other. You end up tiptoeing around your own code, adding more conditions and parameters rather than admitting the abstraction is wrong.
I’ve learned that the right time to abstract isn’t when you see duplication — it’s when you understand the pattern. And you can’t always understand the pattern from just two examples. You need to see how the code evolves, what parts actually stay the same, and what parts diverge.
Here’s my approach now: I let duplication exist until the pattern screams at me. Sometimes three or four similar implementations. Yes, it feels wrong. Yes, it goes against everything we’re taught. But it works.
When I finally do abstract, I have real data about what varies and what doesn’t. I know which parameters actually matter. The resulting abstraction is simpler, more focused, and actually fits the use cases because it was born from them, not imposed upon them.
Sometimes I don’t abstract at all. Those “similar” functions? Turns out they were solving different problems that just looked similar on the surface. The duplication was superficial — the underlying requirements were fundamentally different. An abstraction would have been a lie we told ourselves about the problem domain.
Let me be clear: DRY is still a valuable principle. Utility functions that do one thing well? Abstract those immediately. Parsing logic, validation rules, formatting helpers — these rarely suffer from premature abstraction because their scope is narrow and their purpose is clear. The danger comes when we try to abstract before we understand the problem space. When we see surface similarities and assume they represent deeper patterns. When we optimize for code beauty instead of code clarity.
The key is patience. Let patterns emerge rather than forcing them. Wait until you have enough examples to see what’s truly common versus what’s accidentally similar. As Sandi Metz brilliantly puts it, “duplication is far cheaper than the wrong abstraction.”
The next time you see repeated code and feel that familiar itch to abstract, pause. Ask yourself: Do I really understand what’s common here? What’s likely to change? What’s the cost if I’m wrong?
Because here’s the thing about the wrong abstraction — it’s not just bad code. It’s a trap that gets harder to escape the longer it exists. Every new feature that touches it, every developer who learns to work around its quirks, every line of code that depends on its specific behavior — they all make it harder to fix.
Duplication, on the other hand? It’s ugly, but it’s honest. And when the time comes, it’s easy to fix.