The deadline was Friday. The feature was a user notification system — nothing glamorous, just "tell people when something happens in their account." I had three days.
I built it the fastest way I knew how: a single database table, a cron job that polled every minute, and a boolean flag called sent. It worked. We shipped on time. The product manager was happy. I felt clever.
Six months later, we needed push notifications. Then email digests. Then per-user preferences and quiet hours. The cron job was now firing thousands of queries a minute. The sent flag couldn't distinguish between "notified via email" and "notified via push." Every new requirement meant opening that original file and adding another if branch.
I had under-engineered it. Not because the first version was wrong for day one — it wasn't. But because I had treated "works today" as the finish line instead of the starting line.
That project taught me something I keep relearning: the gap between under-engineering and over-engineering isn't about intelligence. It's about timing. Ship too little foresight and you pay later. Ship too much and you pay now, sometimes for problems you never actually have.
When Simple Goes Too Simple
Under-engineering, as I've come to define it, is building the minimum that satisfies the ticket in front of you — and quietly assuming the future will look exactly like the present.
In the moment, it feels responsible. You're avoiding gold-plating. You're respecting the deadline. The code is readable because there's so little of it. Onboarding a new teammate takes an afternoon, not a week. For a prototype or an early MVP, that simplicity is a superpower.
I've shipped fast plenty of times and been glad I did. The notification system I described wasn't a failure on launch day. It was a success. The failure was pretending we'd never need to extend it.
That's where under-engineering turns. The shortcuts compound. The "we'll refactor later" folder fills up. You start working around your own code instead of with it. What was once easy to maintain becomes fragile — not because it's complex, but because every change touches something that was never meant to bend.
I've felt that particular dread: opening a file you wrote six months ago, recognizing your own handwriting in the comments, and realizing you boxed yourself in. The fix isn't a quick patch. It's a rewrite wearing a patch's disguise.
When Clever Goes Too Clever
If under-engineering is my tendency under pressure, over-engineering is my tendency when I finally have breathing room.
On a different project — same company, different team — we were building an internal analytics dashboard. No hard deadline this time. We had two weeks and a blank canvas. That should have been a gift. Instead, it became a trap.
We designed an event-sourcing pipeline for data that updated once an hour. We built a plugin architecture so "future teams" could add custom widgets without touching core code. We abstracted the chart layer twice — once for flexibility, once more for testability. The README for the plugin system was longer than the README for the product.
Nobody used the plugin system. The "future teams" were hypothetical. The event store added operational overhead for a dashboard that three people checked over coffee. We spent twelve days building infrastructure and two days building the actual dashboard.
That was over-engineering: solving problems we didn't have, for users who didn't exist, on a timeline that didn't require it.
I want to be fair to the other side, though, because over-engineering isn't always waste. Sometimes the extra structure saves you. When you're building payment flows, auth systems, or anything where failure has real cost, designing for robustness upfront isn't vanity — it's the job. The teams I've seen build reliable platforms often err on the side of too much structure, not too little.
The difference, I've learned, is whether the complexity is paying rent on a real problem or speculating on one you hope to have.
The Moment I Stopped Treating It as a Binary
For a long time, I thought the answer was a formula. Add abstraction when X. Skip it when Y. Keep a checklist.
It doesn't work that way.
What changed my thinking was a code review — not one I received, one I gave. A junior engineer submitted a pull request for a feature flag check. Three lines. Clean. Obvious. I almost commented "let's wrap this in a FeatureFlagService with a strategy pattern for different providers."
I caught myself. We had one provider. We had four flags. The "strategy pattern" would have been a monument to my seniority, not a service to the codebase.
That review stuck with me because it made the trade-off visible in a way years of blog posts hadn't. Under-engineering is often a sin of omission — you didn't think ahead. Over-engineering is often a sin of ego — you thought too far ahead, and the person benefiting was you, not the team.
Neither one announces itself. Under-engineering whispers "ship it." Over-engineering whispers "what if."
What I Do Differently Now
I don't have a framework poster on my wall. But I have habits that have saved me from both cliffs more than once.
Before I commit to an architecture, I try to name the actual constraint. Is this a prototype we'll throw away? A core system that'll live for years? A feature with a fixed launch date and an unknown future? The answer changes what "enough" means.
I build iteratively and treat the first version as a hypothesis, not a contract. Ship the cron job if the cron job is the right bet for this week — but leave a note in the PR description about what you'll need when notifications grow. Future you is a teammate too.
I pull people into decisions early. The best guardrail against over-engineering I've found is a product owner who will happily say "we don't need that yet." The best guardrail against under-engineering is an engineer who's lived through the last rewrite and can say "we'll need this in two months, build the hook now."
And I refactor on purpose, not on panic. Small, regular cleanup beats the quarterly "tech debt sprint" where everyone pretends three years of shortcuts can be undone in a week.
The Balance I'm Still Chasing
I'm not going to tell you I've figured it out. I still over-build when I'm excited about a new pattern. I still under-build when I'm tired and the ticket is due.
But the goal has gotten clearer: lean, pragmatic, and honest about what you're optimizing for. If you're optimizing for speed, own the debt you're taking on. If you're optimizing for longevity, own the time you're spending. The mistake isn't choosing one — it's choosing without knowing you chose.
That notification system eventually got rebuilt. It took a week, not a day, because we had to migrate live data while keeping alerts flowing. The analytics dashboard got simplified six months later when a new team inherited it and spent their first sprint deleting abstractions.
Both projects shipped. Both taught me something. That's the part I try to remember when I'm staring at a blank file and feeling the pull in one direction or the other.
The work isn't to always pick the perfect amount of engineering. The work is to notice which way you're leaning, and ask whether that's the trade-off you actually want to make.
Thanks for reading.