SOLID is the acronym for a set of five design practices that in conjunction make code more understandable, flexible and maintainable. SOLID was first introduced by Robert C. Martin (also known as Uncle Bob) in his 2000 paper Design Principles and Design Patterns.
The acronym stands for the following principles:
- Single responsibility
- Open/closed
- Liskov substitution
- Interface segregation
- Dependency inversion
In this blog, we are going to go through all of these principles and understand what they mean, and how to implement them.
Single Responsibility Principle
The single responsibility aims to have only one responsibility for each class in our project. This means that if we are working on a class that is a service layer, it should not have repository layer responsibilities or another kind of data management that is not related to the layer and the class purpose itself.
To make sure we comply with this principle we can check if our class complies with the following questions:
- Does it have only one responsibility?
- Does it have only one reason to change?
Let’s see the following example code to understand it better. Think about an application that receives a URL, makes some validations, and then returns a shortened URL:
If we review the two questions our class needs to comply with, we can see that it has more than one responsibility, and more than one reason to be changed. In this case, ShortUrlService is having the responsibility of making validations and then creating the short URL. Also, if a new rule is added (i.e. the original URL can start with HTTPS) this will require a modification.
To follow the Single responsibility principle we should split the code into two different classes:
This way we have our classes carrying only one responsibility, ShortUrlService will be in charge of creating the short URL only and UrlValidator will be responsible for making the validations. And in the case that we have new requirements, there is only one reason to change each of the classes, a business reason for the service layer and validation one for the other.
Open/Closed Principle
Open/closed means that our classes’ functionality should be open for extension but closed for modifications. In other words, a class can be extended by other classes and they can extend its functionality without needing to modify the functionality of the base class.
Let’s use the previous example to keep working on it. What if we need to support URLs starting with HTTPS? We will need to modify UrlValidator class as we can see here:
In a quick view, this will work right? But what if we want to validate HTTP for some users and HTTPS for another? This will not work anymore and you will need to split the validations.
Most of the time to comply with the open/closed, you need to think about inheritance.
For this case we can split the different validation types and have a class for each of them, one that will be used for HTTP and the other for HTTPS. This way we can reuse them in other classes and only make the validations that we want. In this instance, we are making an example with functionality only, so we are going to convert our validator class to an interface, but in other cases the solution can also be converting the main class to an abstract class or also leaving it as a common class and only extending its functionality:
Now we can use HttpUrlValidator or HttpsUrlValidator depending on what we want to validate. And what if we need to add a new validation? Well, we just need to create the validator class and implement UrlValidator, so the base class is closed to modifications because we don’t need to update it every time we have a new requirement, but it’s open for extension in case we need to add more validations.
Liskov Substitution Principle
This principle states that in object-oriented programming, a class can be replaced by its subclass without breaking the program. The main point of this principle is the substitution, i.e: we have an object of type T and we declare it in some parts of the code to use it. Then a new functionality or code change needs to be done. We create an object of type S that extends T, if we comply with the principle, we should be able to replace the previous code where we use T with type S or any other subtype of T without breaking the program, with no need for other changes.
Let’s continue working with the short URL code. After making the changes to comply with the Open/Closed principle, we said we could use any of the validator classes depending on what we want, but when we declare the object in any class we can use the interface instead of the subclass, so our service will look like this:
As we can see we are declaring the object with the interface type and relying on the inheritance of the class received in param to use the subclass we want. If we want to test the Liskov substitution these two examples should work without the need for further changes.
Interface Segregation Principle
Sometimes, when we work with interfaces, we define some methods that not all of the using classes need to implement, but as this is a requirement when a class implements an interface we end up with empty implemented methods. This is what interface segregation aims to avoid stating that no code should depend on methods that it does not use. Large interfaces should be split into smaller ones so the using class implements the methods that are of interest only.
For this example, let’s say that we have a new class CustomUrlService which can retrieve a short URL but also can create a short URL if it receives an array of characters to use:
In this case, we are not overriding the method because it’s not defined in the interface. Still, we need to define it to be able to comply with the Liskov substitution and be able to work with the interface in other classes. So after defining the method in the interface we will end up with this:
Now that we have the method defined in the interface, we have an empty method in line 23, because ShortUrlService doesn’t have an implementation.
So this is where we need to segregate the interface to implement only methods that our service will use. The new method should be moved to another interface and the new service should implement both:
Dependency Inversion Principle
The last principle of SOLID is the dependency inversion and it’s a methodology to loose coupling, so to work with this principle, our classes should depend on abstractions and not import low-level objects. We made something similar when working with the Liskov substitution, where we replaced the service’s validator param with the implementation of the UrlValidator:
These two are examples of importing low-level objects and where the service is not depending on abstractions, so in case we want to use one or another validator, the service will need to be updated. So to comply with this last principle, the service should depend on the interface and we can implement as many classes as we want from the UrlValidator and the service will not change, but it will use the implementation we send as a param and by reflection it will work as expected, i.e, we can use HttpsValidator for ShortUrlService and HttpValidator for CustomUrlServiceImpl: