Balancing Responsibilities in Software Components
Responsibilities and it's derivative — abstractions — are one of the more ambiguous aspects of software quality, at least to me.
When I talk about software quality, I usually talk about quantifiable/measurable things like Complexity, length, naming conventions, formatting, duplications, and so on.
These measurable aspects can be checked for and guarded against by using static code analyzers. Unfortunately, there’s no code analyzer that can enforce rules for responsibility balancing and abstractions (yet?).
That’s something that only a human can detect since it’s not something tangible. And therefore that’s one of the aspects of software quality that is more often broken.
When I was first introduced to the concept of responsibilities in software components (after watching the great Robert C. Martin talk about the SOLID principles) I was a bit perplexed.
Up to that point, every software problem I encountered could have been solved using a set of clear and finite rules/steps. But when Robert C. Martin said that a component must have only one responsibility — I saw a principle that everyone can interpret differently.
Since then I’ve developed many software components and, naturally, had to deal with the question of responsibility many times over (and suffer from the consequences when I attempted to ignore it).
In this article, I would like to share my experience with you in hopes that it clears up the fog around the concept of responsibilities and abstraction.
Say we have a small program that can take user input, validate it based on some predefined validation function, and show a message when applicable.
We can consider this program as having 3 responsibilities:
1. Take user input
2. Validate that input
3. Display a message
Since these are seemingly small responsibilities, and the total code to cover these features is no more than 100 lines, we decide to develop a single component to do it all. We are a few lines below the 300 LOCs threshold configured in our static code analyzer, so, it’s all good!
The problem with such a component lies in the fact that it is now 3 times more likely to change, 3 times more complex, and therefore 3 times more likely to break. It becomes fragile and less readable.
If you have one component and three responsibilities, then you have uneven responsibility distribution. This is an issue that is more common to lower levels in the architecture (since it is tempting to try and solve all the problems of the program at the foundation level, and just reuse these superhero components everywhere) but can be found at higher levels as well.
Later we will see how the Input Validator can be changed to solve this responsibility issue.
This problem is similar to the problem of load balancing. Think about responsibilities as loads and components as resources. If the loads are distributed unevenly, overloading may occur (for example, when a server handles multiple requests in parallel).
In order to avoid that, you must have enough resources (components) and have even load distribution (even responsibilities per component)
However, there are two differences between the two problems. First, in load balancing the resources are limited, whereas in responsibility balancing you can have as many components as you want.
Second, while for load balancing an even distribution is enough, in the problem of responsibility balancing we must aspire to have as least responsibilities as possible per component. Furthermore, According to Martin’s SOLID principles, a component must only have a single responsibility!
The Single Responsibility Principle
The S in S.O.L.I.D stands for “Single Responsibility Principle”. Simply put, this means that a software component must be responsible for doing only one thing so that it will only have one reason to change. This is, in my view, the most important principle in the SOLID list.
This principle is important because the more likely a component is to change, to more likely it is to break. Furthermore, a component that is responsible for more than one thing, is harder to read and understand, and therefore is harder to maintain.
The gif above is a great illustration of what could go wrong when responsibilities are not properly distributed.
The next time new requirements are introduced, it can shake the program and throw it off balance. New requirements are in this case a bump in the road. It’s like your code is taking a hit.
The more balanced it is, the better it will be able to sustain changes. And since new requirements are, just like road bumps, part of the routine, we should prepare for them.
The only issue with this principle is that it’s not easy to determine what a single responsibility is.
In our Input Validator example, if we get a new requirement to change the message, the validation, or the way we read input, it will force us to update the Input Validator component. This component has 3 reasons to change, so it is more likely to hit that bump in the road and lose balance.
Symptoms of Responsibility Issues
It’s hard to define a set of dry rules for detecting responsibility issues since they are directly related to the business, so one must understand the business behind the software in order to understand what constitutes responsibility, and therefore what violates the single responsibility principle.
However, there are a few things that are easy to notice in the code (and that don’t require a deep understanding of the business), that should raise a red flag when you notice them:
- When a component has too many imports
If you have a component with more than 10 lines of imports at the beginning of the file, it usually means that it’s trying to do too much.
- When a component is too long
If a component has too many LOCs, it usually means that it has more than one responsibility. I like to set the threshold to 200 lines for a file, and 25–30 for a function.
- When you can’t explain what a component does in a single sentence
This one may not be 100% true all the time, but most often it is. If you need to explain to a fellow developer what a component does so that he can work on it, and you can’t do it in one simple sentence, then that component probably has more than one responsibility.
- When a component has too many props
This one is more relevant to UI development — if you have a component that has more than 10 props, then it’s probably responsible for too much (use children as much as possible!)
There can be exceptions to the above rules, but in my experience, components with the above characteristics tend to be responsible for too much, become fragile and break often, and are very hard to maintain.
How to Solve Responsibility Issues
Component responsibility issues are solved either by breaking them into smaller components or by creating abstraction layers.
We could’ve easily solved our responsibility issue in the Input Validator example by breaking our rather big component into 3 smaller components, each of which with a single responsibility.
However, what if we need to create different flavors of our input validation? For example, a name validator that receives only alphabetic characters and shows an error message for invalid characters, and an age validator that takes only integers within a certain range and displays a warning when the given number is outside the range.
One way to solve this is by introducing a type to our input validator:
This is a bad approach. It adds a new responsibility to the Input Validator, as it is now responsible for input types. Such a component can quickly grow into a huge mess as we introduce more types to it.
A better approach for solving the above issue is to use abstraction layers. Abstraction layers provide a way for organizing components from specific to comprehensive, while evenly dividing responsibilities between layers.
If you have 2 components A and B, and component A is using component B internally, then component A is at a higher abstraction layer than component B. The more complex your application is, the more abstraction layers you should have.
For example, we can create a name validator and an age validator, each of which uses the input validator internally:
At the highest level, you have a specific component (i.e. Name Validator) and as you go deeper into the program, you have more comprehensive components (i.e. Input Validator).
Note that lower-level components, despite being able to cater to multiple higher-level components, should not have more than one responsibility.
As I mentioned briefly earlier — it can be tempting to try and solve as many problems as possible at the lower levels of an application. We think that if we have all-capable lower-level components, we can simply reuse them across the whole app, and make our job easier.
However, doing so only adds responsibilities to the lower-levels of the program, making the foundation more fragile. Responsibilities must be evenly divided horizontally (between components in the same level) and vertically (between levels), otherwise, you will find yourself having to rewrite your key components (speaking from experience here…).