Steve Jobs famously wore the same black turtleneck and jeans every day. When speaking to his biographer about his choice of clothing, he said:
“Do you know why I always wear a black turtleneck? It’s a uniform. I decided I had a lot of decisions to make every day, so I wanted to simplify my life.”
The attempt to simplify decisions doesn’t only apply to one’s choice in wardrobe. The price that teams of software developers pay due to unnecessary choices frequently goes overlooked because of their subtle effects. This price isn’t paid in dollars, pesos, or euros but rather in additional development time and bugs that would otherwise not be present.
Why More Choices is Not Always a Good Thing
Making a choice is like reaching a fork in the road. There is one of several paths to take, each sometimes leading to different destinations or the same destination by different routes. This applies not only to design decisions as a programmer but also to the execution paths that any given input into our system may follow.
Every possible branch that could be executed by our code is another piece of context we need to keep in mind as we work lest we introduce bugs. Successfully doing this requires a time commitment proportional to the number of logical branches possible, and failing to do so risks introducing bugs proportional to the number of branches (i.e. choices) that we didn’t consider.
It stands to reason, then, that just as software development benefits from our ability to manipulate the constructs within a system in ways that are likely to prove valuable for extending it, the inability to manipulate constructs in ways that are unlikely to prove valuable can also be beneficial.
This is one of the ways in which more wizened developers distinguish their code from others – by methodically and intentionally restricting developer’s choices in the system to only those which they are likely to actually use.
Below, I’ll outline a few areas in which I’ve seen teams allow themselves too much choice. This list is by no means exhaustive, and not every example applies to every system (some systems really do need certain freedoms). Hopefully, though, it will provide some food for thought on ways you could simplify your systems by removing capabilities from them.
Investing in Your Product’s Future: What is Technical Debt & When to Tolerate It Read post
Areas of Unhelpful Choice
Distinctions Without a Difference (i.e., Choices We Don’t Care About)
Instead of making a system more useful, we create unnecessary complexity when we have it act on or even have an awareness of information that it doesn’t need for its primary purpose.
For example, consider a system that has multiple avenues by which a file can arrive in the system from an outside source (e.g. incoming emails with attachments, files uploaded by users, files retrieved from external systems). Regardless of the source, each needs to be scanned for malware before being accepted by the system, after which it might be shown to users in any number of ways (e.g., displayed in-browser, shared to social media, downloaded with a link).
How do you design this system, then? Do you accept files via email, HTTP request, and any number of other potential input methods? How do you communicate the results of the malware scan to the system potentially using the file? Do you accept some sort of enumeration that tells you from which source the file comes and then send it back to the appropriate source along with the scan’s results so that it can proceed with next steps from there?
In my book, all of the above are poor ideas. The purpose of this system is to decide whether a file contains malware or not. Why does it care from where it came? Why does it care which system is potentially going to use it? It doesn’t.
A better design would be something that looks like the following:
- Choose a single method of accepting a scan. This might be some sort of queue, an HTTP Request, or even storing a file in a certain location.
- Define a response structure that includes the pertinent data, namely whether the file passed the scan or not. Once again, use a single method of communication to define a callback location for the results.
- Force the caller to implement the recipient side of the response communication (e.g., a webhook) according to your specifications.
This system limits its information to only that which it cares about. It doesn’t care about the source, so it doesn’t ask for it or know it. It doesn’t care about who needs the results, only where to send them, so it requests only that information in a well-defined form. We may have limited our choices for how other systems interact with this one, but the ensuing reduction in complexity means that we are better off for it.
Transaction Scripts (i.e., Everyone Makes Their Own Choice)
The entire point of abstractions is to reduce the amount of information a developer must hold in their head by removing implementation details and guaranteeing certain invariants across a well-defined class or function boundary instead.
One of the biggest differences between an inexperienced developer or someone who merely knows a programming language and a seasoned developer is the ability to determine the right level of abstraction for a system.
A common sight when analyzing a system written by the former is a series of transaction scripts attempting to handle complex logical operations. The failure of the authors to abstract complex logic in some fashion means that in all areas of code it is hard to guarantee that you aren’t breaking something because almost anything can modify any data at any level within the system. Reasoning about the system requires finding every location in the entire codebase that accesses a particular piece of data and understanding how it’s being used to prevent bugs. This approach is incredibly fault-prone and inevitably leads to slow development or loads of bugs over time… usually both.
Lest you think this is a one-sided argument for abstraction, it is worth noting that transaction scripts are sometimes the appropriate level of abstraction for certain simple systems or components of a system. Over-abstraction is also an unfortunate common problem typical of developers with slightly more experience, but who haven’t developed an understanding of the “why” behind the best practices they’ve been told. Not understanding the “why” of a thing renders you incapable of applying it correctly to situations outside of the examples you’ve already seen – something antithetical to any engineering discipline. The skill you need to cultivate is determining the appropriate level of abstraction for the system being designed.
Leaky Abstractions (i.e., Choosing Whether to Respect an Abstraction)
If the point of an abstraction is to limit what a developer needs to remember at any given time, an abstraction fails at its purpose if a developer needs to have an awareness of the underlying implementation behind the abstraction.
A common example of this is failing to abstract the data layer in a way that is based on how the data is accessed. A poorly implemented repository pattern is one example of a common culprit. The way this often looks is:
- Developers decide they need to wrap calls to the data layer (let’s say it’s a standard relational database.)
- Repository classes are created for each table with methods to perform the operations needed on each.
- It’s quickly noticed that “everything needs the same ‘get by id’ code,” so a common base repository with standard methods to perform basic operations (create, read, update, delete) is born.
- Inevitably, some needs bridge multiple “tables,” and using the repositories as constructed on a per-table basis would result in multiple round trips at which point one or more of the following happen:
- The underlying implementation, whether it’s an ORM or direct SQL, is used without a repository.
- The underlying implementation is indirectly exposed by adding methods to the relevant repositories that, instead of returning intact entities, return abstractions of partial queries such that the caller can self-construct the overall query.
- One of the repositories has a method added that performs this query across all items and is no longer restricted to operating solely on the one table.
In cases where this occurs, a developer looking to reason about what can change a certain piece of data must seek out not just the “repository” representing that table, but also all other code that accesses it in one of the ways outlined above.
When this happens, it’s usually not an isolated occurrence, but a widespread one leading to a breakdown of the abstraction. Developers can choose whether to use a repository or not based on implementation convenience and can no longer guarantee that the data layer is fully abstracted when making alterations. This means that easy replacement of the underlying data store, one of the purported advantages of this pattern, is no longer feasible.
We Might Need It Again (i.e., Choice to Reintroduce Old Code)
Of course, there are reasons for keeping unused code in place. Maybe it’s for a new feature that isn’t complete yet, but you expect it to be turned on soon. Maybe it’s a seasonal feature that you know will return on a regular basis. Maybe your business/marketing department is just very indecisive and will remove/re-add the same feature repeatedly (this may or may not be something I’ve seen firsthand).
However, keeping this unused code in place has a cost. Any maintenance or changes to the pieces of code this currently unused logic would otherwise interact with must still consider this logic or risk breaking things when the code is re-enabled. If the code is never re-enabled, or if the additional work required to maintain it while unused exceeds what it would take to re-author it once it is needed again (or what it would take to pull it out of git history and fix it up) then you are wasting developer time.
My Objects Can Be ¯\_(ツ)_/¯ (i.e., Choice of Type)
I realize this may come across as fighting words to some (especially with only a single paragraph as justification), but I’ve always been baffled by the appeal that some find in the additional freedom provided by dynamically typed languages. Especially with most languages now allowing you to infer types (e.g., “auto” in C++ or “var” in C# or Java, to name a few), the supposed benefits of dynamic typing don’t seem to justify the cost. After all, what is a dynamic i.e. runtime type other than a second way to potentially have any access operation fail (the first being “null”)? Is it really that much effort to explicitly define your expectations of a particular variable, whether through the language itself or markup (e.g., JSDoc)? So much effort that it’s worth all the “Uncaught TypeError” or similar errors you encounter? Count me as skeptical of that tradeoff, even in tiny systems.
Shared Persistence (i.e., Choice of Owner)
Sharing the same persistence between multiple systems makes it difficult to make changes without breaking one of the systems. Examples of this include but are not limited to:
- Having an asynchronous piece of code that lives in a different system/git repository, which needs your system’s data and directly reaches into the database to extract that data, rather than having the “owning” system pass it when the asynchronous command is enqueued.
- Allowing a business analytics/data team to directly read from your system’s database rather than constructing an API that returns exactly the data they need.
Sure, in these cases, allowing direct data access is easier than building an abstraction layer. After all, when that business team decides they need some other piece of information, now you must go modify the API to give it to them. It’s easier to just let them access the database and manage the queries themselves. They can choose whatever data they want to gather, even insert/modify data if the business decides it’s their responsibility. That works great until the first time you need to change something in the data layer. Was that table one they were managing again? Or maybe you were, but they access data from it? Which columns did they care about again? All the options you gave them become a burden, making alterations a pain.
In Summary
Choice is a powerful thing. We all want choices where they are useful, but, perversely, too much choice can paralyze us. Our conscious decision to limit our choices to only those points in which the choice is necessary and impactful is imperative to designing a successful system.
Having too much choice in areas where we have little to gain leads to wasted time, wasted effort, and a drain on the brainpower that we need to make the best decisions in areas where those choices actually matter.