Written in partnership with Igor Escodro.
As developers, we are constantly trying to implement the best solutions for our customers, considering the requirements and constraints we are given. However, it is not unheard-of for stakeholders to change the requirements midway through the project on a whim, rendering the current implementation useless.
There are also some cases where we have to work on an unstable code base, and requirements continue to come, each time harder and harder to fit into the current mess. Bugs arise every day and we struggle to keep the project moving.
In cases like these, we often wonder what we could have done better in order to protect the project (and ourselves). Although there is no silver bullet in this situation, improving the project design and architecture would probably improve the quality and maintainability of the project. One way to make these improvements is using SOLID principles.
SOLID is a set of principles and good practices for improving software design and architecture, making them easier to maintain, scale and test. The name SOLID is a mnemonic acronym of the principles introduced by Robert “Uncle Bob” Martin: Single Responsibility; Open Closed; Liskov Substitution; Interface Segregation; and Dependency Inversion. Although the concepts were introduced in his paper “Design Principles and Design Patterns” in 2000, the acronym itself was suggested by Michael Feathers sometime later.
In his paper, Uncle Bob lists four “bad smells” that good practices should be able to prevent:
- Rigidity: Simple changes to a single module of a project result in changing several classes of other modules, consuming a large amount of time;
- Fragility: Changing a single point of the code can cause countless side effects;
- Immobility: It is way too difficult to reuse code within the project, as the code to be reused has too many dependencies;
- Viscosity: There are two types of viscosity:
- Design: Making changes that preserve the software design are considerably more difficult than making workarounds;
- Environment: When the build environment is too slow, and it causes the developers to prefer solutions that require less resources from the environment (e.g.: compile time), but these solutions may break the design.
He also mentions that it is the developers’ responsibility to prepare the architecture to deal with changing requirements, and that it should be done with good dependency management.
In this article, we present each of the SOLID principles in more details, explaining their definitions and giving some examples of how they could be applied in real world scenarios.
Single Responsibility Principle
According to the Single Responsibility Principle, a component should have a single reason to change. This is extremely similar to the OO concept of cohesion. A class that has many reasons to change is not cohesive, whereas a class with good cohesion is likely to have few reasons to change.
Consider, for example, this code for a class responsible for processing a purchase order:
The PurchaseController is performing all the operations required to process the order, including validation and sending a confirmation email. This class is clearly doing way too much. If there were a change in the validation rules, the controller would need to be changed. If we were to add new billing steps, we would need to update the controller as well.
A better approach would be to have all the different responsibilities split into different classes, as in the following example:
There are many advantages to this approach:
- It improves the class cohesion, thus making it easier to find any particular logic;
- It makes it easier to reuse the code. For example, the mailSender class could be used in other parts of the project to send different emails;
- Since it is likely that you will only need to update a few classes to add a new feature, it is easier to have multiple people working on the same project without having conflict issues;
- As every bit of related logic is concentrated in specific classes, it is easier to debug any issues. For example, if the order validation is not correct, there probably is an issue within the OrderValidator class.
One example we could analyze in the Android Framework is in the RecyclerView.Adapter class. When you inherit from it, there are some methods you are required to implement:
The Adapter has to know how to inflate a View, create a ViewHolder, define the number of elements that will be displayed, and to update the data whenever the user scrolls the RecyclerView — possibly performing some kind of processing to determine the new data.
Therefore, we can conclude that this adapter could be split into more classes. That brings us to another point. The utmost responsibility of the Adapter is to be a bridge between a view that displays multiple elements and a generic data source. Having multiple classes performing smaller parts of the job would make the adapter harder to understand and use, as you probably would need to implement several classes to make it work.
As several concepts in software development, we need to be careful to not be overly preoccupied with the Single Responsibility Principle and make the project extremely complex a result.
Open Closed Principle
The idea of the Open Close Principle is that your classes should be open for extension, but closed for modification. Although this might sound a bit confusing, this example should help:
The listResponsibilities function returns a list of responsibilities of an employee. However, if a new employee role were to be added, it would require changes in the function. Although this is a valid solution, it does not scale well. Imagine there were 100 different EmployeeRoles. It would cause this function to increase hugely, making it a lot more difficult to be maintained. Moreover, what if the list of responsibilities of a tester changed? It would require going through the function body and changing the specific line, which is error prone.
So, how could we improve this function? Polymorphism comes to the rescue! By defining a contract, which could be either an abstract class or interface in this case, we can create different employee classes that have different responsibilities.
Notice how the body of the listReponsibilities function is much cleaner. Now, if we need to add a new EmployeeRole, we only need to add a new class extending Employee (or implementing an interface, depending on the solution) and implementing the getResponsibilities method. The problem of changing the responsibilities of an EmployeeRole is also simplified, as it could be solved by changing the body of a specific class, instead of changing the listResponsibilites function.
The code that lists the Employees logic is now independent of changes in the Employees structure. In general, we can say that the Open Closed Principle minimizes the impact that requirement changes might cause. Given that, we can say that the final goal of this principle is allowing new features to be added to the codebase without breaking code that is currently working.
We can see an application of the Open Closed Principle in the Android framework. In the past, the TextView class had over 20 direct and indirect subclasses. Currently, in Android 10, it has “only” 14.
Other problems aside, the TextView is a good example because Android handles any View that contains text as a TextView, which means that the actual instance of the View does not matter. In other words, it is possible to extend the behavior of a view containing text by subclassing TextView, while keeping the original TextView intact.
Although originally intended to be applied only in class design, this principle is also a good tool for determining dependencies between modules within the same project. The domain — the module containing the business logic of your project — should not depend on any other modules. Therefore, when communicating with other modules, such as the repository or the presentation layers of the application, the domain should declare a communication interface so that another module will provide an implementation.
Liskov Substitution Principle
The Liskov Substitution Principle was named after its creator, Barbara Liskov, who states that “objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program” (Martin, R. C. 2000). You will probably need to read it a few times to understand it, but the principle itself is easy to understand.
Let us start with a simple abstract class for employees, where one of the functions requests paid vacations:
This abstract class can be easily used to represent all the employees of the company working full-time and interns:
Now, all of the employees can request their vacations by submitting the vacation form. The function receives the employee as parameter and make the request to the system.
But, one day, the company decides to hire an external consultant, which does not have paid vacations. Therefore, the system cannot allow the employee to request it.
What will happen when the function onVacationFormSubmitted is called by a Consultant? You guessed it right: the system will crash, informing a Consultant does not have paid vacations.
Although this example is very extreme, it illustrates very well the Liskov Substitution Principle: changing the instance of a subtype must not make the system misbehave or stop working. The instance being used should be irrelevant to the system.
A simple (and very ugly) solution is adding a check in the function to ignore this call when a Consultant submits the vacation form:
As mentioned in the previous section, this code breaks the Open Closed Principle. What if tomorrow a third-party employee is hired? Keeping this code is both costly and dangerous for the application.
But how can we overcome this issue without relying on instance checks? By splitting the responsibilities. Instead of having the function related to vacation in all the employees, we can create a separate abstract class only for vacationable employees.
Now we can attribute each interface for the right employee and update the submission function to support only employees with vacations:
It is possible to see the Liskov Substitution Principle in Kotlin as well. Take a look at the following example with List, which you have surely already written at least once:
How is that possible? All of the properties expect a List of Employee but accept an empty List, an ArrayList and a MutableList without any problems. That’s the Liskov principle in action: all of the return types are subtypes of List and it really doesn’t matter which one is being sent, the functionality is the same across all implementations.
This Liskov Substitution Principle is harder to understand technically than when it is applied to the code. In fact, as you can see in the example above, you probably followed it without even knowing. On the other hand, it is easy to break this principle but difficult to detect it. There are no shortcuts or explicit bad smells to detect it during the development, but a good starting point is to constantly revisit the code to check if the current design keeps adhering to the new requirements.
Interface Segregation Principle
The Interface Segregation Principle states that it is better to have many client specific interfaces than having one general purpose interface. To illustrate this concept, consider the following example:
The Vehicle interface defines some base operations expected for a vehicle, and Car implements the defined methods. As the Vehicle should be a generic interface, let’s see other implementations:
As we can see, the Vehicle interface requires some methods that are not relevant to the Motorcycle and Bicycle implementations. As the interface requires that the implementing classes have the declared methods, it is necessary to have these methods implemented with an empty body. If the number of empty methods is low, this might be acceptable, but having too many of them is cumbersome.
To follow the principle, the Vehicle interface should be broken into smaller interfaces, which are more relevant to the implementing classes.
The implementing classes would, then, become:
In this case, there is no need to have declared methods with no implementation.
There is another side to this principle too. The client classes of the interface don’t need to know about an extensive number of unrelated functions. For example, if there is a gas station implementation that will fill up the vehicles, then it shouldn’t need to know about the existence of the openTrunk function.
An example in the Android framework of the Interface Segregation Principle is the TextWatcher interface.
Although all the interface’s functions are related, it is not uncommon for an application to need to access just one of the callback methods. However, implementing the interface requires all of the functions to be declared, so you often end up with two empty functions polluting your class.
Dependency Inversion Principle
The Dependency Inversion Principle can be summarized by the phrase “depend upon Abstractions. Do not depend upon concretions”. This principle is a strategy based on two pillars:
- High-level modules should not depend on low-level modules. Both should depend on abstractions;
- Abstractions should not depend on details. Details should depend on abstractions.
So, let’s discuss these points by introducing an example: imagine you have a module that contains the most valuable assets of your system, the business logic. Once the business logic is implemented, it is unlikely to be changed until the requirements change. The business logic module does not have all the information needed for it to work, so it needs to request them to other modules, a local database, for example. This interaction can be represented below:
The code representation for this relationship can be seen below:
The Business Rule module accesses the Local Database directly to get all the information needed to run . We can say that the Business Rule module depends on the Local Database module. This relationship works fine until a wild non-functional requirement appears, such as changing the local database to a remote one. That’s simple. What could go wrong?
Basically, we have two problems here: since the Business Rule module depends on the Local Database, as soon as we remove the Local Database module, the code does not compile anymore. Another problem is that we will need to refactor the Business Rule, the most important module of the system, making it volatile.
We can apply the Dependency Inversion Principle to this scenario, making the Business Rule module dependent on an abstraction instead of a low level policy. The new flow is represented below:
In this image, the solid line (no pun intended) represents the dependency flow and the dotted line represents the control flow. The dependency flow, as the name suggests, indicates the direct dependency between two points of the code. In the code, the Low level policy implements or extends the Abstraction.
The control flow, on the other hand, represents the execution flow. It represents the High level policy accessing the Low level policy thought the abstraction. To the High Level policy, it doesn’t matter if the Low level policy is a local database, cloud or in-memory. It only expects that some policy will match that abstraction contract.
With this new structure, how will the Business Rule module behave when removing the Local Database module? It will not be impacted at all, as its dependencies are inside the Business Rule module only.
Applying the Dependency Inversion Principle gives the system the following advantages:
- Flexibility: Concrete classes change a lot (libraries, framework, non-functional requirements), abstract classes, not so much. Relying on abstraction allows changing implementations and developing new features;
- Reliability: High level policies contain important parts of the system and they should not be updated based on low level policies changes;
This principle allows us to protect the most important part of code, making it independent from other components. When creating a high level policy in your project, always ask yourself if it contains a low level policy. If so, this low level policy is a good candidate to have its dependency inverted.
The principles represented by the SOLID acronym guide us to create better architecture and prepare for future changes. Although the principles are very well structured and easy to follow, they do not guarantee a “perfect architecture” (if such thing exists). Architecture knowledge comes with experience and time, but knowing these principles will help you solving problems that you have probably already faced, but didn’t have the tools to overcome.
- Design Principles and Design Patterns – Martin, R. C.
- Clean Architecture – Martin, R. C.