SOLID
- coderinthewild
- May 8, 2020
- 5 min read
When talking about designing and developing systems, SOLID is an acronym that you HAVE TO KNOW. These five principles are one of the foundations of software architecture and development.
Object-oriented programming gave us a new way of designing applications, this allowed us to group related data in one class to encapsulate functionality. But with great power comes great responsibility, if done wrong, systems can become huge monsters that are fed with every new functionality added. Because of this, Robert C. Martin -or Uncle Bob- proposed five principles to make our lives easier, helping us create understandable and maintainable software.
- Single Responsibility Principle
- Open-Closed Principle
- Liskov Substitution Principle
- Interface Segregation Principle
- Dependency Inversion Principle
It is important to say that they are heuristic, based on experience, it is not something some guy came up with in theory and said: Oh that would be cool!.
Keep reading to find out in detail what they are about and see an example for better understanding!
SINGLE RESPONSIBILITY PRINCIPLE
“A class should have one, and only one, reason to change.”
According to this principle, classes/components/microservices should have only one reason to change, therefore one responsibility. This will of course favor encapsulation, and to have less coupling and more cohesion.
To put it in practice, I find that Bob said it better than anyone:
"Gather together the things that change for the same reasons. Separate those things that change for different reasons”.
Consider the following example, why do you think the class User doesn’t follow SRP?
class User{
int id;
String name;
User(this.id, this.name);
void saveUserDb(User user);
User getUserDb(int id);
void deleteUserDb(int id);
}
As we can see, class User has the definition of the entities attribute, the constructor, and also has access to database transactions, resulting in more than one responsibility. If we ever need to change the database, we would probably have to change the names or types of the attributes which don’t make sense.
To avoid this, we should separate the responsibilities:
class User{
int id;
String name;
User(this.id, this.name);
}
class UserDb{
void saveUserDb(User user);
User getUserDb(int id);
void deleteUserDb(int id);
}
Resulting in a more cohesive and encapsulated code. This will also help if we have various implementations of the database, we will see this later.
OPEN-CLOSED PRINCIPLE
"Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.”
In other words, if we want to add new functionality, the ideal would be to be able to program it without touching or modifying existing code.
Let's continue with our example, imagine our users can be teachers or students, we would like to print a list of users and for each student, we would like to show their id but for each teacher, we have to show their email. The code would be something like this:
void printUsers(List<User> users){
for(var i=0; i < users.length ; i++){
var user = users[i];
if(user.role == “Student”){
print(user.id);
}else if(user.role == “Teacher”){
print(user.email);
}else{
print(“");
}
}
}
But this doesn’t comply with Open-Closed, as if we have another role, let’s say, parents, we would need to add another if in the iteration, modifying existing code. Of course, this is an easy example and it doesn’t seem to be much hustle, but imagine a big system with a lot of different variables, this would be unsustainable.
To solve this we can use polymorphism, an abstract class User that implements the method printUser(). Each role will have its own implementation of this method, and the code inside
the iteration will call just do user[i].printUser().
abstract class User{
//…
abstract void printUser();
}
class Teacher extends User{
@override
void printUser(){print(email)}
}
class Student extends User{
@override
void printUser(){print(id)}
}
class Parent extends User{
@override
void printUser(){print(name)}
}
//...
void printUsers(List<User> users){
for(var i=0; i < users.length ; i++){
var user = users[0];
user.printUser();
}
}
LISKOV SUBSTITUTION PRINCIPLE
The principle defines that objects of a superclass shall be replaceable with objects of its subclasses without breaking the application. That requires the objects of your subclasses to behave in the same way as the objects of your superclass. So if in a function we use a certain class, we should be able to use any of its subclasses without interfering in the program’s functionality.
In our previous example:
List<User> users = new List<Teacher>();
This clearly violates LSP as if we are creating a list of users, we should be able to add teachers, students, and parents to it, but we are declaring it as a list of teachers.
P.S: Uncle Bob states that if you violate LSP, you are also violating the Open-Closed principle!
INTERFACE SEGREGATION PRINCIPLE
“Make fine grained interfaces that are client-specific.”
According to this principle, it is better to have many interfaces that declare the little amount of methods that are related (high cohesion), than having one huge interface that forces us to implement all of its methods when it sometimes won't be necessary. The Single Responsibility Principle is very useful when trying to divide a huge interface in the best way possible.
interface UserActions {
eat();
haveBreak();
gradeAssignment();
playWithFriends();
}
This doesn’t comply with ISP as it declares methods that don’t need to be defined by some of the user subclasses, gradeAssignment() is only for teachers whilst playWithFriends() is probably only for students. To solve this problem we could do something like this:
interface UserActions {
eat();
haveBreak();
}
interface TeacherActions{
gradeAssignment();
}
interface StudentActions{
playWithFriends();
}
DEPENDENCY INVERSION PRINCIPLE
“Depend on abstractions, not on concretions.”
And here we are at the last principle, which has to main 'pillars':
High-level modules shouldn’t depend on low-level modules. Both should depend on abstractions.
Abstractions shouldn’t depend on details. Details should depend upon abstractions.
Its objective is to reduce dependencies between modules, making the coupling between classes smaller. It will come a time when our system will have lots of modules, when this happens we should use dependency injection, which allows us to control functionality from one place within the application and not spread all over it, making modifiability and testing much easier.
Let's say we have a class that has access to the data through a database:
Class DatabaseService{
//...
User getData();
}
class DataAccess{
private DatabaseService databaseService;
public DataAccess(DatabaseService dbService){
this.databaseService = dbService;
}
User getData(){
databaseService.getData();
}
}
Nowadays this works perfectly, but what happens if tomorrow we want to access the data through an API? We would have to change everything and touch the existent code, which is a No-No. This is because our high-level module (DataAccess) depends on a low-level module (DatabaseService). DataAccess should always depend on abstractions! Let's fix it:
interface IDataService{
//...
getData();
}
class DataAccess{
private IDataService dataService;
public DataAccess(IDataService dataService){
this.dataService = dataService;
}
User getData(){
dataService.getData();
}
}
Here, no matter the class we use in DataService that extends IDataService, the application will work perfectly. This will save us a lot of time and trouble when having to add functionality or another way to access the data. So now, all that is left to do are the services that implement IDataService interface:
class DatabaseService implements IDataService{
User getData(){//… return data;}
}
class ApiService implements IDataService{
User getData(){//… return data;}
}
P.s: This also covers Liskov!
FINAL STATEMENT
SOLID principles are that, principles, good practices, that will help you have top-quality software. Implementing them can seem tedious, but in the long run you will see the benefits. More importantly, once you start applying them, it will become easier and with a lot of practice, there will come a time when you don't even realize you are using them.
You will end up with a maintainable and robust system, easy to modify not only for you but for your team and the ones that come after. It will be efficient, flexible, with reusable code and actually easy to escalate as it will enable you to add new functionality more quickly.
Had you ever heard about SOLID before? Do you use it? Would love to hear from you!
Comments