“Leave That Bad Code Alone”: How to Fix Technical Debt Pragmatically

David Pinho
Sopra Steria NL Data & AI
6 min readAug 30, 2024

Software development is a constant dance between the ideal and the practical. We all want perfectly designed, elegant code that’s a breeze to maintain. The reality is often messier. Quick hacks, rushed features, and even simple changes in requirements create what we call technical debt.
Like financial debt, we accrue it over time due to a short-term need (like meeting a deadline) at the cost of long-term payment (slower development speed in the future).

It’s natural to think of our end goal as reaching a pristine state where code is almost perfect — we look at code all day, after all. Traditional wisdom places a lot of importance on refactoring and cleaning up bad code, but that’s not always the smartest strategy. Sometimes, the best thing you can do is leave that messy code alone, at least for a while. In this post, we’ll explore when to tackle technical debt, when to postpone it, and how to improve code quality pragmatically, aligning with business goals.

Avoiding technical debt in the first place

Say that you’re building a new system or component. It’s a big deal, something you know will be around for a while. In that situation, you should make your solution as high-quality as possible in a way that’s just right — not too flimsy, not overly complex.

To avoid under-engineering (building a house of cards), be aware of applying all the best practices:

  • Clean code: This is the “code hygiene” stuff, where your code uses good naming conventions and is organized in an easy-to-read way.
  • Design principles and patterns: Adding structure and flexibility to your code so your system can more easily adapt to changing circumstances.
  • Testing: Solid tests help you verify if your code is correct, especially when making changes.

These practices are like the grammar of coding — pretty straightforward once you learn them, for most situations. But it gets tricky here: these practices are seen as “best practices” because they solve particular common issues. If those issues don’t apply to your use case, you risk over-engineering your solution, which can sometimes introduce more technical debt than under-engineering.

To counteract that tendency, you often hear about the principle of YAGNI (“You Ain’t Gonna Need It”), which I always had some resistance to. Why? Because it’s a misnomer. It should be called YMPNIBYCACIL: “You might plausibly need it, but you can always change it later” (I guess sometimes you must make some sacrifices to find good acronyms). YMPNIBYCACIL is not a grand unified theory of when you’re over-engineering something since everything is context-dependent and hard to quantify. It’s instead a starting point. You begin by thinking of the simplest way of achieving something and comparing it against something “better”. These are the tell-tale signs that over-engineering is at play when arguing in favor of the ‘better’ solution:

  • Struggling to find reasons or giving many small, inconsequential reasons.
  • Getting defensive (“I have many years of experience doing this”).
  • Giving abstract reasons (“it will reduce the coupling and improve cohesion”) without tying that to a concrete benefit such as time saved or improved uptime.
  • Giving reasons that use the future tense (“We will need it for x y z”).

Some of these justifications can be completely valid — you might soon need to scale to ten times the number of users or ten times the data. But test those assumptions by asking concrete questions and then evaluate them against implementation efforts. Some examples:

  • Assume that our reasons for doing something more complex will be correct (like “we will need to scale to ten times the number of users in 6 months”), but we will go with the more straightforward approach anyway. What problems would that create?
  • If our assumptions are wrong and we also go with the more straightforward solution, what would we gain or lose from that?

Discuss benefits or drawbacks in very concrete terms, such as processing speed, development hours saved, uptime percentage, or revenue. That will help you move away from intuition-based discussions. But don’t solely reach out for technical reasons; social reasons could be just as valid. A team that is never given time to fix issues that come up might opt to build a more future-proof solution. Or that team might know that a future-proof solution will save them a lot of time, but they go with something ‘worse’ because a critical deadline is approaching.

More often than not, there will be no clear answer. In such a case, have a bias towards implementing the more straightforward solution: use fewer abstractions to keep things loosely coupled, thereby making the code more procedural (“Do X, then Y, then Z”) and with more duplication.

When does it make sense to fix technical debt?

You might’ve heard that there’s no fundamental tradeoff between software quality and development speed, as evidenced by DORA’s publications. This research was popularized by figures like Dave Farley and others in the opinionated-consultantosphere. The catch is that they’re looking at long-term aggregated data. You can’t use it as a justification to fix every piece of bad code you encounter.

Improving code takes time. In a short-lived project (or one that might be short-lived), you will never see the payoff of that investment.

Pragmatic strategies for improving code quality

“We all drive a used car”, as they say, so most of your projects will have some history, even if that history is related to past you. In these situations, don’t view technical debt as an all-or-nothing ordeal — you either pretend it doesn’t exist or stop delivering anything and just focus on technical debt. Instead, estimate the damage that it’s causing and then employ strategies that take an appropriate amount of time given the risk and benefit.
I think about solving technical debt with this laddered approach:

  1. The Boy Scout Rule: Leave the code you touch a bit cleaner than you found it. If you see a piece of code that’s not used, delete it; if you need to add an abstraction because of a new requirement, take the time to do it. Even minor improvements made consistently over time add up. This strategy has a great evolutionary quality: areas of the code that we touch more often will be improved the most over time. The inverse of this is the technical tornado: making the smallest change that will make something work. That tendency will ensure that the foundations of what you’re building don’t improve and bugs are never truly fixed, just patched up.
  2. Prevention: this is all about ensuring you can identify an issue more quickly next time. You could log things better, create alerts, or document where to look if a problem arises. You’re not fixing technical debt but ensuring it’s less costly.
  3. Improving DevEx and testing: Before you do a major refactor, look around for easy wins. If you shave a few minutes off of a lengthy build process or add tests to an essential part of your codebase, that will likely translate into hours of development time a month with little added risk. Start with things no one would object to — syntax checks, easy ways of reverting a deployment, validation of configuration files to avoid manual errors, and other similar things. For the nice-to-have things that require coordination (like adopting better linting standards), it’s a good idea to introduce changes slowly. You may keep these things as optional CI/CD checks so that your colleagues aren’t blocked by things that they are not used to. You could also, for example, enforce test coverage requirements that are slightly better than they are now.
  4. Strategic replacement: If a system is beyond repair, incrementally replace the most valuable or critical parts instead of a full-scale rewrite. Use static code analysis tools to prioritize parts of the codebase that are often modified or messy.
  5. Full-scale rewrite: When nothing else can be done, rewrite things from the ground up. Naive full-scale rewrites usually don’t work well if you have a system that’s been doing a lot and has reached a state of stability by being continuously battle-tested in production. It’s better to gradually ‘strangle’ the application by replacing its functionality piece by piece. To protect yourself against social and political risks, Adzic and Evans have the following advice: “Don’t give everyone 2% of what they need, instead give 2% of users everything they need” so that your changes are felt and provide tangible benefits. Without that, it’s too tempting to pull the rug from the rewrite project and continue using the old system.

In all of these situations, remember that you won’t have the luxury of stopping feature development to address technical debt. That’s always considered the nuclear option.

Conclusion

Technical debt is a natural consequence of software development. A pragmatic approach means choosing strategies that match the benefits that users will get from fixing technical debt. Sometimes, that means doing nothing about it.

--

--