Over-engineering makes you feel smart, but here comes the technical debt

One of the struggles I have come to know intimately over a career as a programmer is the tendency to over-engineer solutions. You constantly walk a knife edge trying to write good clean code and also deliver on time, and I believe the number one reason we miss our own deadlines is over-engineering.
As a developer you get given tasks that shift and change all the time, so you tend to fall into one of two camps:
- Perfectionist: abstract for as many possibilities as possible. “Let me show you my codez…”
- Hacker: throw it together quickly and have no qualms about a bit of duplication, or even throwing things out and starting again. “Does it work? Yes? Why are you talking to me then?”
Throughout a project you typically get to sit in both camps. You kind of have to pick the things you want to invest your time in before the money runs out and you need to haul ass to deliver. The good engineers are the people who know how to balance these two things and hit a sweet spot: maintainable, documented, fairly clean code without the complexity, heavy coupling or spaghetti that either persona produces when left alone too long.
For this post I’m going to focus on the Perfectionist tendency to over-engineer, because its sweet siren song has claimed us all at one point or another (put your hand up if you have ever considered writing your own framework). Here are some tools to identify when you’re doing it, to decide whether it’s a good idea or not, and to see the true cost it takes on your project and your company.
I started writing this in 2020 and never finished it. Coming back to it now, with AI writing a good chunk of the code I ship, every point below is more true, not less. More on that at the end.
Telltale signs you’re over-engineering a problem
- You can’t explain the problem in a few sentences to someone else and have them understand you.
- You can’t explain how you’re solving it in a few sentences either. If the explanation needs a diagram, the code needs a rethink.
- Simple changes take a long time. Frameworks fall into this camp a lot, usually around forms. It’s retrospective, but it’s the easiest sign to spot because it will make you angry. Add a 10x multiplier if the developer was not you.
- You’re spending a lot of time on edge cases that haven’t arisen yet and aren’t likely to. But it would be so much sweeter if they did…
- Your data flows are getting very abstract and you’re struggling to hold the flow in your head. You’re jumping between files constantly and can’t find the one you’re looking for.
- It takes longer than a morning to pick it up on Monday. We all forget things over the weekend, don’t beat yourself up just yet, but you should be well back into it within an hour or two.
- You have to create a lot of supporting functions for a single task. Sure, it will save time in the long run, you tell yourself. But where do you stop?
- You come back to a section and can’t follow the threads of logic. The classic culprits are events, excessive shorthand, bad naming and black-box magic functions. There’s a place for all of these except bad naming, but without clear commenting or documentation you have just made something somebody will either replace or spend a very long and painful time unravelling. Chances are it will be you, so put the time in now and thank yourself later.
- It never quite works no matter how much you patch on. You get this a lot retrofitting solutions into legacy codebases. I was dealing with one when I first drafted this. There was no money to rebuild it, so what do you do?
If you ticked three or more of these, keep reading.
Ok, yes, I’m over-cooking problem xyz. Do I push on or start again?
This is probably the hardest thing to do. You have already invested X amount of time and effort, or there’s no money to fix it, so how do you justify deleting code and starting again?
The hard answer is that sometimes you can’t fix the problem and sometimes you can’t afford to start again. So take a breath, put your ego to one side and look at your solution objectively.
Step 1: Write your own list of problems
Go through the application and hunt for every problem you can find. Jot them down, and next to each one note how much of the codebase you have to touch to get the job done. That second column is the important one. A problem that lives in one file is a bug. A problem that lives in nine files is architecture.
Once you have the list, estimate how long it would take to write the thing from scratch. Unless you are very good at estimates, double it, and you’ll be getting closer to reality.
Step 2: Estimate the cost of living with it
Now do the other side of the ledger. For each problem on the list, estimate how many hours a month it costs you right now: time spent working around it, time spent explaining it to someone new, time spent fixing the bugs it breeds. Be honest. Include the Monday mornings.
Multiply by however long the thing has to live. If the project is being replaced next year, that’s twelve months. If it’s your company’s core product, that’s forever, and forever is where over-engineering really gets expensive.
Step 3: Compare, then decide out loud
You now have two numbers: the cost to rebuild, and the cost to keep. Most of the time they make the decision for you, and most of the time the answer is somewhere in the middle: don’t rebuild the whole thing, rebuild the one abstraction that’s generating most of the list.
Whichever way you go, write the decision down and say it to someone. “We’re keeping this because a rewrite costs six weeks and this costs us two days a month” is a sentence you can revisit in a year. “We’ll fix it later” is not. Half of technical debt isn’t bad code, it’s undocumented decisions, and the interest compounds every time a new person has to rediscover why things are the way they are.
What is the true cost of this problem?
Here’s the bit I couldn’t write in 2020, because I hadn’t sat on the other side of the table yet.
When you over-engineer, you don’t pay once. You pay in four currencies, and only one of them is yours.
Your time. The obvious one, and the cheapest. The weekend you lost to the abstraction, the Monday mornings, the edge-case handling for the edge case that never came. You feel this one, so you can at least manage it.
Everyone else’s time. Every developer who touches that code after you pays a learning tax. Over-engineered code has a higher tax than bad code, because bad code is at least honest about what it does. A clever abstraction looks like it does one thing and actually does five, and the new person finds out about the other four in production. Multiply the tax by every hire, every contractor, every future you.
The business’s options. This is the expensive one. Over-engineering makes change slow, and a business that can’t change its software quickly can’t change its mind about its product. Every “that’ll take three weeks because of how the form layer works” is a feature not shipped, a customer not won, a competitor not answered. Nobody puts this on a timesheet, which is why it’s the cost that actually kills companies.
Trust. When the simple things take a long time, the people who pay for the software stop believing the estimates. Once that trust is gone, every piece of work gets padded, questioned and renegotiated, and now the whole team is slower, not just the code.
Add those up and the real cost of over-engineering is never the hours you spent building it. It’s the hours everyone else spends living in it, and the things the business couldn’t do while they were.
A small example, because the big ones are too embarrassing
Abbreviations to reduce typing. Consider the following SCSS:
w {
&-full {
width: 100%;
}
&-half {
width: 50%;
}
}
It seems like a handy shortcut to only have to type w-{something} for width classes. And it is, for you, the creator, because you know you did that. For someone new to the codebase, they see this:
<div class="w-half">
...
</div>
And then they go looking for .w-half in the stylesheet, and it isn’t there, because it’s never written down anywhere as a complete string. They search, they find nothing, they ask you, or worse, they write a second .w-half of their own and now there are two. You saved yourself eleven keystrokes and cost the next person twenty minutes and a bug.
That’s a tiny one. The pattern is the same at every scale: the shortcut only pays off for the person who already knows it exists. Event buses that nobody can trace. Base classes with one subclass. A “generic” repository layer that wraps a single database call. Every one of them made someone feel smart on the day, and every one of them is an invoice addressed to a stranger.
The rule I use now
Over the years this has boiled down to one test I run before adding any layer, method, abstraction or helper. A layer earns its place when more than one caller uses it and it encapsulates more than a single call or a single conditional. Both. If it fails either test, I call the underlying thing directly at the call site and move on.
A method that only forwards to a framework function, with one caller, is noise. Before adding a method, name the decision it makes. If there isn’t one, don’t add it. The exception is overriding a hook the framework itself calls, which isn’t a wrapper, it’s an interface, so keep it and say in the docblock who calls it.
Favour the shortest call chain that works. You can always add a layer later, when the second caller actually turns up. You can rarely remove one once three people have built on it.
Why this matters more in 2026
I’ll finish with the reason I came back to this draft. Most of the code I ship now is written with an AI pair, and AI assistants are the most enthusiastic Perfectionists I have ever worked with. Ask for a function and you get a function, an interface, a factory, a config object and three edge-case handlers for inputs the function will never receive. It all looks tidy. It all passes. And it’s over-engineering at a speed no human could manage, because the cost of writing the extra layer has dropped to nothing while the cost of living with it hasn’t moved at all.
So the old discipline matters more, not less. The nine signs above still work on generated code. The two-sided ledger still works. And the shortest-call-chain rule is now the first thing in my instructions to the assistant, because if you don’t tell it where to stop, it won’t.
Take away
- Over-engineering is the Perfectionist’s failure mode, and it’s the number one reason deadlines slip.
- You can spot it: can’t explain it, simple changes are slow, you’re handling the future instead of the present, Mondays hurt.
- Push on or restart is a numbers question. Write the problem list, cost both sides, decide out loud.
- The true cost is paid mostly by other people and by the business’s ability to change, not by you.
- A layer earns its place when it has more than one caller and does more than forward a single call. Otherwise, call the thing directly.
- If an AI is writing your code, that rule goes in the prompt.