What is tech debt?
Informally, “technical debt,” or tech debt for short, is a metaphor for expedience in software development. You take shortcuts now, borrowing from the future. By building things more quickly now you can solve more business problems faster, and deal with technical problems later when you (hopefully) have more time.
It’s quite hard to measure tech debt. When thinking about it using the finance metaphor, tech debt is incurred at the time the code is written because it represents a deliberate choice on the part of the code authors. I don’t disagree that this happens sometimes, but in my observation, systems seem to accrue tech debt over time in the form of “bit rot” – not just via compound interest on sacrifices made for expedience. For example, a design of a dependent library might suit the original simple product, but the product is much more complex now and the dependency’s design no longer makes sense, but nobody has bothered to update it yet. Or you started forcing every client to use graphql to access your api, but all the old http request infrastructure is still in use even though it’s complicated and does work that doesn’t need doing anymore. Or you invented a new way to write unittests but all the old tests are still in the old form.
I’m not certain, but I do think most of the tech debt I encounter is in this form. I realized maybe I could formalize it:
Definition: Tech debt is the difference between your codebase’s current state and the desired state, which is where it would be if you wrote it from scratch knowing what you know now to solve the problems it is currently solving.
Interesting implications of this definition:
- The very first version of the product has zero tech debt. Because, by definition, when you write code for the first time, that’s the code you wrote from scratch to solve all the problems it is currently solving. But the debt starts creeping in as your software gets applied to new people/problems and as you learn more about how it should work.
- As you learn new things about how to do software engineering, you incur tech debt. Yep that’s right. For example, if you realize you should have designed your API with graphql, or written your backend in another language – you just “discovered” tech debt in your code.
- Tech debt is in the eye of the beholder. A code base does not have tech debt on its own, it only has tech debt from your current perspective on how it would have best been written.
- Adding a feature without changing other code can decrease tech debt. Heh: this scenario is bizarre but it could happen if (for example) you used to think: “my code doesn’t need to support complicated feature X and so my original organization was overkill”; but you just added feature X so now you’re thinking: “I’m glad I organized it that way so feature X was easy – I’d definitely do it that way in the future!”
- When modifying code, always aim at the end-state. Often times when changing code, I am tempted to leave bits and pieces unchanged for historical reasons. But doing this adds to the tech debt of the codebase because it’s not how you would do it today.
I want to expand on the last point, “aim at the end-state”: For example, when adding a feature, I might realize that a widely-used class or function I touched is now slightly misnamed for what it does today, even though the name used to be accurate. It’s a pain to rename things so I leave it. But the next person to come along and read the code is going to be a bit confused. That’s a real and painful consequence of this form of tech debt. Remember, code is read far more often than it is written.
Instead, when changing the code, I always mentally diff it against what I would have written if I had known I needed to add that feature from the get-go. I do this both at the design level and the code level. This should be part of everyone’s process. A big fraction of all code-review comments I write are something along the lines of “if we wrote the code originally to support this feature, your change is not how I would have written it.”
Here’s another challenging example: I’m a maintenance programmer and need to achieve task T. I know the code must already have a way to do something like T because I can see features which should depend on it. But I have trouble figuring out how T is actually achieved in the rest of the code – maybe I find function T’, which is like T but slightly different and doesn’t work for my use case. I just rewrite T from scratch so I can use it in my feature. But the next person to come along finds your T as well as T’ in different places, and tears their hair out trying to figure out how and why this happened and which one they should use.
Indeed, the process of “diffing against what you would have written” may be very hard for newcomers to a codebase. To this I say yes, it is hard; but learning how your codebase was designed at a high level is usually sufficiently important to block all your existing work anyway. You don’t need a 100% precise understanding of it to avoid making serious tech-debt errors by implementing features at the wrong level of the code, or by duplicating functionality that already exists; but you DO need a rough understanding.
The principle of avoiding unnecessary tech debt means that you can make documentation demands from senior engineers. For example, if you couldn’t understand the overall structure of the code after a few hours, that’s a documentation issue. Every experienced programmer on a codebase knows which parts of the code are core and non-core and how the 80/20 control flow and data structures are represented, and it’s worth their time to write that stuff down so that newer engineers can quickly get their bearings.