Introduction
I believe that every developer should be familiar with the SOLID principles. The SOLID principles are comprised of five principles that all aim for the same purpose: writing understandable, readable, maintainable, and testable code, especially in the OO style that many developers can collaboratively work on.
SOLID is a helpful acronym you can use to remember five essential principles:
- Single Responsibility Principle (SRP)
- Open-Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
When and where should I use the SOLID principles?
Before diving into the first principle let's know what is PDD.
PDD is Pain-Driven Development. It means you should write your code as simple as you can to solve a specific problem not worrying about SOLID. Because applying the SOLID upfront is premature optimization.
Instead, as your app grows look for places in your code where the app is painful to work with. This pain may be coupling, many duplications, or difficulty in testing.
When you get there, see if any SOLID principles you can apply to improve your design and mitigate this pain.
In this article, let's begin with the SOLID first principle which is the Single Responsibility Principle (SRP).
What is the Single Responsibility Principle?
Robert C. Martin (Uncle Bob) defines the Single Responsibility Principle as:
Each software module should have one and only one reason to change.
You can think this definition is so easy to implement. Or in contrast, you can think of some questions like what is the Module exactly? what is the Reason to Change? and what does it relate to the Responsibilities?
To be honest, this definition was very tricky for me. I was always thinking of these questions. So I am writing this article to share my approach to following this principle.
OK, first of all, you can think of a Module as a function, class, component, or even microservice.
To better understand this principle, we have to keep another two principles in our minds Encapsulation and Delegation. But why?
I think we agree on this fact, the better we able to separate the what and the how a class does, the better is our software design. This separation could be done using Encapsulation and Delegation.
A class should encapsulate doing a specific task in a specific way. And when this class is a single-purpose, it does this purpose perfectly and we can use it easily.
Or a class can delegate a specific task to another instance of a class that encapsulates doing this task in an abstracted way.
On the other hand, when a class is multi-purpose, it often ends up coupling things that shouldn't be related together and making it harder to use.
As you can see, the Auth
class has a method login
that is responsible for its business logic and sending an SMS to the user after logging in. The Auth
class delegates sending the SMS task to another class SmsProvider
which encapsulates its implementation in a way that the Auth
class doesnt know how the SmsProvider
does its implementation.
What is the Responsibility? and What about the Reason to Change?
Responsibility is the answer to a question of how something is done.
Responsibility at the code level might be data persistence, logging, validation, third-party integration, or the business logic itself. At the distributed system level might be caching, message queuing, proxy, or load balancer.
SRP suggests that every module has one reason to change and has one responsibility. Each of these responsibilities may be changed in the future. For example, data persistence may be changed from files to databases or even from one database to another. Validation criteria may be changed. The logging tool can be changed. Business logic is usually changed. The caching type may be changed. Or the message queuing tool may be changed.
Yes, it is tricky to define the responsibility and the reason to change. As a result, you have to be aware, as you can, of any potential request for change that might violate the SRP in your code. These requests are the source of the reason to change. They might be requests from your managers or even improvements from your perspective.
The more you know about these requests, the more you can separate your decisions and apply the SRP perfectly.
To what extent should we apply the SRP?
Unfortunately, following the SRP sounds easier than it is.
Some developers take the SRP to the extreme by creating a class with just one method. And when they write actual code they have to inject many classes which makes the code more complex and unreadable.
You shouldn't oversimplify your code. You have to use the SRP in common sense. There is no benefit if you create every class with only one method. In that case, why do classes exist, and why not go back to procedural programming?
You have to define the balance point between the over-simplicity and the over-complexity in your code. That point can be defined by:
- Ask yourself "what is the responsibility of this module?". If your answer has the word "and", you likely violate the SRP.
- Ask yourself what are the potential reasons to change this module?. If you have many reasons that don't relate to each other, you likely violate the SRP and your module is low cohesive and tightly coupled.
The relation between SRP and Coupling, Cohesion, and Separation of Concerns
The SRP is closely related to the concept of coupling. When a class performs many details that aren't related to each other, these details are tightly coupled. And the more details a class has, the more reasons for a change it has as well.
A loosely coupled class is responsible for some higher-level concerns and delegates to other classes which are responsible for the details of how to perform the lower-level operations for these concerns. This introduces us to another principle, Separation of Concerns.
Separation of Concerns suggests that a program should be separated into sections. Every section has to deal with one concern and should not know how another section does a specific task.
A key benefit of following the Separation of Concerns is that the higher-level code doesn't have to deal with lower-level code and doesn't know how the lower-level details are implemented.
Another concept that is closely related to the SRP is Cohesion. Cohesion refers to how strongly the relationship between module elements is. The more a module has responsibilities, the lower cohesion between its elements.
Take a look at this example to better understand these principles, Class
has three fields and two methods. method1
uses only field2
and doesn't use the other fields. method2
uses field1
and field3
and doesn't use field2
.
This diagram depicts that Class
might be tightly coupled, low cohesive, and doesn't separate its concerns. So lets try to refactor it with these principles in our mind:
Now, we can say that Class1
and Class2
are highly cohesive, loosely coupled, and concerns are separated perfectly.
Why should we apply the SRP?
Now, we have known where and when should we use the SRP, but why should we bother ourselves by applying it?
In fact, there are many benefits that come from applying the SRP:
- We know that requirements change over time. Each change affects the responsibility of at least one class. The more responsibilities your class has the more reasons for changes you have.
- The single-purpose module is much easier to read, explain, and understand. You may remember how frustrated you feel when you have to refactor a very big class.
- For sure, separated modules are more flexible and configurable than many responsible modules. If you have a big class and you want to add a new feature or make a feature configurable, yes you can do that in the class itself but only by increasing the class size and increasing its complexity.
- Single responsible modules are likely more reusable than many responsible modules. You may note that the methods that have only one purpose most likely have no side effects and don't depend on the class state. Yes, you are right as you thought, it is the Functional Programming. The SRP nudges us toward the Functional Programming style.
- Its away easier to test and maintain single-purpose modules.
- Having classes and methods that have only one purpose helps a lot to investigate the performance issues easily. And more remarkable at the level of the distributed systems, services that only have one purpose are easier to monitor the load and resources bottlenecks and as a result, scale up or down independently.
- If your class depends on one class or more, any change in this class would affect its dependencies. You might need to update these dependencies or recompile them even though they are not directly affected by your change.
I'm convinced, I will use the SRP every time and everywhere
Before going that far, keep in mind these points:
- At the level of distributed systems, the more services you build, the low reliability you get. Yes, there are many ways to overcome this point, but you have to keep in your mind that everything has its cost of time, effort, and money.
- As we knew, separating concerns increases for sure the code size, and the effort and time you need to write this code. However, in the long term, it decreases the effort and time in general.
- Indeed, separating concerns in our code hits the overall performance. However at the level of code, this point could be neglected, but at the level of distributed systems, it hits a lot. Many services mean higher latency and networking issues that affect the system performance directly.
Lets apply the SRP to an example
Lets introduce a simple example that introduces a class that has many reasons to change and then try to apply the SRP to it.
interface User {
email: string
password: string
}
const users: User[] = [];
class Auth {
register(input: User) {
// Logging responsibility
console.log('User Registration');
// Validation responsibility
if (!input.email) {
throw new Error('You have to provide your email');
}
if (!input.password) {
throw new Error('You have to provide your password');
}
// Persistence responsibility
users.push(input);
}
}
First of all, lets make our investigation and ask ourselves:
- What is the responsibility of this class? You might say it only registers a user. Another one might go deeper and say it is responsible for logging and validation and persistence. OK, if you're confused to identify, jump into the next test.
- What are the potential reasons to change this class? I think we agree that we may change the logging mechanism, the validation criteria, or the persistence approach, right? So we have many reasons to change this class. So it might be an alert to refactor your class and apply the SRP.
As a result. lets refactor this example with SRP in mind.
interface User {
email: string
password: string
}
const users = [];
// Logger class encapsulates its logging implementation. Whenever you want to change the logging mechanism, you only update this class only
class Logger {
log(message: string) {
console.log(message);
}
}
// Validator class encapsulates its validation implementation. Whenever you want to change the validation criteria, you only update this class only
class Validator {
validateRegisterInput(input: User) {
if (!input.email) {
throw new Error('You have to provide your email');
}
if (!input.password) {
throw new Error('You have to provide your password');
}
}
}
// PersistentStore class encapsulates its persisting implementation. Whenever you want to change the persistence approach, you only update this class only
class PersistentStore {
storeUser(input: User) {
users.push(input);
}
}
class Auth {
logger = new Logger();
validator = new Validator();
store = new PersistentStore();
register(input: User) {
// Logging responsibility is delegated to the Logger class
this.logger.log('User Registration');
// Validation responsibility is delegated to the Validator class
this.validator.validateRegisterInput(input);
// Persistence responsibility is delegated to the PersistentStore class
this.store.storeUser(input);
}
}
As you can see, there is more code in this version than in the previous version. That's right, but the refactored version is easier to test, maintain, update, and more readable. Now, the Auth
class which is the higher-level code doesn't know how the lower-level code is implemented. It only delegates other classes to do specific tasks which in turn encapsulate their implementations.
Conclusion
At the end of the day, the Single Responsibility Principle is very essential. But be careful when using it. Use it only to eliminate pain by improving your design after writing some working code. Don't oversimplify your code.
SRP helps you to achieve high cohesion, loose coupling, and separation of concerns.
Finally, keep your modules as small and simple as you can, has one responsibility, and has one reason to change. This eases your testability, maintainability, and readability.
Thanks a lot for staying with me up till this point, I hope you enjoy reading this article.
Top comments (0)