As software systems grow in size and complexity, they can become increasingly difficult to maintain and scale. This is often due to poor design decisions that make the system rigid, fragile, and hard to change. This can lead to a phenomenon known as “code rot,” where the codebase becomes so difficult to work with that developers are afraid to make changes, resulting in an outdated system.
The SOLID principles are a set of guidelines that aim to solve the said problems, and those principles are:
Single Responsibility Principle (SRP): “A class should have one and only one reason to change”.
Open-Closed Principle (OCP): “Objects should be open for extension but closed for modification”.
Liskov Substitution Principle (LSP): “Subtypes should be substitutable for their base types”.
Interface Segregation Principle (ISP): “A client should not be forced to implement interfaces it does not use”.
Dependency Inversion Principle (DIP): “High-level modules should not depend on low-level modules, but rather both should depend on abstractions”.
In this article, we will dive deep into the first principle and cover the rest of the principles in the next articles.
The Single Responsibility Principle
Let's think of a car: A car has many parts, such as the engine, transmission, wheels, and brakes. Each of these parts has a specific function and is responsible for one task. The engine is responsible for providing power to the car, the transmission is responsible for shifting gears, the wheels are responsible for propelling the car, and the brakes are responsible for slowing the car down. Each part is built and designed to perform one specific task and they work together to make the car move. In the same way, each class or module in software development should have one, and only one, responsibility and they work together to make the software work.
Now, let's think about what will happen if we will combine multiple parts of the car into one component. For example, instead of having separate parts for the engine, transmission, and brakes, we would have one part that is responsible for all three functions. This would make the component much more complex and harder to maintain. Imagine trying to fix or replace just the brakes while the engine is connected to it and all the other functions, it would be difficult and time-consuming.
Similarly, in software development, if we do not follow the single responsibility principle, we end up with classes or modules that have multiple responsibilities. This makes the code harder to understand, harder to test, and harder to maintain. When a change is needed, it becomes difficult to determine which part of the code needs to be modified and what impact that change will have on the rest of the system. In addition, it can increase the risk of bugs and make it harder to identify where the issue is coming from.
And that is the idea behind this principle: “A class should have one and only one reason to change”.
So, how can we implement this principle?
Implementation
Let's start with a simple example. Consider a class Invoice
that has the responsibilities of managing an invoice's details, calculating the total amount, and printing the invoice:
class Invoice {
constructor(items) {
this.items = items;
}
addItem(item) {
this.items.push(item);
}
calculateTotal() {
let total = 0;
for (const item of this.items) {
total += item.price * item.quantity;
}
return total;
}
printInvoice() {
console.log("Invoice Details:");
for (const item of this.items) {
console.log(`- ${item.name}: $${item.price} x ${item.quantity}`);
}
console.log(`Total: $${this.calculateTotal()}`);
}
}
This class violates the Single Responsibility Principle as it has three responsibilities: managing the invoice’s details, calculating the total amount, and printing the invoice.
We can refactor this class to follow the Single Responsibility Principle by separating these three responsibilities into three different classes: Invoice
, InvoiceCalculator
, and InvoicePrinter
:
class Invoice {
constructor(items) {
this.items = items;
}
addItem(item) {
this.items.push(item);
}
}
class InvoiceCalculator {
constructor(items) {
this.items = items;
}
calculateTotal() {
let total = 0;
for (const item of this.items) {
total += item.price * item.quantity;
}
return total;
}
}
class InvoicePrinter {
constructor(items) {
this.items = items;
}
printInvoice() {
console.log("Invoice Details:");
for (const item of this.items) {
console.log(`- ${item.name}: $${item.price} x ${item.quantity}`);
}
console.log(`Total: $${this.calculateTotal()}`);
}
}
In this refactored code, the Invoice class is now only responsible for managing the invoice's details, the InvoiceCalculator class is only responsible for calculating the total amount, and the InvoicePrinter class is only responsible for printing the invoice. This makes the code easier to understand, maintain, and test, and also makes it easier to make changes to one responsibility without affecting the other two responsibilities.
To use these classes, we can create instances of each class and pass the necessary information between them, like so:
const invoice = new Invoice([
{ name: "Item 1", price: 10, quantity: 2 },
{ name: "Item 2", price: 20, quantity: 1 },
]);
const invoiceCalculator = new InvoiceCalculator(invoice.items);
const invoicePrinter = new InvoicePrinter(invoice.items);
invoice.addItem({ name: "Item 3", price: 5, quantity: 3 });
const total = invoiceCalculator.calculateTotal();
invoicePrinter.printInvoice();
Let’s look at another example. We have a web application that is responsible for saving user’s data into DB:
class UserController {
constructor() {}
async createUser(request, response) {
const user = request.body;
const validationErrors = this.validateUser(user);
if (validationErrors.length) {
return response.status(400).send({ errors: validationErrors });
}
const newUser = await this.saveUserToDB(user);
return response.status(200).send({ user: newUser });
}
validateUser(user) {
const errors = [];
if (!user.email) {
errors.push({ field: 'email', message: 'Email is required' });
}
if (!user.password) {
errors.push({ field: 'password', message: 'Password is required' });
}
return errors;
}
async saveUserToDB(user) {
// code to save user to database
}
}
This example violates the Single Responsibility Principle because the controller class should have only one goal: handling the communication between the client and the business logic, but instead it has three responsibilities:
validating user data, saving the user to the database, and handling the communication between the client and the business logic.
To make this code follow the SRP principle, we can refactor it as follows:
class UserController {
constructor(userRepository, validator) {
this.userRepository = userRepository;
this.validator = validator;
}
createUser(user) {
if (!this.validator.isValid(user)) {
throw new Error('User is not valid');
}
this.userRepository.save(user);
}
}
class UserRepository {
save(user) {
// Save the user to the database
}
}
class Validator {
isValid(user) {
// Validate the user object
return true;
}
}
Now, the UserController
class is responsible only for coordinating the process of creating a user, and delegating tasks to its collaborators such as userRepository
, and validator
. The actual work of saving the user and validating the user is done by separate classes. This makes the code easier to maintain and test, as each class has a single responsibility and can be tested in isolation.
The Hard Part
We get a lot of benefits from implementing the SRP. with that being said, for me, the hardest part of this principle is the trade-off between complexity and maintainability: Balancing the trade-off between the increased complexity(more and more files) and improved maintainability of the codebase(responsibilities are separated into those different files) is a challenge when applying the SRP.
Implementation Tip
It can be hard sometimes to implement this principle. I thought about that a lot, and I realized that when I ask the next question I usually end up implementing this principle successfully:
Does my class name indicate what it does? and does its methods indicate how it does it?
From my perspective, the class name should indicate its ultimate goal, and its methods should indicate how it achieves that ultimate goal. The question above helped me in a lot of cases when I wanted to implement SRP.
Conclusion
So that is the Single Responsibility principle, I hope this article helped you. In the next one, I will cover the Open-Closed Principle.
Top comments (0)