Stop Over-Engineering. Your Future Self Will Thank You.
by Eric Hanson, Backend Developer at Clean Systems Consulting
The Feeling Is the Problem
Over-engineering doesn't feel like over-engineering when you're doing it. It feels like being thorough. It feels like anticipating future needs. It feels like building something that won't embarrass you when requirements change. The psychological reward is real — you're not being lazy, you're being forward-thinking.
The problem is that "forward-thinking" in software often means "imagining problems you don't have yet and paying to solve them now." The future requirements you're protecting against frequently don't arrive. The architecture you built for scale you haven't reached becomes a source of friction for the scale you actually have.
What Over-Engineering Actually Costs
The costs are not abstract. They show up as concrete, measurable friction:
Onboarding time: A new engineer joining a team with a heavily abstracted codebase — plugin systems, event buses connecting components in the same service, factory hierarchies five levels deep — takes two to three times as long to become productive compared to a team with a straightforward implementation. That cost is paid every time someone joins.
Debugging surface area: Indirection is the enemy of debuggability. When an event passes through three dispatch layers before being handled, a bug in handling is harder to trace than one in a direct call. You pay this cost on every incident.
Change velocity: Counterintuitively, over-engineered systems are often harder to change, not easier. When every concern is abstracted into a generic framework, a specific change requires understanding the framework before modifying it. The flexibility you built for can become a cage.
Maintenance burden: Every layer of indirection you add is a layer someone has to understand and maintain. Interfaces without multiple implementations, abstract base classes with one concrete subclass, dependency injection containers for services with no test doubles — these are all maintenance overhead for flexibility that was never used.
The Pattern: Building for Hypothetical Requirements
The specific mechanism that creates over-engineering is designing for requirements you expect to have rather than requirements you have. This manifests in recognizable ways:
// "We might need to support multiple notification channels someday"
interface NotificationChannel { void send(Message m); }
class EmailChannel implements NotificationChannel { ... }
class SmsChannel implements NotificationChannel { ... }
class PushChannel implements NotificationChannel { ... }
class ChannelRegistry { ... }
class NotificationRouter { ... }
// What was actually needed this quarter:
emailService.send(user.getEmail(), subject, body);
The interface-plus-registry approach is not wrong in principle. It's wrong for a system that sends only email and has done so for eighteen months with no concrete plans to add SMS. When SMS is actually planned, adding the abstraction takes two days. Maintaining it speculatively for eighteen months costs every engineer on the team, continuously.
The YAGNI Principle Has Teeth
YAGNI — You Aren't Gonna Need It — is one of the most empirically supported ideas in Extreme Programming and one of the most frequently ignored. The argument is not that you should never think ahead. It's that speculative features and speculative abstractions have a high failure rate: the requirements you expected don't arrive, or they arrive in a different shape than you anticipated, and now you've built a solution that doesn't fit the actual problem.
The original XP research (Beck's Extreme Programming Explained, and subsequent retrospectives from teams at C3 and Chrysler) consistently found that speculative flexibility additions rarely matched actual future requirements. Building for what you actually need and extending when you actually need to extends — this pattern outperforms pre-built flexibility in most real project contexts.
What Appropriate Design Looks Like
The counter-position to over-engineering is not no design. It's design scaled to the problem you have.
A service that processes payments needs careful design around idempotency, retries, and failure handling — those are known hard problems with known failure modes. It does not need a plugin architecture for payment processors until you have a second payment processor and a concrete timeline for adding it.
A data model that will support an international rollout in Q3 should handle multiple currencies now. A data model where internationalization is "possible someday" should stay simple and be extended when "possible someday" has a date attached to it.
The practical question is: what do I know for certain versus what am I assuming? Build for the certain parts. Design seams for the uncertain parts — places where the code can be extended without being rewritten — but don't fill in those seams speculatively.
The Practical Takeaway
Before your next architectural decision, ask: if this requirement never changes, will the complexity I'm adding have been worth it? If the answer is no, build the simpler thing. Leave a comment that says "extend here if we add X" and move on. Future-you can build X when X is real.