Technical debt is something that is present in all organizations. Incurring it can help free up the time of your dev team to work on high-priority features but if left unaddressed in the long run it can destroy you from the inside.
In it’s classical formulation, technical debt is described as intentionally taking shortcuts and cutting corners in code quality in order speed up development. While this can be valuable in the short term, over the long term it makes the overall codebase more bug-prone and future features slower to develop. Eventually you “pay back” the debt by cleaning up the original code.
The financial metaphor of debt is used as a mental model to understand the tradeoffs around an application’s code quality. You don’t have to be at 100% quality all the time (you can’t). Sometimes there are strategic reasons to let quality dip temporarily.
Unlike real debt, technical debt isn’t usually tracked and accounted. So how do you even know what parts of the code might need some attention?
Identifying and paying down technical debt is something we do a lot of at thoughtbot, both as part of our code audit service, and a day-to-day part of of our regular engagements.
The first, and perhaps obvious step, is to talk to your dev team. Ask them what parts of the code make it hard for them to add new features? What parts of the code are scary to change? They know the parts of your codebase where “here be dragons”. Newcomers to the team and junior developers can be particularly helpful here. What parts of the code are confusing? What areas are intimidating?
In addition to talking with your team, here are some common tech debt hotspots to look out for.
Check the state of your test suite. Do you have one? If so do you have a
reasonable amount of coverage (it doesn’t have to be 100%)? Do all the tests
currently pass on your
master branch? A test suite is critical to giving you
confidence in your code and the changes improvements you’re going to make.
Because this doesn’t have an immediate impact on client-facing behavior it’s one
of the first place where corners are cut.
Check the state of your project’s documentation. This can be code-level comments, setup instructions, or even outlines of your architecture. Sometimes, documentation is straight up absent. Other times it’s poorly written or hard to access and navigate. Ask yourself: How hard is it for teammates that recently joined the project to become productive?
Search for unnecessary sources of complexity in your application. The part of the code that handles your core business logic is a good place to start. In object-oriented systems, a lot of this complexity usually accretes around a single object colloquially known as the system’s god object.
Keep an eye open for anti-patterns specific to your paradigm, language, and framework. In a Rails app for example, you might look for heavy uses of metaprogramming, database calls from the view layer, or monkey-patching.
Finally, check to see your code dependencies are up to date. Procrastinating on language or framework major version upgrades can cost you a lot of extra development time in the long term. This is an underrated source source of tech debt so we’ll explore it in more depth in an upcoming section.
Make it work, make it right, make it fast
– Kent Beck
It’s common to ship the first version of a feature that uses a naive implementation with no performance improvements. Compromising on quality to ship faster, that sounds like technical debt! There’s a whole different set of tradeoffs however.
Unlike traditional tech debt, unoptimized code has no cost if it hasn’t become a bottleneck. This makes it more like a zero-interest loan that you only need to pay off if you become really successful. That’s a pretty good deal! Start simple and move quickly. You can always scale your app once it becomes popular.
Beware that performance and scaling changes often introduce new complexity into your application and can themselves be the cause of new technical debt.
Software that falls behind on updates progressively becomes harder to change and deploy. This is often referred to as bitrot. Software is not static nor does it inhabit a static world. Everything is constantly undergoing change, from the very languages the code is written in, to tools used to build the software, all the way to the platforms customers use to consume it.
It’s easy to procrastinate on updates. After all, why put in all that effort just to have code that does exactly the same thing as it already does today?
This is particularly pernicious because putting off updates is like the old proverb about boiling a frog. At first, day-to-day development will continue to feel mostly the same. As time goes on, you start encountering a plugin that is no longer compatible here, a library that no longer supports you there. It starts as a minor annoyance but keeps growing until eventually you can’t ignore it anymore. By now, doing the work of an upgrade will be very expensive.
It’s often tempting to put off the work of an upgrade by forking unsupported version of packages, frameworks, or even languages. This ends up being really costly as now you’re taking on even more maintenance burden. Even worse, now your code can start to diverge quite a bit from standard use to work with your custom versions of these dependencies. When you finally do upgrade it’s going to much more expensive.
Keeping with the metaphor of technical debt, this is like taking out a high-interest loan to prevent from defaulting on an existing debt.
At thoughtbot, we’ve worked on many upgrades for our clients over the years.
We’ve found that upgrading sooner is much cheaper than later. Additionally, work
on an upgrade doesn’t have to be all or nothing. It’s often possible to make
changes to your app now that will make it more forwards compatible with the
version you’re trying to upgrade to. This can be done by creating shims for new
APIs. Sometimes there’s even official tooling for this, such as Rails’
strong_parameters gem that allowed you to use the Rails 4 strong parameters
API in Rails 3 applications. Introducing this gem isn’t as much work as a full
upgrade would be. When the time comes and you’re ready to completely switch
over, the task is much easier because a lot of your code is already compatible.
You know you have technical debt. You’ve identified the areas that need improvement. Now how do you prioritize these changes?
Common financial advice is to pay off high-interest credit cards before trying to pay off your mortgage. The same applies to technical debt. What parts of your code will be most expensive to fix next week rather than today? Which fixes are blocking or slowing down high-priority feature work?
Comparing the complexity and churn (frequency of changes) of the files in a project is a technique popularized by Michael Feathers to identify high-priority improvement opportunities in your application. Not all complex files are important to change now. Low-quality code only imposes a cost when you need to modify, extend, or debug it. A subsystem that is very complex but never has to be modified has a very low “interest rate”. Instead you’ll want to focus on files that are both complex and change a lot. These are prime candidates for refactoring.
Once you know which improvements are high-priority, it’s time to schedule them.
Ideally, you can integrate code quality improvements into day-to-day work. At thoughtbot we will take time to improve any code that we’ve needed to modify as part of implementing a particular feature. This “slow and steady wins the race” approach encourages smaller, iterative changes to fixing technical debt. Even seemingly monolithic fixes like language or framework version upgrades can often be decomposed into smaller steps that can shipped without needing to do the full upgrade all at once.
Onboarding and project setup is a great example of this kind of day-to-day improvement approach. When a newcomer to the project tries to get set up, they will inevitably run into issues. Are there some setup steps that aren’t documented? Open a PR against the README. If the steps can be automated and added to the setup script, that’s even better!
Not all tech debt improvements can be folded into day-to-day work. Occasionally you will encounter large enough chunk of technical debt that may require a more targeted and sustained approach to paying it back. You might devote a week to cleaning up a particular part of the code. For bigger problems, you might create a small team to address them. This is the approach GitHub took when doing a massive Rails upgrade.
Make sure that these more sustained efforts have clearly defined goals like “get us onto the latest version of our language” or “break up a particular big gnarly part of our code into smaller components”. This tends to yield better results than more unfocused approaches like having every fifth sprint be designated a “fix technical debt” sprint.
It’s worth noting that there is no such thing as perfect code. You can’t put in a lot of work, eliminate all the tech debt, and then go back to feature work. Instead, you and your team will have to constantly evaluate whether a particular quality improvement is worth doing now. As every feature is developed, you will to decide when “good enough” is good enough.