SOLID Principles: The Building Blocks of Clean Code
Have you ever felt frustrated when trying to make changes to an existing codebase? Or maybe you've struggled to understand code written by someone else? If so, chances are that the code wasn't following the SOLID principles. These principles are like the golden rules for writing clean, maintainable, and scalable code. They can help you avoid headaches and make your life as a developer much easier.
What are the SOLID Principles?
SOLID is an acronym that stands for five fundamental principles of object-oriented programming and design:
Single Responsibility Principle
Open/Closed Principle
Liskov Substitution Principle
Interface Segregation Principle
Dependency Inversion Principle
Let's dive into each of these principles and see how they can help you write better code!
Single Responsibility Principle
The Single Responsibility Principle (SRP) states that a class should have only one reason to change. In other words, a class should have a single, well-defined responsibility or job. This principle helps to create classes that are focused, easy to understand, and easier to maintain.
Example:
// Violates SRP
class Employee {
calculatePayroll() { /* ... */ }
generateReport() { /* ... */ }
sendEmail() { /* ... */ }
}
// Follows SRP
class PayrollCalculator {
calculatePayroll() { /* ... */ }
}
class ReportGenerator {
generateReport() { /* ... */ }
}
class EmailSender {
sendEmail() { /* ... */ }
}
In the first example, the Employee
class has multiple responsibilities, which violates the Single Responsibility Principle. In the second example, each class has a single responsibility, making the code more focused, easier to understand, and easier to maintain.
Open/Closed Principle
The Open/Closed Principle (OCP) states that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. This means that you should be able to add new features or behaviors without modifying the existing code. This principle promotes code reusability and makes it easier to add new functionality without breaking existing features.
Example:
// Violates OCP
class ShapeCalculator {
calculateArea(shape: Shape) {
if (shape instanceof Rectangle) {
// Calculate area for Rectangle
} else if (shape instanceof Circle) {
// Calculate area for Circle
}
// ... and so on for other shapes
}
}
// Follows OCP
interface Shape {
calculateArea(): number;
}
class Rectangle implements Shape {
calculateArea() {
// Calculate area for Rectangle
}
}
class Circle implements Shape {
calculateArea() {
// Calculate area for Circle
}
}
// You can now add new shapes without modifying the ShapeCalculator
class Triangle implements Shape {
calculateArea() {
// Calculate area for Triangle
}
}
In the first example, the ShapeCalculator
class violates the Open/Closed Principle because you need to modify it every time you want to add support for a new shape. In the second example, by introducing an interface and concrete classes for each shape, you can extend the functionality by adding new shape classes without modifying the existing code.
Liskov Substitution Principle
The Liskov Substitution Principle (LSP) states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. In other words, the subclass should not violate the behavior expected from its superclass.
Example:
// Violates LSP
class Rectangle {
constructor(protected width: number, protected height: number) {}
setWidth(width: number) {
this.width = width;
}
setHeight(height: number) {
this.height = height;
}
area() {
return this.width * this.height;
}
}
class Square extends Rectangle {
setWidth(width: number) {
this.width = width;
this.height = width; // Violates the behavior of a Rectangle
}
setHeight(height: number) {
this.width = height;
this.height = height; // Violates the behavior of a Rectangle
}
}
// Follows LSP
interface Shape {
area(): number;
}
class Rectangle implements Shape {
constructor(protected width: number, protected height: number) {}
area() {
return this.width * this.height;
}
}
class Square implements Shape {
constructor(private side: number) {}
area() {
return this.side * this.side;
}
}
In the first example, the Square
class violates the Liskov Substitution Principle because it inherits from Rectangle
but doesn't adhere to the expected behavior of a rectangle (where the width and height can be set independently). In the second example, by using an interface and separate classes for Rectangle
and Square
, we ensure that the behavior of each shape is respected and follows the Liskov Substitution Principle.
Interface Segregation Principle
The Interface Segregation Principle (ISP) states that clients should not be forced to depend on interfaces they do not use. In other words, it's better to have many small, specific interfaces than one large, monolithic interface.
Example:
// Violates ISP
interface Worker {
work(): void;
eat(): void;
sleep(): void;
}
class OfficeWorker implements Worker {
work() { /* ... */ }
eat() { /* ... */ }
sleep() { /* ... */ }
}
class Robot implements Worker {
work() { /* ... */ }
eat() { /* Not applicable for a Robot */ }
sleep() { /* Not applicable for a Robot */ }
}
// Follows ISP
interface Worker {
work(): void;
}
interface FeedableWorker extends Worker {
eat(): void;
}
interface SleepingWorker extends Worker {
sleep(): void;
}
class OfficeWorker implements FeedableWorker, SleepingWorker {
work() { /* ... */ }
eat() { /* ... */ }
sleep() { /* ... */ }
}
class Robot implements Worker {
work() { /* ... */ }
}
In the first example, the Worker
interface violates the Interface Segregation Principle because it forces clients like the Robot
class to implement methods they don't need (eat
and sleep
). In the second example, by segregating the interfaces into smaller, more specific ones, we ensure that clients only depend on the interfaces they actually need, following the Interface Segregation Principle.
Dependency Inversion Principle
The Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules; both should depend on abstractions. Additionally, abstractions should not depend on details, but details should depend on abstractions.
Example:
// Violates DIP
class DatabaseRepository {
constructor(private database: MySQLDatabase) {}
// ...
}
class MySQLDatabase {
// ...
}
// Follows DIP
interface Database {
query(query: string): Result;
// ...
}
class MySQLDatabase implements Database {
query(query: string): Result {
// MySQL-specific implementation
}
}
class PostgreSQLDatabase implements Database {
query(query: string): Result {
// PostgreSQL-specific implementation
}
}
class DatabaseRepository {
constructor(private database: Database) {}
// ...
}
In the first example, the DatabaseRepository
class directly depends on the MySQLDatabase
class, which is a low-level module. If we want to switch to a different database system, we would need to modify the DatabaseRepository
class. In the second example, by introducing an abstraction (Database
interface), both the DatabaseRepository
and the database implementations depend on this abstraction. This way, we can easily swap out the database implementation without modifying the DatabaseRepository
class, following the Dependency Inversion Principle.
Why Use the SOLID Principles?
Following the SOLID principles can bring numerous benefits to your codebase:
Maintainability: SOLID code is easier to understand, modify, and extend, making it more maintainable in the long run.
Testability: Classes with single responsibilities and loose coupling are easier to unit test, leading to more reliable and robust code.
Example:
// Violates SRP and difficult to test class UserManager { private users: User[] = []; addUser(user: User) { // Validate user // Save user to database // Send welcome email } // ... } // Follows SRP and easier to test class UserValidator { validateUser(user: User): boolean { // Validate user } } class UserRepository { saveUser(user: User) { // Save user to database } } class EmailSender { sendWelcomeEmail(user: User) { // Send welcome email } } class UserManager { constructor( private validator: UserValidator, private repository: UserRepository, private emailSender: EmailSender ) {} addUser(user: User) { if (this.validator.validateUser(user)) { this.repository.saveUser(user); this.emailSender.sendWelcomeEmail(user); } } }
In the first example, the
UserManager
class has multiple responsibilities, making it difficult to test each responsibility in isolation. In the second example, by separating the responsibilities into different classes, we can easily unit test each class independently, leading to more reliable and robust code.Scalability: By adhering to the SOLID principles, your codebase becomes more flexible and scalable, allowing you to add new features or modify existing ones with minimal effort.
Reusability: SOLID code promotes the creation of modular and reusable components, reducing duplication and increasing efficiency.
Example:
// Violates OCP and DIP class EmailSender { sendEmail(message: string, recipient: string) { // Send email using a specific email service provider } } // Follows OCP and DIP interface EmailService { sendEmail(message: string, recipient: string): void; } class SMTPEmailService implements EmailService { sendEmail(message: string, recipient: string) { // Send email using SMTP } } class HTTPEmailService implements EmailService { sendEmail(message: string, recipient: string) { // Send email using HTTP API } } class EmailSender { constructor(private emailService: EmailService) {} sendEmail(message: string, recipient: string) { this.emailService.sendEmail(message, recipient); } }
In the first example, the
EmailSender
class is tightly coupled to a specific email service provider, making it difficult to switch providers or reuse the email sending functionality elsewhere. In the second example, by introducing an abstraction (EmailService
interface) and implementing different email service providers, we can easily swap out the email service implementation or reuse theEmailSender
class with different email services, promoting code reusability and extensibility.Collaboration: When working in a team, SOLID principles help ensure that the codebase is consistent, readable, and easier for multiple developers to work on simultaneously.
Example:
// Violates SRP and ISP class UserService { private users: User[] = []; addUser(user: User) { // Validate user // Save user to database // Send welcome email } updateUser(userId: string, updatedUser: User) { // Find user by ID // Update user in database // Send update notification email } deleteUser(userId: string) { // Find user by ID // Delete user from database // Send deletion confirmation email } // ... } // Follows SOLID principles interface UserRepository { addUser(user: User): void; updateUser(userId: string, updatedUser: User): void; deleteUser(userId: string): void; // ... } interface EmailService { sendWelcomeEmail(user: User): void; sendUpdateNotificationEmail(user: User): void; sendDeletionConfirmationEmail(userId: string): void; } class UserService { constructor( private userRepository: UserRepository, private emailService: EmailService ) {} addUser(user: User) { this.userRepository.addUser(user); this.emailService.sendWelcomeEmail(user); } updateUser(userId: string, updatedUser: User) { this.userRepository.updateUser(userId, updatedUser); this.emailService.sendUpdateNotificationEmail(updatedUser); } deleteUser(userId: string) { this.userRepository.deleteUser(userId); this.emailService.sendDeletionConfirmationEmail(userId); } }
In the first example, the
UserService
class has multiple responsibilities and violates the Single Responsibility Principle and the Interface Segregation Principle, making it difficult for multiple developers to work on it simultaneously. In the second example, by separating responsibilities into different interfaces and classes, and adhering to the SOLID principles, the codebase becomes more consistent, readable, and easier for multiple developers to collaborate on.Conclusion
Remember, the SOLID principles are not rigid rules but rather guidelines to help you write better code. As you gain more experience, you'll develop a deeper understanding of when and how to apply these principles effectively.
So, the next time you're writing code, take a moment to think about the SOLID principles. Your future self (and your teammates) will thank you for it!
Subscribe to my newsletter
Read articles from Ayoub Touba directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Ayoub Touba
Ayoub Touba
With over a decade of hands-on experience, I specialize in building robust web applications and scalable software solutions. My expertise spans across cutting-edge frameworks and technologies, including Node.js, React, Angular, Vue.js, and Laravel. I also delve into hardware integration with ESP32 and Arduino, creating IoT solutions.