The most important goal in designing software is understandability
Friday, January 19, 2024
When you're designing a piece of software, the single most important thing to design for is understandability. Security, performance, and correctness are all important, but they come after understandability.
Don't get me wrong, all of those are important. Software that isn't correct leads to expensive errors and frustrating experiences. Slow software can be unusable and frustrating. And insecure software, well, we have a moral and an economic imperative to ensure our software is secure. But understandability supersedes these.
It's most important, above these, because you cannot ensure any of these other design goals without understandability. It has to come first.
Misunderstood software produces defects
If software is misunderstood by its implementers and maintainers, then it will end up with defects. Major defects. These will come in many forms.
The most obvious one is with correctness. If you can't understand a given piece of code, you won't be able to read it and understand that it's doing and what it should be doing. Tests are not your salvation here, because (1) they can cover only limited surface area, and (2) they suffer the same problem: if you don't understand the software you likely don't understand it enough to test it well.
This then gets tangled up with security and performance requirements, too. If you don't understand the system, how are you going to make it secure? You can't understand your way into perfect security—it's a process and it's not something that's done. But if you start from not understanding your software, any hope of security is entirely lost. You'll miss some base requirements and introduce grievous simple security problems, not the kind that come from complex and subtle interactions between components.
And when you don't understand the software, then any change you make for performance gains is likely to break critical functionality or secure behavior in fundamental ways. Caching can leak information or mess up your business logic. Improving queries to solve a performance problem can produce major defects, or even end up causing regressions in performance1.
So if you don't understand the code, then it's a losing proposition to try to do anything with it: add a new feature, fix a bug, work on security.
"It's not me, it's you"
It's easy to feel shame or anxiety about not understanding the code. I carried that for a long time. There was a codebase I worked on in a previous role where I had no idea what it was doing. The backend was tough for me to understand, but I got it eventually. The frontend, no hope, I never made heads nor tails of it.
I assumed that I was just not a good enough engineer to understand our frontend code, and that there was something wrong with me.
Look, reader, I'm a principal engineer with over a decade of experience. I'm pretty good at my job: our tech leads and most senior engineers come to me for their hard problems, and I consistently debug things anywhere in our stack, including the frontend. If I felt I couldn't understand it, there were definitely others who also could not. And the fact that I blamed myself, with so much evidence that I was good at what I do... Turns out, the problem wasn't me, it was the code.
If you've felt similarly, know that you're not alone. And that it's not you. It's the code, the system around it. Tell that codebase "It's not me, it's you." Sometimes things are not understandable because you don't have expertise, but if you're generally experienced in the area that that code is in, it's quite probable that the problem is the codebase you're trying to work in.
How do we make it understandable?
So that just leaves the issue of how to make things understandable. There are a couple of general approaches. You can make the code itself inherently understandable, or you can give supporting documentation to aid in understanding it. Both are needed, and both have limits.
Make the code understandable
This is something we do routinely in software engineering, although it's easy to lose sight of it. There are a few key considerations I use when I do this:
- Remember your audience. What will other maintainers of this code reasonably be expected to know? If something isn't common knowledge in your team or your industry, then you should probably add some comments explaining it.
- Isolate the highest complexity. If something is complicated, it's worth pulling out into its own unit (a module, a function, whatever) so that you can define its interface and use it in a more fluently readable way, while also constraining that complexity for people who are trying to understand it later.
- Read it with fresh eyes. It's hard to evaluate your own code for readability. One trick is to put the code away for a few days, then read it yourself again after you've switched it all out of your working memory a day or two later. This will help you see things that might trip up a new reader.
- Integrate any code review comments. If someone asks how something works in a code review, do not just explain it to them in the comment. This means it's not clear to your reader who has all the context of your pull request, so it will not be clear to future readers who lack that context. Instead, update the code to be more clear (structurally or with comments) and then reply asking them if the change helps.
Add supporting documentation
Sometimes, the code will just be hard to understand. This is usually when there's a tension between requirements. Performance improvement will often result in less clear code, for example.
It's also hard (impossible?) to understand the full context of a codebase from the code by itself. As much as we talk about self-documenting code, the codebase doesn't contain the entire system.
So we need some supporting documentation. Here are some things that are very helpful for understanding a codebase.
- System architecture documentation. I like to keep system architecture diagrams, glossaries of key terms and services, and an explanation of the system as a whole, for the systems I work on. These do get out of date, but a one-month out of date document is better than none at all. For these, I keep a recurring calendar task to update it so that it never drifts too far out of date. For a growing company, onboarding is also a good time to make sure it's current.
- Architecture decision records and design reviews. We make a lot of decisions about architecture and code design as we go through our days as software engineers. When we make these decisions, that's a good time to write them down. This has three effects. The first is the clear one: it gives a record that we can use to understand later on what decision was made or why it was made. The second is less obvious, which is that by having to write our decision down we get clearer on it ourselves, and it forces us to try to explain it to someone else. This makes it so we have some focus on understandability. And the third is that this is a great place to insert a design review process, or at least broadcast these out, so you get feedback on clarity early in the process before writing code.
- Product requirement documents. These are super helpful for us to know what we're implementing and why it matters. But they're also very helpful later for understanding the code in its context. Was this weird behavior actually intended, or is it a bug? If you can go look at why it was implemented and the original requirements, that helps you answer that question.
- Code comments. These are the elephant in the room. They're helpful for explaining what a particular unit of code does and why it exists. These are very helpful in any case where something will be surprising, so they should be used for things that people will look at and puzzle over. They're also good for pointing to related documentation, otherwise it's hard to discover the related docs to understand the code when you're maintaining the code.
Those are just a few of the ways you can can add supporting documentation to help with understandability!
Gradual improvement works
Understandability is a fuzzy thing that's subjective. And it's not something that you can, or should, aim for perfection on. If you're working in a codebase today and it's hard to understand, the temptation can be to throw it away and start over. Sometimes that's merited, but often gradual improvement can be a good solution.
Each time you struggle to understand something, or you gain a better understanding through a task you work on, that's a good time to add documentation or improve the code to make it more understandable! Each small improvement will help you in the future and help your teammates. And each time you improve it, you lead by example and show people that this can and should be done.
I once got paged because a query change to reduce load on the performance ended up making an infinitely growing queue. That was a fun one. It wasn't too hard to resolve and cleared itself in hours after we fixed it, but it's a perfect example of this at play, because the DB code was not understood and it was not clear that it was not understood, which is the worst failure mode.
If this post was enjoyable or useful for you, please share it! If you have comments, questions, or feedback, you can email my personal email. To get new posts and support my work, subscribe to the newsletter. There is also an RSS feed.
Want to become a better programmer? Join the Recurse Center!