The single responsibility principle (SRP) is the first of the five SOLID principles. It may be the simplest SOLID principles to understand, but it is not always easy to apply, especially if you’re a junior developer. What does this principle say?
There should never be more than one reason for a class to change. In other words, every class should have only one responsibility
SRP can be challenging to apply because it requires developers to break down complex problems into smaller, more manageable units of code. Identifying and isolating responsibilities can be challenging, and if done incorrectly, it can lead to poor design decisions.
Let’s take an example. The following class is in charge of importing products into an application as a PIM or an ERP.
type Product = {
name: string
description: string
};
class ProductImport {
constructor(private connection: Connection) {}
async import(filePath: string): Promise<void> {
await this.loadProductFromCsvFile(filePath);
}
private async loadProductFromCsvFile(file: string): Promise<void> {
const csvData: Product[] = [];
createReadStream(file)
.pipe(csvParser())
.on('data', (product: Product) => csvData.push(product))
.on('end', async () => {
for (const data of csvData) {
await this.saveProducts(data);
}
});
}
private async saveProducts(product: Product): Promise<void> {
await this.connection.execute(
'INSERT INTO products (name, description) VALUES (?, ?)',
[product.name, product.description],
);
}
}
This ProductImport
class does several things, it gets product data from a CSV file and imports them into a database. That means it has multiple responsibilities which violates the single responsibility principle (SRP).
We need to break down this class into smaller ones to isolate responsibilities and make it compliant with the single responsibility principle. We will create a new class called CsvProductLoader
that will load the product data from the CSV file, and we will create a second class called MysqlProducts
that will be responsible for saving product data into the database."
class CsvProductLoader {
async loadProduct(file: string): Promise<Product[]> {
const products: Product[] = [];
createReadStream(file)
.pipe(csvParser())
.on('data', (product: Product) => products.push(product));
return products;
}
}
class MysqlProducts {
constructor(private connection: Connection) {}
async save(product: Product): Promise<void> {
await this.connection.execute(
'INSERT INTO products (name, description) VALUES (?, ?)',
[product.name, product.description],
);
}
}
We still need the ProductImport
class. It acts as a controller and is responsible for orchestrating the interactions between the CsvProductLoader
and MysqlProducts
classes. The ProductImport
class doesn’t need to handle any of the low-level data processing or database operations. Its primary responsibility is to delegate the tasks of loading and saving data to the specialized classes. This separation of concerns promotes modularity and makes the code more maintainable.
class ProductImport {
constructor(
private productLoader: CsvProductLoader,
private products: MysqlProducts,
) {}
async import(filePath: string): Promise<void> {
const products = await this.productLoader.loadProduct(filePath);
products.forEach((product: Product) => this.products.save(product));
}
}
There is a last thing to improve in this code example. The ProductImport
class currently relies on concrete classes, which doesn’t adhere to the Dependency Inversion principle because high-level modules should not depend on low-level modules directly. To address this, we need to introduce interfaces to abstract away the dependencies in the ProductImport
class.
interface ProductLoader {
loadProduct(file: string): Promise<Product[]>
}
interface Products {
save(product: Product): Promise<void>
}
class ProductImport {
constructor(
private productLoader: ProductLoader,
private products: Products,
) {}
}
I’ve written an article about the Dependency Inversion Principle (DIP), which explains the principle and how it helps to make testing easier:
Do you speak French ? Tired of the same old CRUD applications, struggling with your framework, or feeling the pressure of production releases? It’s time to take your career to the next level.
Discover the power of Hexagonal Architecture and DDD to build robust and sustainable Symfony applications. Join me and kickstart your journey toward mastering advanced development techniques.
The biggest benefit of working with small classes is that it eases testing. The original ProductImport
class required a working database and the ability to read files from the filesystem. This doesn’t help with having a short feedback loop. Testing code that involves IO operations is more complicated because the code cannot be executed without the tools required by the application. Splitting massive classes into smaller ones helps isolate the IO operations and makes your code more testable.
I’ve written an article about how the Single Responsibility Principle (SRP) helps with easy testing, especially when your classes are huge, and you want to test their private methods:
Creating good code is like playing with Lego bricks. It involves working on small, easily testable classes and assembling them using composition to build more complex features.