Lessons from 20 Years of Software Engineering

by Daniel Reeves

Most of what they teach you in computer science will not prepare you for the job. That's not a knock on education — it's just that the curriculum optimizes for correctness proofs and Big-O notation, while the job optimizes for shipping things that don't catch fire at 2 a.m.

I've been writing software professionally since 2004. I've worked on embedded systems, enterprise SaaS, consumer apps, and a few things I'd rather not put on a résumé. The lessons from 20 years of software engineering that actually stuck weren't the ones I learned in a classroom or from a conference talk. They were the ones I learned by being wrong, repeatedly, in front of people whose opinions of me I cared about.

This isn't a list of tips. It's closer to a confession.

The Code Is Rarely the Hard Part

Early on, I thought the job was about writing elegant code. I spent hours on naming, on abstractions, on making sure every function did exactly one thing. That discipline isn't worthless — but I was optimizing for the wrong audience. I was writing code to impress other engineers, not to solve a problem for a user.

The hard part is almost always understanding what you're actually supposed to build. Requirements are written in a language that sounds like English but behaves like a foreign dialect. "The user should be able to filter results" sounds simple until you're in a room with four stakeholders who each have a different definition of "filter" and "results."

I started keeping a habit around 2011 that I'd recommend to anyone: before writing a single line of code, write down what done looks like. Not a spec — just a paragraph. "When this feature ships, a user can do X. They'll know it worked because Y." It forces the ambiguity out into the open where you can deal with it.

Complexity Is the Enemy, and You're Often the One Inviting It In

Every system I've worked on that became a nightmare to maintain had one thing in common: it was built by smart people who liked solving hard problems. The trap is that smart engineers tend to reach for sophisticated solutions before they've confirmed the simple one won't work.

I've introduced abstraction layers that made the codebase "cleaner" while making it impossible for a new hire to follow the execution path without a map and a flashlight. I've built configuration systems flexible enough to handle requirements that never materialized. I've used event-driven architectures in places where a function call would have been fine.

The rule I try to follow now: if you can't explain why you didn't use the boring solution, you probably should have used the boring solution. Boring code ships. Boring code is debuggable at 2 a.m. Boring code doesn't require a PhD to modify six months later.

This pairs with something I picked up from a senior engineer around 2008 who told me, "Write the code your replacement will thank you for." I thought it was a platitude. It isn't.

What You Think Is a Technical Problem Is Usually a People Problem

Around year five, I noticed a pattern. The projects that failed rarely failed because of technical reasons. They failed because two teams couldn't agree on an interface contract. They failed because a product manager was too afraid to tell a VP that the timeline was fiction. They failed because a lead engineer and a junior engineer weren't communicating, so the junior engineer just guessed — and guessed wrong for three weeks.

Software is a social activity. The code is the artifact, but the process is entirely human. I've seen technically mediocre teams ship great products because they communicated well and trusted each other. I've seen technically brilliant teams produce nothing usable because they were in a permanent cold war over architectural decisions.

If you're a few years into your career and you're frustrated that "soft skills" keep coming up in performance reviews, I'd take that seriously. Not because it's corporate-speak, but because the ability to run a good meeting, write a clear technical document, or have a difficult conversation without it turning into a referendum on someone's competence — those things have more impact on your output than your knowledge of design patterns.

Rewrites Are Almost Never the Answer

I've been part of three full system rewrites. One of them was justified. The other two were expensive acts of engineering ego that ultimately delivered a new system with different bugs and the same underlying design mistakes, because the team that built the original system also built the replacement.

The seductive thing about a rewrite is that it feels like progress. You're moving fast, you're writing clean code, you're not dragging around the technical debt of the old system. What you're actually doing is throwing away years of implicit knowledge encoded in the existing code — all the edge cases that got handled, all the weird customer behaviors that got accommodated, all the lessons that got learned the hard way and are now expressed as a guard clause in a function nobody remembers writing.

Martin Fowler's writing on strangler fig patterns is more useful here than almost anything else I've read. Incrementally replace the parts that hurt. Keep the system running. Don't bet the company on a big-bang migration.

The Tools Matter Less Than You Think, Until They Really Matter

I've had strong opinions about programming languages, frameworks, and editors for twenty years. Most of those opinions, in retrospect, were rationalizations for familiarity. I liked the tools I knew. I dressed that preference up as technical judgment.

That said: tooling does matter at the margins. Choosing a framework with a tiny community in 2015 meant that by 2019 you were maintaining a dependency nobody else cared about. Choosing a language your team doesn't know means a slower ramp-up time that compounds across every new hire. These aren't abstract concerns.

The honest framework I use now: pick the boring, well-supported option unless you have a specific, demonstrable reason not to. "It's more elegant" is not a demonstrable reason. "Our team already knows it" is. "It has a five-year support commitment from a major organization" is.

For what it's worth, the tools I've seen cause the most unnecessary pain aren't the ones with technical limitations — they're the ones with steep learning curves that teams adopt without a training plan. When adopting new tools, Docker for local development is a good example of something that requires proper setup guidance to avoid frustration. See also: why developer onboarding documentation matters more than most teams admit.

Lessons from 20 Years of Software Engineering, Distilled

If I had to compress everything into a short list — not tips, but genuine lessons that changed how I work:

  • Clarity before cleverness. Every time.
  • The spec is a starting point, not a contract. Talk to the people who'll use the thing.
  • Simple systems fail gracefully. Complex systems fail spectacularly. Choose accordingly.
  • Your technical reputation is built on reliability, not brilliance. Ship what you said you'd ship, when you said you'd ship it.
  • Invest in the people around you. The best career move I ever made was helping a junior engineer get promoted. The second best was asking a senior engineer to explain their reasoning instead of just accepting it.

There's a version of this essay I could write that's more optimistic — that frames twenty years as a journey of growth and mastery. That version would be easier to read and less useful. The truth is that most of the progress I've made came from recognizing mistakes I was repeating, not from breakthroughs.

If you're earlier in your career, the most valuable thing you can do is build a habit of honest retrospection. Not the sanitized kind you perform in a sprint ceremony. The kind where you sit with a notebook and ask yourself what you actually got wrong this week and why.

That habit, more than any framework or language or methodology, is what compounds over time.

Tomorrow: Pick one project you're currently on and write down, in a single paragraph, what done looks like. If you can't, that's the first problem to solve — not the code.