The SOLID principles are a set of good practices of the Object-Oriented Design (OOD) world. We should have them in consideration when producing our software if we want it to be more easily scalable, maintainable, and extended.
SOLID is an acronym, and each character of this acronym refers to an OOD principle, explained down below.
S - Single Responsibility Principle
One class should have only one responsibility. Sometimes you can see this principle written as "One class should only have one reason to change". All of this means that a class should only do one job, and do it right!
Let's take a look at a small coding example.
Say we have a class responsible for managing our users, whose code can be seen below.
public class User {
private String name;
private int age;
public User(int name, int age){
this.name = name;
this.age = age;
}
public void saveUserToDatabase(){
//implementation detail
}
//getters and setters
...
}
As you can see from the above code, besides having the basic getters and setters, this class is also responsible for saving the user in the database.
The previous class, being a data container, should only change if changes in our data model occur. With our current class that doesn't happen, because we have a second reason to change. Every time we want to change the database logic, we will have to change our class, and that breaks the Single Responsibility Principle.
The code below shows an example of the refactor we needed to do to have obedience to this principle.
public class User {
private String name;
private int age;
private InfoPersistenseProvider database;
public User(int name, int age, InfoPersistenseProvider database){
this.name = name;
this.age = age;
this.database = database;
}
public void saveUserToDatabase(){
this.database.saveToDatabase();
}
//getters and setters
...
}
public class InfoPersistenseProvider{
public InfoPersistenseProvider(){}
public void saveToDatabase(User user){
//implementation detail
}
//getters and setters
...
}
As you can see from the previous code example, what we did was remove the database logic to a separated class.
Let's resume the example of changing the database type logic. Instead of changing the User class code, we only change the class that holds such logic, which in our case is the InfoPersistenseProvider class. If tomorrow we wanted to change such logic as well, we would only change in the proper place, which would always comply with the Single Responsibility Principle.
O - Open/Closed Principle
A class should be open for extension but closed for modifications. This means that we can add new functionalities without changing the existing code of the "base" class (that was extended).
Now let's take a look at a small coding example below, where we compute the user's interest in a specific video content, based on its previous consumption habits, for example.
public class Video{
private String type;
//...
public double computeUserInterest(){
if(this.type.equals("Movie")){
//compute movie interest
}else if(this.type.equals("TVShow")){
//compute TVShow interest
}
}
}
What happens if you want to add 10 new video types to our platform? Our if-else would become gigantic. This example, where we constantly add if blocks, represents a classic example of the violation of the Open/Closed principle.
How can we obey to this principle?
In the code block below we show how.
public interface Video{
public double computeUserInterest();
}
public class Movie implements Video{
public double computeUserInterest(){
//implementation detail
}
}
public class TVShow implements Video{
public double computeUserInterest(){
//implementation detail
}
}
Now, what happens if you want to add a new video type to our platform? You just need to make the class implement our interface. This makes our code cleaner and, more important, obeying to the Open/Close principle.
L - Liskov Substitution Principle
In practical terms, this principle states that if you have a parent class and one of its subclasses, you can replace the parent class by the subclass and still have valid behavior. This is the basic behavior we find in the OOP principle inheritance, meaning the subclass should be able to do everything its parent class does.
Let's analyze the upcoming block of code to understand this concept better!
public class Movie{
public void play(){
//implementation detail
}
public void increaseVolume(){
//implementation detail
}
}
public class Joker extends Movie{
}
public class ASilentMovie extends Movie{
@Override
public void increaseVolume(){}
}
Let's say we have our Movie superclass, which has two main actions: play (which starts the movie) and increaseVolume (which increases the movie's volume). This logic will work in most of the movies produced in today's day and age, but there is a market niche for silent movies which, as we all know, do not have any volume.
Do we have a correct behavior if, all of sudden, in our code we replaced a Movie object with another object that represents a silent movie? The answer is NO. No, because of the increaseVolume action. It is not correct to say "I will increase the volume of a silent movie", because there is no such thing as the concept of volume in these kinds of movies! This means that we don't obey the Liskov Substitution Principle.
I - Interface Segregation Principle
According to this principle, a class should not be forced to implement methods that it does not depend upon. A nice rule of thumb here is if you detect that you are making a dummy/empty implementation of a method (like the increaseVolume in the ASilentMovie class in the previous example), you are breaking this principle. The purpose here is to have our classes have only the methods that they need to perform their jobs.
Let's continue with our previous movie example, but a bit refactored, to see this principle in action.
public interface Movie{
public void play();
public void increaseVolume();
}
public class Joker implements Movie{
public void play(){
//implementation detail
}
public void increaseVolume(){
//implementation detail
}
}
public class ASilentMovie implements Movie{
public void play(){
//implementation detail
}
public void increaseVolume(){
//implementation detail [PROBLEM HERE]
}
}
As we saw from the previous Movie example, there was a problem when we tried to increase the volume of a silent film. We were breaking the Liskov Substitution Principle. As you can now see, and as previously mentioned, we are also breaking the Interface Segregation principle, because the ASilentMovie class is depending(implementing) on a method that is not used.
We can refactor this to comply with the Interface Segregation Principle, as follows.
public interface MoviePlayManager{
public void play();
}
public interface AudioManager{
public void increaseVolume();
}
public class Joker implements MoviePlayManager,AudioManager{
public void play(){
//implementation detail
}
public void increaseVolume(){
//implementation detail
}
}
public class ASilentMovie implements MoviePlayManager{
public void play(){
//implementation detail
}
}
As you can see, our ASilentMovie class only depends on the methods it actually uses!
D - Dependency Inversion Principle
According to this principle, your classes should depend only on abstractions and not on particular implementations.
This might be hard to visualize, but have the following code example in consideration.
public class ComedyCategory{
}
public class Movie{
private String name;
private ComedyCategory comedyCategory;
public Movie(String name, ComedyCategory comedyCategory){
}
public ComedyCategory getCategory(){
return this.comedyCategory;
}
public void setCategory(ComedyCategory comedyCategory){
this.comedyCategory = comedyCategory;
}
}
Take a closer look at the movie class. It has two fields, which are the movie name and the movie category, which is a specific class with its own specificity and characteristics.
What happens with the example I just gave you is that our movie class is depending on a specific implementation of a category (in this case the comedy category). With this, we are creating a tight coupling between our Movie class and the ComedeCategory class. We want to avoid these tight couplings.
One way we can do this is by creating the abstraction/interface of a category and having all existing categories inherit/implement it.
The following refactoring tries to show this in practice.
public interface Category{
}
public class ComedyCategory implements Category{
}
public class Movie{
private String name;
private Category comedyCategory;
public Movie(String name, Category comedyCategory){
}
public ComedyCategory getCategory(){
return this.comedyCategory;
}
public void setCategory(Category comedyCategory){
this.comedyCategory = comedyCategory;
}
}
As you can see, now our Movie class only depends on the abstraction of the category and not on a specific implementation of a category. What does this mean in practical terms?
With our first approach, to instantiate the Movie class, we should have something like Movie joker = new Movie("Joker", new ComedyCategory())
. If we tried and did Movie joker = new Movie("Joker", new DramaCategory())
, we would get an error!
With our refactored approach, either way would work (the second one would work if DramaCategory implemented Category, of course). We gain flexibility in which category to place our movie at. This flexibility, which can be more generalized in our projects, is one of the major advantages of obeying this principle. Another one is that our applications get more decoupled.
😁 I hope this has helped!
That's everything for now! Thank you so much for reading. If you are more of a visual learner please refer to this amazing article, which explains the previous concepts in a more visual way!
If you have any questions please feel free to drop them off.
Follow me if you want to read more about these kinds of topics!
Top comments (0)