The Best Code Is Often The Dumbest Code
Here's a contrarian take: duplication is often better than the wrong abstraction.
The conventional wisdom says "Don't Repeat Yourself." See similar code twice? Extract it. Notice a pattern? Abstract it. This advice is well-intentioned, but applied prematurely, it creates more problems than it solves.
Let me explain why the dumbest, most straightforward code is often the best code.
The Abstraction That Hurt
Consider a simple requirement: send different types of emails to users. Welcome emails, password resets, notifications.
Here's the straightforward approach:
function sendWelcomeEmail(user: User) {
const html = welcomeTemplate(user.name);
await emailClient.send(user.email, "Welcome!", html);
}
function sendPasswordReset(user: User, token: string) {
const html = resetTemplate(user.name, token);
await emailClient.send(user.email, "Reset your password", html);
}
function sendNotification(user: User, message: string) {
const html = notificationTemplate(user.name, message);
await emailClient.send(user.email, "Notification", html);
}And here's what happens when you abstract too early:
interface EmailStrategy<TContext extends BaseEmailContext> {
getTemplate(): Template<TContext>;
getSubject(context: TContext): string;
getRecipients(context: TContext): Recipient[];
beforeSend?(context: TContext): Promise<void>;
afterSend?(context: TContext, result: SendResult): Promise<void>;
}
abstract class BaseEmailHandler<
TStrategy extends EmailStrategy<TContext>,
TContext extends BaseEmailContext
> implements EmailDispatcher<TContext> {
// ... 200 lines of "flexibility"
}The abstracted version handles use cases that don't exist yet. It's "extensible" for requirements that may never come. And when requirements do come, they're rarely the ones you anticipated.
Meanwhile, the simple version is readable in 30 seconds and trivially modifiable.

Why We Abstract Too Early
The instinct to abstract comes from good places:
DRY is beaten into us. "Don't Repeat Yourself" is taught as a fundamental principle. We feel guilty when code looks similar.
We want to be "good architects." Abstractions feel professional. Design patterns feel sophisticated. Simple code feels amateur.
We're optimistic about future requirements. "We'll definitely need to support multiple notification channels later." Maybe. Maybe not.
But these instincts often lead us astray.
The Cost of Abstraction
Every abstraction has costs that aren't immediately visible:
Cognitive Load
Every layer of abstraction adds concepts to hold in your head. Simple functions are self-documenting. Abstract interfaces require understanding the abstraction first.
When you read sendWelcomeEmail(user), you understand it immediately. When you read emailDispatcher.dispatch(new WelcomeEmailStrategy(), userContext), you need to trace through multiple files to understand what actually happens.
The Indirection Tax
Want to modify how emails are sent? With simple functions, you find the function and change it. With an abstraction layer, you need to:
- Understand the abstraction's contract
- Find the right place to make the change
- Ensure you're not breaking other consumers
- Navigate through strategy classes, handlers, and factories
This tax is paid every single time anyone touches the code.
Premature Coupling
Here's the irony: abstractions meant to reduce coupling often increase it.
When you have two similar functions, they're independent. Change one, and the other doesn't care. But when you extract a shared abstraction, both depend on it. Change the abstraction, and you risk breaking both consumers.
When Duplication Is Actually Fine
Not all duplication is created equal.
Accidental duplication: Two pieces of code happen to look similar right now, but represent different concepts that will evolve independently.
function calculateShippingCost(order: Order): number {
return order.items.length * 5 + order.weight * 0.5;
}
function calculateStorageCost(inventory: Inventory): number {
return inventory.items.length * 5 + inventory.weight * 0.5;
}These look similar today. But shipping will need to account for distance, speed, and carrier rates. Storage will need duration, facility type, and insurance. If you merge them into a shared calculateCost function, you'll spend forever adding special cases.
Intentional duplication: Sometimes duplicating code is the right choice for independence.
// In the Orders module
function formatCurrency(amount: number): string {
return `$${amount.toFixed(2)}`;
}
// In the Reports module
function formatCurrency(amount: number): string {
return `$${amount.toFixed(2)}`;
}Yes, it's identical. But Orders and Reports are independent domains. If Reports needs to show currencies differently for international clients, you don't want that change to affect Orders.
The Rule of Three
Here's a heuristic that works well: don't abstract until you have three examples.
With one example, you have no idea what varies. With two, you might see a pattern, but it could be coincidental. With three, you can see what's genuinely common and what's incidentally similar.
// First email type: just write it
function sendWelcomeEmail(user: User) { ... }
// Second type: still just write it
function sendPasswordReset(user: User, token: string) { ... }
// Third type: now you have data
function sendOrderConfirmation(user: User, order: Order) { ... }
// NOW you can see the real pattern
// All three take a user, generate HTML from a template, and send
// The abstraction is obvious and grounded in realityAbstractions built from real examples fit reality. Abstractions built from imagination fit imagination.
Simple Code Is Easier to Change
Here's the key insight: simple code is easier to refactor into the right abstraction than wrong abstractions are to refactor into the right abstraction.
If you write three simple functions and later need to unify them:
- You can see all the code
- You can identify the real commonalities
- You refactor incrementally
If you build an elaborate abstraction and it doesn't fit the actual requirements:
- You need to understand the existing abstraction
- You need to migrate all consumers
- You're fighting sunk cost fallacy
The simplest implementation preserves optionality. You can always add abstraction later. Removing wrong abstraction is much harder.
Signs You've Over-Abstracted
Watch for these warning signs:
You can't explain the abstraction simply. Good abstractions have clear mental models. If it takes paragraphs to explain why the EmailStrategyContextFactoryProvider exists, something's wrong.
Changes require understanding the whole system. In well-designed code, most changes are local. If every change requires navigating through multiple layers of indirection, the abstractions aren't earning their complexity.
The abstraction handles speculative requirements. "We might need to..." is a red flag. If nobody's asked for it, you probably shouldn't build it.
Tests are harder than the code. If you need elaborate mocking setups to test simple behavior, the abstraction is likely too complex.
A Healthier Approach
Here's a practical workflow:
When writing new code:
- Write the simplest thing that works
- Accept some duplication
- Ship it
When adding similar functionality:
- Write it simply, don't immediately abstract
- Keep an eye on genuine patterns
- Refactor when patterns become clear
When you feel the urge to abstract:
- Do you have three real examples?
- Is this solving a problem you actually have?
- Will this be simpler than the current code?
- If any answer is "no," resist the urge
The Experienced Developer's Mindset
Junior developers are often excited by abstraction. Patterns! Architecture! Frameworks!
Senior developers are often suspicious of abstraction. What problem does this solve? What's the cost? Is simpler possible?
This isn't because senior developers are less creative. It's because they've seen abstractions become prisons. They've inherited codebases where "flexible" architecture made every change painful. They've learned that simple code ages better than clever code.
The goal isn't to avoid all abstraction. It's to abstract at the right time, when you have real data about what varies and what doesn't.
Conclusion
The best code is often the code that looks "too simple." Functions that do exactly what their name says. Logic that's readable without jumping through files. Duplication that preserves independence.
When you see similar code, pause before extracting. Ask yourself: is this truly the same concept, or does it just look similar right now? Will these evolve together, or independently?
And remember: you can always abstract later. You can't easily un-abstract.
The dumbest code is often the smartest choice.