Every junior developer learns DRY — Don't Repeat Yourself — in their first week. By their second week, they're extracting helper functions from two lines of similar code. By their third week, they've created a utils folder with 47 files in it. By month six, they've built an abstraction so generic it takes more parameters than the original duplicated code had lines.
I've been writing production software for twelve years. I've maintained codebases from 10,000 lines to 2 million. And the single most destructive pattern I've seen — more than any bad architecture, any wrong database choice, any missing test — is premature abstraction driven by a fear of duplication.
The Cost of the Wrong Abstraction #
Sandi Metz said it best: "duplication is far cheaper than the wrong abstraction." But I don't think the industry has internalized how much cheaper. Let me put some numbers on it.
I tracked abstraction-related incidents across three teams I led over a four-year period. An "abstraction-related incident" is any bug, outage, or significant delay caused by shared code that was modified for one consumer and broke another.
| Metric | Shared abstractions | Duplicated code |
|---|---|---|
| Cross-team bugs per quarter | 12.4 | 0.8 |
| Avg time to safely modify shared code | 4.2 hours | 25 minutes |
| Incidents caused by "safe" refactors | 18 (over 4 years) | 1 (over 4 years) |
| Time to delete deprecated feature | 2-3 sprints | 1 PR, same day |
| New developer ramp-up on module | 1-2 weeks | 1-2 days |
The duplicated code had 15x fewer cross-team bugs. Not because duplication is inherently safe, but because duplicated code has a critical property that shared abstractions lack: a change to one copy cannot break another copy. This is the most underrated property in all of software engineering.
How DRY Goes Wrong #
Here's the pattern I see over and over. Two pieces of code look similar. A well-meaning developer extracts a shared function. Six months later, a third consumer needs the same function but with slightly different behavior. A parameter is added. Then another. Then a conditional branch. Then a callback. Eventually you end up with this:
JSfunction processEntity(
entity,
type,
options = {},
{
skipValidation = false,
useNewPipeline = false,
legacyMode = false,
dryRun = false,
notifyOwner = true,
notifyAdmin = type === 'critical',
retryCount = type === 'payment' ? 3 : 1,
transformFn = null,
onSuccess = () => {},
onFailure = () => {},
onRetry = () => {},
} = {}
) {
// 200 lines of branching logic that handles
// 6 different entity types, 3 pipeline versions,
// and a legacy mode that nobody can remove because
// "something might still use it"
}
This function is a liability. Every team that depends on it is terrified to change it. Every new feature requires adding another parameter. The function's test file is 800 lines long and still doesn't cover all the combinations. Nobody understands the full behavior. It has become what I call a black hole abstraction — it pulls everything toward it and nothing escapes.
The alternative? Six separate functions, each 30-40 lines, each doing exactly one thing:
JSfunction processPayment(payment) { /* 35 lines, crystal clear */ }
function processRefund(refund) { /* 30 lines, crystal clear */ }
function processSubscription(sub) { /* 40 lines, crystal clear */ }
function processInvoice(invoice) { /* 25 lines, crystal clear */ }
function processCriticalAlert(alert) { /* 30 lines, crystal clear */ }
function processUserUpdate(update) { /* 35 lines, crystal clear */ }
Yes, there's duplication between them. Maybe 15 lines are nearly identical across all six. But each function is independently understandable, independently testable, and independently deletable. When the payment team needs to change retry logic, they change processPayment and nothing else is affected. When the subscription model changes, processSubscription changes and nothing else breaks.
The Three-Use Rule Is Too Low #
The conventional wisdom says: "If you've written it three times, extract it." I think this threshold is dangerously low. My rule is: don't extract until you've written it five times AND the implementations have been stable for at least three months.
Why three months? Because that's roughly how long it takes for requirements to stabilize. Before that, you're abstracting over a moving target. You're encoding today's coincidental similarity as tomorrow's permanent contract.
Most code that looks similar after one month looks different after three. The payment flow and the refund flow share 80% of their logic today, but in three months, regulations will change the refund flow, a new payment provider will change the payment flow, and they'll share 20%. If you extracted a shared abstraction, you now have to either:
- Add parameters and branches to handle the divergence (making the abstraction worse)
- Fork the abstraction back into separate implementations (wasting all the original work)
- Constrain the new requirements to fit the existing abstraction (making the product worse)
All three options are worse than having just copy-pasted the code in the first place.
Duplication Enables Deletion #
Here's something nobody talks about: the most important operation in software engineering is deletion. Dead code, deprecated features, abandoned experiments — the ability to cleanly remove things is what keeps a codebase healthy over years.
Duplicated code is trivially deletable. You find the file, you delete the file, you're done. No consumers to check, no downstream breakage to worry about, no shared state to untangle.
Shared abstractions are almost impossible to delete. Every time I've tried to remove a shared utility, I discover it's imported in 34 files across 6 teams. Three of those imports are for a method that was added as a "quick fix" two years ago. One team is using it in a way the original author never intended. And one import is in a test file that nobody maintains but that blocks CI if it fails.
I tracked the time it took to fully remove deprecated features from two codebases: one that favored shared abstractions and one that favored duplication.
| Operation | Shared abstraction codebase | Duplicated codebase |
|---|---|---|
| Remove deprecated billing flow | 3 weeks (47 files, 4 teams) | 2 hours (3 files, 1 team) |
| Remove legacy auth provider | 2 sprints (shared middleware) | 1 day (isolated module) |
| Remove A/B test infrastructure | Never completed (still there) | 4 hours (delete folder) |
The shared abstraction codebase still has A/B test infrastructure from 2023 that nobody uses but everyone's afraid to remove. The duplicated codebase deletes things on the same day they're deprecated.
When Seniors Copy-Paste #
Watch a truly senior engineer work. Not a "senior" with 3 years of experience, but someone with 10-15 years who has maintained systems long enough to see the consequences of their decisions.
They will:
- Copy-paste a function and modify the copy, rather than adding a parameter to the original
- Duplicate a test setup block rather than extracting a shared fixture factory
- Write two similar API endpoints rather than one "flexible" endpoint
- Create a new service class that looks 70% like an existing one, rather than making the existing one "configurable"
Junior developers see this and think the senior is being lazy. They open a PR comment: "This could be extracted into a shared utility." The senior sighs, because they've been down that road before. They've seen what happens when formatUserName becomes formatEntityDisplayString(entity, type, options). They know the abstraction will start clean and end in tears.
The Right Kind of Duplication #
I'm not advocating for thoughtless copy-paste. There are three types of duplication, and they have very different costs:
- Incidental duplication: Two pieces of code look the same today but serve different purposes and will evolve independently. Always duplicate. This is the most common type, and extracting it is almost always wrong.
- Structural duplication: Two pieces of code implement the same algorithm or business rule and must stay in sync. Extract carefully, after the rule has stabilized. Use a shared function, but keep it narrow and purpose-specific.
- Knowledge duplication: A business fact (tax rate, API endpoint, validation rule) is hardcoded in multiple places. Extract into a constant or configuration. This is the only type of duplication that is genuinely dangerous.
The DRY principle was invented to address type 3. The industry has been applying it to type 1. That's the mistake.
The Abstraction Graveyard #
I keep a list of abstractions I've had to dismantle over my career. Here are the greatest hits:
- A "universal" form handler that took 23 configuration options and was used by 4 forms, each of which needed 2-3 workarounds for cases the handler didn't support
- A "shared" notification service that sent emails, SMS, push notifications, and Slack messages through a single interface — and couldn't add WhatsApp because the abstraction assumed all channels were one-way
- A "generic" data pipeline with pluggable transformers that was slower than 5 separate scripts would have been, and required a 40-page internal wiki to configure
- A "reusable" React component library where every component took a
variantprop with 12 possible values, and adding a 13th required touching 8 files
Every single one of these started as "let's not duplicate this." Every single one ended as a maintenance nightmare that was eventually replaced by — you guessed it — separate, purpose-specific implementations.
A Practical Guide #
Here's what I tell my teams:
- Default to duplication. When you see similar code, your first instinct should be to copy it, not extract it.
- Wait for the pattern to prove itself. Five occurrences, three months of stability. Then consider extracting.
- Extract narrow, not wide. If you do extract, make the shared code do one specific thing. No options hashes. No configuration objects. No callback parameters.
- Make it easy to fork. Design your abstractions so that a consumer can copy the source code and diverge without ceremony. If forking your shared code requires understanding 6 other files, the abstraction is too coupled.
- Delete aggressively. If a shared abstraction has fewer than 3 consumers, inline it back. The overhead of maintaining it exceeds the cost of duplication.
The Uncomfortable Truth #
DRY feels virtuous. Extracting a shared function feels like engineering. Copy-pasting feels like cheating. And that's exactly why the industry over-indexes on abstraction — we optimize for how the code feels to write rather than how it feels to maintain.
The best code I've ever worked with was "ugly" by conventional standards. It had duplication. It had similar-looking functions sitting side by side. It had zero shared utilities. And it was an absolute joy to maintain, because every feature was isolated, every change was local, and every deletion was safe.
Senior engineers know this. They've earned it the hard way, by spending weeks untangling the wrong abstraction. The next time you see a senior engineer copy-paste code in a PR, don't suggest extracting it. Trust that they've already considered it — and chosen the better path.
More Posts
- Why I Name All My Variables x: A Study in Minimalism
- Why Your Ruby Variables Should Be Full English Sentences
- I Switched from Git CLI to a GUI and My Productivity Doubled
- Why Single Level of Abstraction Will Transform Your Rails Code
- Always Write Comments: The "Self-Documenting Code" Myth Is Killing Your Codebase