Hey! You can find me on Mastodon, Bluesky, or Twitter!
A common theme in my circles is that of complexity, and that the code that powers software shall be “simple.” I share the general sentiment. This topic has recently become very personal again as I found myself debugging a failure on Windows running in Wine inside a Linux docker container virtualized through WSL on another Windows machine I was remoting into (sigh), yet on the other hand I was willingly signing up to a serverless backend provider so I can write more Javascript. Clearly, I am finding myself on both sides of the debate here. While I do not have any answers, I still want to share some thoughts about “simplicity.”
In a previous job, I encountered two notions of simplicity:
- “This API should be simple to use for users.” Simplicity here usually alluded to the fact that the target audience is less technical than the implementor. The point of the API is to turn something that is complex into something that looks simple. Simplicity means that when you pull the lever, you get the desired result without really having to be concerned about any of the many in-between steps.
- “This system should be as simple as possible.” Simplicity here means that the mental burden of reasoning about the entire system should be minimal and that the code should be as close to what the machine is doing as possible. Simplicity means that when you pull a lever, you can reasonably predict and understand what is going to happen in order to reach your desired result.
These two notions frequently clashed, for example when it turns out that in order to provide a “simple API” you need to bring in heavy machinery that turn the backend into a decidedly non-simple system. For example, at some point we shipped a tool that was post-processing the output of C# compilation (IL code) to translate what looked like a lambda into a loop body inside of a struct running as an asynchronous operation (and all that to stay in unmanaged code for performance reasons). The API was arguably “simple” but the implementation was anything but. Later iterations ditched the IL postprocessing in favor of C# source generators (a means to perform codegen at compile time) but still kept crucial steps like patching user code to call some generated code instead of sticking to their original implementation.
What the “simple” API did was all but “simple” to understand. This was not just because there were plenty of edge-cases, but also because this was part of the point: there was just a lot of stuff happening, and the point of the API was to hide that. Long discussions at some point lead at least some to more carefully distinguish between “easy to use” (instead of “simple API”) and “simple to understand.” I am going to use “simple”/”complex” only for the second meaning from here on.
While I am not convinced that there is a dichotomty between “easy to use” and “simple to understand”, I do think that solutions with an emphasis on “ease of use” often come about because the problem space is no longer simple to understand1. As a concrete example, at some place the complexity of correctly orchestrating the startup of multiple processes has become so complicated that it was decided that we should have a tool for that instead: Fill in all the different command line arguments automatically, expose a few checkboxes and input fields, done. The tool reduced the error rate and covers the common use-cases; it is definitely a value-add, a clear success. However, the tool was only necessary because a lot of complexity already existed in the first place. The tool did nothing to reduce the complex mess below, it just merely attempted to hide it really well, and did so successfully. But inevitably, over time the “easy to use” solution then needs to grapple with the fact that the original problem space became complicated for a reason, and then it might become just as complicated when slowly every option of the messy lower layers slowly bubbles up to the upper layer while changes further down are now harder because of this new dependent in an upper layer.
This is not to say that “easy to use” solutions on top of complex systems are a bad idea. They can be exactly what you need, they can be very economical, they can be the right thing for 95% of your users, they can enable new users to use your system at all. I personally certainly benefit from easy to use solutions in spaces that are unfamiliar to me, see my opening comment! But these solutions do not reduce total complexity. Afterall, that is why we pay other companies to solve problems for us with an easy to use interface: so we do not need to delve into the complexities ourselves2. You always need someone that actively maintains the easy-to-use solution and then debugs all the failure cases for which you inevitably have to understand the lower layers.
Easy-to-use solutions are hence not a simplification. I would go further and claim that one should be skeptical of any attempt to reduce complexity by adding something. When you add anything at all, you are first and foremost increasing total complexity. It is just a tradeoff: We accept an increase in total complexity because it might be economical. For example, compilers are complex beasts, but they are undoubtedly useful. You get to ignore all the complexity inherent to them, until you run into internal compiler errors or need to understand some details about why a particular optimization did not happen. The frequency of that happening influences how much complexity you are willing to endure before you ditch the tool, and for most people that frequency is low. Or you accept that adding a new feature comes with new code and complexity, but that feature is valuable to your users.
An obvious question then is how to reduce complexity. Clearly, some complexity is inherent to solving problems and probably cannot be reduced: If your problem consists of 30 wholly separate, different cases, then you are in all likelihood going to distinguish between 30 different cases somewhere in your program. In other words, your solution cannot be simpler than the problem you are trying to solve, and the real world is often messy.
However, in practice I experience that solutions are often much more complex than what the problem would require necessarily3. For example, someone might be using a 3rd party game engine instead of building their own tools. This can be a great choice for many reasons (and my salary depends on people making that choice!). However, the complexity of rendering, input handling, etc. still exists, it is just well hidden, and every part is likely more complicated than it would have to be to serve just your single game. There are also plenty of other reasons for why software is more complex than necessary, e.g. changing requirements over time, or someone inventend additional constraints for their software (like “all code needs to be reusable”, “programming is text compression and no line should be repeated”, or “the codebase must model the real world”), or someone pulled in unnecessary dependencies into their solution (“I need this thing so I am going to inherit from it and its 4 base classes”).
As you can probably guess from the wording, I think that these last things above are cases where complexity could be avoided. Write reusable code when it’s a requirement, because reusable code is just harder to get right. Share code only when it is unchanging, not just when it is similar across multiple sites. Sharing code has a cost. Use the smallest scope tool you can afford. Use dumb tools. Do not model the real world. Only solve problems you actually have4.
A quote that often rings true for me is this one by Mike Acton: “Solving problems you do not have will create new problems that you then very much do have.” (This regularly pops up in my head when I find myself deleting the last 2 hours of code because I nerdsniped myself.) I find that data-oriented design as coined by Mike in particular may be interpreted as an attempt to reduce unnecessary complexity as much as possible: Computer programs transform inputs to outputs, and this input-output relation is “the problem” you are trying to solve. If you study your program, you can only reduce unnecessary complexity that you introduced into it in the first place. But if you want to understand the necessary complexity, the absolute minimum, then you need to study the problem, that raw relation, the data itself.
-
I will not claim that all simple solutions are easy to use. I am actually not sure about this at all. Though if your solution is simple, it’s probably more likely to also be easy to use. ↩
-
Maybe whether you choose a easy-to-use vs. a simple API design says something about your relationship to your user: Are you their support team and all their issues should go into a Jira ticket for you, or is the user expected to understand the entire thing? The latter might be the case because your users are just as skilled as you, or because you cannot offer support. ↩
-
I want to briefly relate this to Fred Brooks paper “No Silver Bullet - Essence and Accidents of Software Engineering”, since he calls out complexity as an essential difficulty. He establishes the notion of essential and accidental difficulties: “I divide them into essence, the difficulties inherent in the nature of the software, and accidents, those difficulties which today attend its production but which are not inherent.” - I do not disagree with Brooks: Brooks talks about software development process, and grappling with complexity is an essentialy difficulty of that process. I find it tempting to think of difficulty as “accidental” and “essential” as well, but Brooks does not actually use these terms for complexity. In fact, all the breakthroughs in reducing accidental difficulties that he mentions arguably increase complexity to gain ease of use. ↩
-
“Software architecture” is sort of the opposite: There is a large problem that no single group has, everyone locally would be better off if they did not have to adhere to what the architects decided, but globally it would be a disaster to not have an agreed upon architecture. Architecture is hard for many reasons, not least of which because people like me exist. ↩