Jesse Conner

S is for Solid (And for Single Responsibility)

Posted: 2/29/2024

When I was learning how to code through school, books, and other means, I somehow managed to never be exposed to the world of coding philosophy. There were the obligatory principles of object-oriented programing that come with every single piece on Java (and the occasional piece on C#), but never any of the more abstract philosophies. Consequently, I was well into my professional coding career before I stumbled upon topics like SOLID and design patterns.

SOLID is an acronym comprising of five different best practices for writing readable and maintainable code. The S in SOLID stands for the single responsibility principle, and that is what I am going to touch on today.

To summarize the meaning of single responsibility for those not familiar, it specifies that

“A class should only have a single responsibility, that is, only changes to one part of the software’s specification should be able to affect the specification of the class.” -Robert C. Martin.

In more simple terms, each block of code should be responsible for exactly one part of the program’s functionality. The principle is not tied in any way to object oriented programing despite what the definition implies. It is just as relevant to functional programming, or anything else between the two paradigms.

When I first learned the concept, my reaction was somewhat lukewarm. I am big on organization in my day to day, so my code already reflected that. However, as time went on, I began to reflect on what exactly it meant to have a single responsibility. My focus turned towards the spirit of the principle, not the letter of the principle.

At first it seems straightforward, the code should only do one thing, what is so intricate about that? We need to start with the question “What is one thing?”. That question reveals the devil in the details. Is it simply anything that can be described without using the word “and”?

To demonstrate, take a to-do list app as an example (I mean, what else would we use for our tutorial of sorts?) It does one thing; it makes a list of to-do items. However, we can take it to another extreme. Possibly, determining whether the user has entered any text in a textbox for the purpose of enabling a submit button is a single responsibility. The answer needs to lie somewhere in between a single class/module for the entirety of the application and having a class with only one method that checks if the contents of the textbox are empty.

I wish that I could give you a clear-cut answer but as with nearly everything in coding, the answer is both subjective and nuanced. My general rule of thumb revolves around three different criteria. Complexity of the code, the overall quantity of code, and the benefits gained by modularity.

The first criteria that I use is straightforward. If breaking up the code into different functions or modules makes it easier to work with and understand, then it should be broken up. A great example of this is in a multipart condition. Instead of having three, four, or even more different segments in a conditional, split them out. Not only does this give an opportunity to reduce the cognitive overload of the line, and focus on the important details, but it also allows for labelling. Instead of something like

if(number % 2 == 0 && number > 10 && number < 100)

it can become

if(isEvenNumberInRange(number))

It is a great way to reduce the dependency on magic numbers, and add to the clarity of the code.

The next criteria that I use focuses on the sheer amount of code that is present. As a general guideline, a class, module, file, or however the code is broken up should stay under one hundred lines. There are exceptions to this. How much detail there is in one hundred lines can very greatly between languages. One hundred lines of HTML or CSS may not actually represent much detail or cognitive load. In those cases, it may not make sense to break those files up. Another case might be if the code represents a lot of data without attached functionality or a meaningful way to split that data up. It is not a glamorous part of development, but sometimes there are hundreds or even thousands of lines of data that could be relegated to a JSON file or a database some day, but for now, is sitting in the code base.

The quantity rule applies at a deeper level as well, albeit without a strict cap. I generally try to keep functions under ten lines. There are obvious exceptions such as when the code becomes more complicated when split up. I am also certainly not saying that functions should not be less than ten lines. It is more so that if the function is over ten lines it is likely doing more than it should. This rule also indirectly forces nesting to be avoided. I do not take a hardline approach to nesting like not mixing different levels of abstractions or being a never nester. My methodology is to avoid mixing, and nesting, but ultimately there are diminishing returns and time is money.

The last principle that I use in deciding what a single responsibility is revolves around the modularity, or at least the potential modularity of the code. To summarize, the more places that a block of code is used, the less it should do. This allows for more flexibility in where the code is used, less reason to change the code (Spoilers: that is half of the O in SOLID), and easier testing and debugging. To break down the three of those.

If a function capitalizes a string to title case, and trims the whitespace around it, it is not as widely usable as having separate functions for the two. It is very likely that consumers might want to use one of the two functionalities, not both. (Note: please do not make a function for the soul purpose to trimming the whitespace around a string. That is exactly what I was referring to in the section about the extremes).

The less likely you ever need to touch the code ever again the better. Even the smallest change can cause chaos downstream, and the less the code was doing before, the less likely you will need to change it. A nasty example of this that I encountered was changing a return type in a JavaScript library from null to undefined. Without knowing the silly intricacies of JavaScript, the change seems as innocent as they come. I will spare the non-JS developers the pain of the details, but those two values act mostly the same, and “mostly” is evil.

The third consideration surrounding modularity is the ability to test. I hope to write much more on testing in the future, but at its core, it is simple. The less code there is, the easier it is to write tests for. The less there is to test, the more likely it will get tested and get tested correctly. When functions are written as a simple “these one or two things go in and this one thing comes out” everyone wins.

My guidelines are exactly what they are, mine and guidelines. What works well may vary by project and will certainly vary by person or team. Even for myself, I am sure that there are dozens of edge cases that I did not cover here. In practice, single responsibility is one of those vague “you will know it when you see it” things, but those are the guidelines that I use as a starting point.