SOLID Principles in Simple Terms
SOLID clean-code
The SOLID principles are divided into five parts and are considered some of the most important principles in object-oriented programming.
What is SOLID?
SOLID is an acronym for five principles. The aim of these principles is to make software easier to understand, more flexible, and easier to maintain. As a programmer, developer, or software engineer, learning these five principles is a “must.” These principles can be applied to any object-oriented design.
Here are the five SOLID principles:
1. Single Responsibility Principle (SRP)
Every class in your program should have a single, specific responsibility. In other words, a class should only be responsible for one functionality of the program.
2. Open/Closed Principle (OCP)
Software entities (like classes, modules, and functions) should be open for extension but closed for modification.
3. Liskov Substitution Principle (LSP)
If S is a subclass of T, objects of type T should be replaceable with objects of type S without altering the behavior of the program. In simpler terms, subclasses should not change the behavior or properties of their parent classes.
4. Interface Segregation Principle (ISP)
Classes should not be forced to implement methods they do not need. This principle emphasizes designing interfaces in such a way that when a class uses it, it does not have to implement unnecessary methods.
5. Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details, but details should depend on abstractions.
Key Takeaways:
- Most design patterns aim to implement the SOLID principles, especially the first and second ones.
- Very few programs implement all five principles simultaneously.
- Like the real world, it is nearly impossible to follow all principles perfectly.
- Applying each principle should be done thoughtfully to avoid complicating the problem.
- The SOLID principles are frequently discussed in interviews.
Single Responsibility Principle (SRP)
SRP stands for Single Responsibility Principle, which, when translated literally, means “the principle of single responsibility.”
Here’s the official definition of SRP: “A class should only have one reason to change.”
What does this mean? 🤔
This principle tells us that every class in our program should have one specific responsibility. In other words, a class should only and only be responsible for one function in the program.
We’ve all heard the phrase: Do one thing, but do it well!
Example:
Consider the following class:
class User {
public information() {}
public sendEmail() {}
public orders() {}
}
In this User class, there are three methods:
- information() – Returns user information.
- sendEmail() – Sends an email to the user.
- orders() – Returns the user’s orders.
Now think: what should be the purpose of a class named User? Most likely, it should deal with storing or displaying user-related information.
However, when we look closely, only the information()
method aligns with the class’s purpose. The other methods (sendEmail() and orders())
deal with responsibilities unrelated to the User class.
The Problem:
The User class should not be responsible for sending emails or handling user orders. By including these unrelated functions, the class loses focus and mixes its intrinsic responsibilities with external ones.
This becomes problematic when we want to expand the class. For instance, if we decide to send more customized emails, the class’s responsibilities might overlap, leaving us unsure whether it’s a User class or an Email class!
The Solution: 🤔
The solution is to separate unrelated responsibilities into their own dedicated classes:
class User {
public information() {}
}
class Email {
public send(user: User) {}
}
class Order {
public show(user: User) {}
}
As you can see, the User class is now cleaner, more focused, and easier to manage. Additionally, it’s now simpler to extend both the User class and other classes.
SRP Beyond Classes:
This principle can also apply to methods and functions. For example, consider the following send method, which violates SRP:
class Mailer {
public send(text) {
mailer = new Mail();
mailer.login();
mailer.send(text);
}
}
mail = new Mailer();
mail.send('Hello');
The send method is doing two things:
- Authenticating.
- Sending the email.
Additionally, this example violates the second SOLID principle (which we’ll explore later).
A Better Solution:
To follow SRP, we can refactor the method like this:
class Mailer {
private mailer;
public constructor(mailer) {
this.mailer = mailer;
}
public send(text) {
this.mailer.send(text);
}
}
myEmail = new MyEmailService();
myEmail.login();
mail = new Mailer(myEmail);
mail.send('Hello');
Here, the send method only handles its specific responsibility: sending the email. Authentication has been moved to its appropriate place.
Open/Closed Principle (OCP)
The second principle of SOLID is the Open/Closed Principle, often abbreviated as OCP. Its formal definition is:
Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.
What Does “Open” and “Closed” Mean?
The terms “open” and “closed” here might seem a bit abstract, so let’s clarify their meaning in the context of OCP:
Open:
A class is considered open if it can be extended, allowing you to add new methods, properties, or behaviors to it. For instance, you can create a subclass, add new functionality, or change its behavior without modifying its original code.
Closed:
A class is closed when it is complete, fully tested, stable, and can be reliably used by other classes. It should not need to be modified in the future. Some programming languages use the final keyword to ensure that a class remains closed.
Explanation of OCP
The OCP principle tells us to write code so that new features can be added without modifying existing code. Instead, you should be able to introduce new functionality by extending the code in a way that leaves the original code untouched.
In essence, a class should be both open (for extension) and closed (for modification) at the same time.
Example:
class Hello {
public say(lang) {
if (lang === 'pr') {
return 'درود';
} else if (lang === 'en') {
return 'Hi';
}
}
}
let obj = new Hello();
console.log(obj.say('pr'));
Here, the Hello class determines the greeting message based on the input language. Currently, it supports two languages (‘pr’ for Persian and ‘en’ for English).
Problem:
If we want to add support for new languages, we need to modify the say method:
class Hello {
public say(lang) {
if (lang === 'pr') {
return 'درود';
} else if (lang === 'en') {
return 'Hi';
} else if (lang === 'fr') {
return 'Bonjour';
} else if (lang === 'de') {
return 'Hallo';
}
}
}
let obj = new Hello();
console.log(obj.say('de'));
If we want to support 150 languages, the method becomes long and hard to maintain. Every time a new language is added, we must modify the method. This violates OCP because the say method is not closed to modification. It’s always exposed to changes.
Solution:
To solve this, we can abstract the behavior and write the code in a way that allows new features (languages) to be added without altering the existing class.
Refactored Code:
class Persian {
public sayHello() {
return 'درود';
}
}
class French {
public sayHello() {
return 'Bonjour';
}
}
class Hello {
public say(lang) {
return lang.sayHello();
}
}
let myHello = new Hello();
console.log(myHello.say(new Persian())); // Output: درود
Explanation:
- Each language is now represented by its own class (e.g., Persian, French), encapsulating the specific behavior.
- The Hello class and its say method are no longer modified when a new language is added. Instead, you just create a new language class.
- The Hello class remains closed for modification but open for extension.
Using Interfaces for Optimization:
We can make this approach even cleaner and more scalable by using interfaces:
interface LanguageInterface {
sayHello(): string;
}
class Persian implements LanguageInterface {
public sayHello(): string {
return 'درود';
}
}
class French implements LanguageInterface {
public sayHello(): string {
return 'Bonjour';
}
}
class Hello {
public say(lang: LanguageInterface): string {
return lang.sayHello();
}
}
let myHello = new Hello();
console.log(myHello.say(new Persian())); // Output: درود
Advantages of Using Interfaces:
- Scalability: Adding new languages is simple. You just create a new class that implements the LanguageInterface.
- Maintainability: The Hello class remains untouched when new languages are added.
- Extensibility: The system is inherently more flexible and robust.
Key Takeaways:
-
The Open/Closed Principle encourages us to design code that is easy to extend without modifying existing functionality.
-
Abstracting behaviors into interfaces or separate classes is an effective way to follow this principle.
-
This approach results in cleaner, more maintainable, and scalable code.
Liskov Substitution Principle (LSP)
The third principle of SOLID is the Liskov Substitution Principle, abbreviated as LSP. It’s a simple principle, both to understand and to implement.
The formal definition of LSP is:
If S is a subclass of T, objects of type T should be replaceable with objects of type S without altering the behavior of the program.
Understanding LSP
Let’s assume we have a class A:
class A { ... }
We create instances of A to use across the program, as shown below:
x = new A();
// ...
y = new A();
// ...
z = new A();
Now, we want to extend A to add new functionality. We create a subclass B:
class B extends A { ... }
Since B is a subclass of A, we want to use B wherever A was previously used. For example:
x = new B();
// ...
y = new B();
// ...
z = new B();
This is substitution! We are replacing A with B. According to the LSP, this substitution should not introduce errors in the program, and the existing code should not require any changes.
Violating LSP: An Example
Let’s look at a scenario where LSP is violated. Assume we have a Note class that performs operations like creating, updating, and deleting notes:
class Note {
public constructor(id) {
// ...
}
public save(text): void {
// save process
}
}
A user might use this class as follows:
let note = new Note(429);
note.save("Let's do this!");
Now, we want to add a new feature: read-only notes. To do this, we create a subclass ReadonlyNote and override the save method to prevent saving:
class ReadonlyNote extends Note {
public save(text): void {
throw new Error("Can't update readonly notes");
}
}
In the parent class, the save method worked normally, but in the new subclass, it throws an exception to indicate that saving is not allowed.
If we substitute Note with ReadonlyNote in the program:
let note = new ReadonlyNote(429);
note.save("Let's do this!");
What Happens?
The user, unaware of the change, suddenly encounters an unexpected exception when trying to save the note. This forces them to modify their code to handle this exception.
Here, LSP is violated because the ReadonlyNote class changes the behavior of the parent class (Note). As a result, the substitution introduces problems, and the user must adjust their code.
A Better Solution 👌
To avoid this issue, we can create a separate class for writable notes, such as WritableNote. Then, we move the save method to this new class:
class Note {
public constructor(id) {
// ...
}
}
class WritableNote extends Note {
public save(text): void {
// save process
}
}
This ensures that:
- ReadonlyNote does not modify the behavior of Note.
- WritableNote is explicitly responsible for handling save operations.
Now, we can handle read-only and writable notes without any conflict or unexpected behavior.
Key Takeaways:
When extending a class:
- Ensure substitutability: The child class should work seamlessly wherever the parent class is used.
- Avoid altering behavior: Do not override methods in a way that changes the expected behavior of the parent class. For example, if the parent class has a method that returns a number, the child class should not override it to return an array.
By following LSP, we ensure that inheritance enhances functionality without breaking the original design.
Interface Segregation Principle (ISP)
The fourth principle of SOLID is the Interface Segregation Principle (ISP). Its formal definition is:
Classes should not be forced to implement methods they do not use.
Explanation of ISP
The principle emphasizes that interfaces should be designed in such a way that a class implementing an interface is not forced to define irrelevant methods. Unrelated methods should not be grouped together in a single interface. This principle is closely related to the Single Responsibility Principle (SRP), as it also advocates for keeping responsibilities specific and separate.
Example of Violating ISP
Consider the following interface:
interface Animal {
fly(): void;
run(): void;
eat(): void;
}
This interface has three methods: fly, run, and eat. Any class implementing this interface must define all three methods. Now, let’s create a Dolphin class:
class Dolphin implements Animal {
public fly(): boolean {
return false;
}
public run(): void {
// Logic for running
}
public eat(): void {
// Logic for eating
}
}
In this case:
- Dolphins cannot fly, so the fly method is implemented with a dummy return value (false).
- This violates ISP because the Dolphin class is forced to implement a method it doesn’t use.
Applying ISP
To comply with ISP, we should separate the interface into smaller, more specific interfaces. Let’s refactor the code:
interface Animal {
run(): void;
eat(): void;
}
interface FlyableAnimal {
fly(): void;
}
Now, the Dolphin class only implements the methods relevant to it:
class Dolphin implements Animal {
public run(): void {
// Logic for running
}
public eat(): void {
// Logic for eating
}
}
For animals that can fly, like birds, they can implement both Animal and FlyableAnimal interfaces:
class Bird implements Animal, FlyableAnimal {
public run(): void {
// Logic for running
}
public eat(): void {
// Logic for eating
}
public fly(): void {
// Logic for flying
}
}
Benefits of ISP
-
Cleaner Code: Classes only implement methods they actually use.
-
Modularity: Interfaces become smaller and more focused on specific behaviors.’
-
Maintainability: Code is easier to understand, test, and refactor.
-
Reusability: Interfaces and their implementing classes are more reusable across different contexts.
Key Takeaways
-
Avoid grouping unrelated methods in a single interface.
-
Divide larger interfaces into smaller, behavior-specific ones.
-
Classes should only implement the interfaces they need.
By adhering to ISP, we ensure that our code is modular, maintainable, and easier to extend. This results in a more cohesive and structured application, where reusability and refactoring are simpler.
Dependency Inversion Principle (DIP)
The fifth and final principle of SOLID is the Dependency Inversion Principle (DIP). Its formal definition is:
High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.
Breaking It Down
The formal definition might seem a bit abstract, so let’s break it into simpler concepts by understanding the key terms:
1. Low-Level Classes
These are classes responsible for basic operations, such as interacting with the database, reading from a file system, or sending an email.
2. High-Level Classes
These classes handle more complex and specific operations. For example, a reporting class (Log) that saves and reads logs depends on database operations performed by a low-level class.
3. Abstraction
Abstraction refers to abstract classes or interfaces that define a blueprint for behavior. For instance, an Animal abstract class can define general behaviors like eat() or run() that are implemented by specific animals like Cat or Dog.
4. Details
Details refer to the specific implementation of methods and properties in a class.
Problem Without DIP
Let’s look at an example to understand the problem:
class MySql {
public insert() {}
public update() {}
public delete() {}
}
class Log {
private database;
constructor() {
this.database = new MySql();
}
}
In this code:
-
Log is a high-level class.
-
MySql is a low-level class, responsible for basic database operations.
Here, Log directly depends on MySql. If we want to switch to a different database (e.g., MongoDB), we must modify the Log class. This creates two problems: 1. Any changes to MySql may require corresponding changes in Log. 2. Reusability suffers, as Log is tightly coupled to MySql.
Solution: Applying DIP
To solve this, we introduce an interface (abstraction) to decouple the high-level class (Log) from the low-level class (MySql). This allows us to change the implementation of the database without affecting Log.
Step 1: Define an Interface
interface Database {
insert(): void;
update(): void;
delete(): void;
}
This interface acts as a contract, defining the methods required for database operations.
Step 2: Implement the Interface
Now, the low-level classes implement the Database interface:
class MySql implements Database {
public insert() {}
public update() {}
public delete() {}
}
class FileSystem implements Database {
public insert() {}
public update() {}
public delete() {}
}
class MongoDB implements Database {
public insert() {}
public update() {}
public delete() {}
}
Each low-level class provides its specific implementation of the insert, update, and delete methods.
Step 3: Update the High-Level Class
The high-level class (Log) depends on the Database interface rather than a specific implementation:
class Log {
private db: Database;
public setDatabase(db: Database): void {
this.db = db;
}
public update(): void {
this.db.update();
}
}
In this design:
-
Log is decoupled from specific database implementations.
-
Any class implementing the Database interface can be used with Log.
Step 4: Using the Solution
We can now switch databases easily without modifying the Log class:
let logger = new Log();
logger.setDatabase(new MongoDB());
logger.update();
logger.setDatabase(new FileSystem());
logger.update();
logger.setDatabase(new MySql());
logger.update();
Benefits of DIP
-
Flexibility: High-level classes are not tied to specific implementations.
-
Reusability: High-level classes can work with any implementation that follows the abstraction.
-
Maintainability: Changes to low-level classes do not affect high-level classes.
Key Takeaways
-
Decouple dependencies: Use abstractions (interfaces) to reduce direct dependencies between high-level and low-level classes.
-
Plan for change: With DIP, you can replace low-level details without affecting high-level logic.
-
Be mindful: Overusing abstractions can make your code overly complex. Apply this principle only where it adds value.
By following the Dependency Inversion Principle, you can create more modular, maintainable, and scalable software systems.