What are SOLID principles of programming?
We're going to dive into the world of SOLID principles and explore how we can implement them in JavaScript
Yogini Bende
Aug 14, 2024 • 11 min read
One thing every developer is most fascinated about is writing clean and maintainable code, yet we all face challenges in doing that at some or the other level. How to build software that is easy to understand, flexible to changes, and maintainable is a common point of discussion for all of us.
In my earlier days as a developer, acronyms like SOLID, KISS, DRY, and YAGNI used to scare me a lot. I remember facing interviews and answering these questions theoretically. But to understand how powerful these principles are, you need to implement them while you code.
Today, in this article, I will be talking about SOLID principles and how we are implementing them in Peerlist.
A little bit of theory about SOLID Principles
Don’t worry, I am going to explain the practical use case as well but before jumping right in, let me first credit the person who introduced these.
SOLID principles provide a set of guidelines for writing solid, robust, and maintainable software. Originally introduced by Robert C. Martin and mostly associated with Object Oriented Programming, they are very powerful to make your code and life better!
Now, let's dive into the world of SOLID principles and explore how they can be implemented in JavaScript.
What SOLID acronym stands for?
SOLID is an acronym that stands for five important principles: Single Responsibility Principle (SRP) Open/Closed Principle (OCP) Liskov Substitution Principle (LSP) Interface Segregation Principle (ISP) Dependency Inversion Principle (DIP).
Let's break down each principle and understand how they contribute to writing better code.
Single Responsibility Principle (SRP)
The Single Responsibility Principle (SRP), as the name suggests, a class or a function should have only one reason to change. In other words, a class should have a single responsibility or job. This helps keep the codebase more modular, easier to maintain, and readable.
Let's take an example
Imagine you're building a simple blog application. To start the app development, you created a class Blog
. You added a logic to display blog posts in this class. So far, good!
Now comes a feature request, where you need to add user authentication as well. Where you will add it?
For shipping faster, we might add this feature to the same class/function. But that is where this SRP principle fails. You could separate these responsibilities into two distinct classes: PostManager
and Authenticator
.
Example:
// Bad: Single class handling multiple responsibilities
class Blog {
displayPosts() {
// ...
}
authenticateUser() {
// ...
}
}
// Good: Separate classes with single responsibilities
class PostManager {
displayPosts() {
// ...
}
}
class Authenticator {
authenticateUser() {
// ...
}
}
By following to the SRP, you ensure that changes in one part of your application won't impact the unrelated parts.
When Peerlist was small and we just had a profile feature, we used to have a single file to make all the DB calls, called db.js
. This worked fine until features started adding up. The file would have blown up by size and by chaos with this approach approach. We added SRP and created separate files for separate contexts. For e.g. scrollDb.js, inboxDb.js, userDb.js, authDb.js. I think you got it now!
Open/Closed Principle (OCP)
The Open/Closed Principle emphasizes that software entities (classes, modules, functions) should be open for extension but closed for modification. In simpler terms, you should be able to add new features or behaviors without altering existing code.
In JavaScript, this can be achieved using techniques like inheritance and polymorphism. Instead of modifying existing code, you create new classes that extend or implement existing ones.
class TextFormatter {
formatToUppercase(text) {
return text.toUpperCase();
}
formatWithPrefix(text, prefix) {
return `${prefix} ${text}`;
}
}
// With OCP
class TextFormatter {
format(text) {
// Default implementation: no formatting
return text;
}
}
class UppercaseFormatter extends TextFormatter {
format(text) {
return text.toUpperCase();
}
}
class PrefixFormatter extends TextFormatter {
constructor(prefix) {
super();
this.prefix = prefix;
}
format(text) {
return `${this.prefix} ${text}`;
}
}
In the above example, in original code, we have a "TextFormatter" class that can format text in different ways. It can make text uppercase, add a prefix to it, or leave it as is.
Now, let's say we want to add a new way to format the text - we want to make it lowercase.
In the first example, we make changes to the existing "TextFormatter" class to add this new formatting option. This is not in line with the Open/Closed Principle, as we are altering the existing code.
In the second example, we follow OCP correctly and created a base class called "TextFormatter" that has a common method called "format." This method provides a default behavior, which is to leave the text unchanged.
Then, we create new classes like "UppercaseFormatter," "PrefixFormatter," and "LowercaseFormatter" that inherit from "TextFormatter." Each of these new classes provides its own way of formatting text. This allows us to add new formatting options without changing the existing code, following the Open/Closed Principle.
Liskov Substitution Principle (LSP)
The Liskov Substitution Principle states that objects of a derived class should be able to replace objects of the base class without affecting the correctness of the program. In simple terms, if a class is a subtype of another class, it should be able to be used interchangeably with its parent class.
Sounds bookiesh right? Lets go through the example to understand this better.
// Base function for calculating the area of a shape
function calculateArea(shape) {
return shape();
}
// Define functions for calculating areas of specific shapes
function calculateRectangleArea(length, width) {
return length * width;
}
function calculateCircleArea(radius) {
return Math.PI * radius ** 2;
}
function genericShape() {
return 0;
}
// Rectangle is a specific shape
function rectangleShape() {
return calculateRectangleArea(5, 10);
}
// Circle is another specific shape
function circleShape() {
return calculateCircleArea(3);
}
console.log(calculateArea(genericShape)); // 0
console.log(calculateArea(rectangleShape)); // 50
console.log(calculateArea(circleShape)); // ~28.27
Now let's go through the Liskov Substitution Principle (LSP) using functions in a simple way.
Imagine we have a job to calculate the area of different shapes like rectangles, circles, and squares. Each shape has its own way of calculating the area, right?
Now, let's say we have a main function called calculateArea. This function should work with any shape and give us the correct area, no matter which shape we're dealing with. This is where LSP comes in.
In LSP, it means that if you have a way to calculate the area of any shape, you can use that same way for all shapes without causing any problems.
Here's how it works:
We have a main function calculateArea that takes another function as input. This other function should know how to calculate the area of a specific shape.
We have separate functions for calculating the area of different shapes: calculateRectangleArea for rectangles, calculateCircleArea for circles, etc. Now, here's the key part: Each of these shape functions works in its own unique way to calculate the area of its respective shape.
We also have a generic shape function called genericShape, which returns 0 because it's a basic shape with no specific area calculation. We can use the calculateArea function with any shape function.
For example, if we want to find the area of a circle, we use calculateArea(calculateCircleArea).
And the magic is, no matter which shape function we use with calculateArea, it correctly calculates the area for us. That's what Liskov Substitution Principle is all about - being able to use different shapes interchangeably without causing problems.
Interface Segregation Principle (ISP)
The Interface Segregation Principle suggests that clients should not be forced to depend on interfaces they do not use. In simple words, it's better to have smaller, specific interfaces rather than a large, monolithic one.
Let's consider an example, imagine you have a set of functions that perform various actions in a text editor. Some users of the text editor may only need specific functionalities, so we want to follow the ISP to keep our functions modular.
// Here is a large, monolithic interface with multiple methods
function TextEditor() {
this.insertText = function () {
// ...
};
this.deleteText = function () {
// ...
};
this.formatText = function () {
// ...
};
}
// User 1 only needs basic text insertion and deletion
function User1(editor) {
editor.insertText();
editor.deleteText();
}
// User 2 needs advanced formatting in addition to basic actions
function User2(editor) {
editor.insertText();
editor.deleteText();
editor.formatText();
}
// After implementing ISP
function BasicEditor() {
this.insertText = function () {
// ...
};
this.deleteText = function () {
// ...
};
}
function AdvancedEditor() {
this.formatText = function () {
// ...
};
}
function User1(editor) {
editor.insertText();
editor.deleteText();
}
function User2(editor) {
editor.insertText();
editor.deleteText();
editor.formatText();
}
Imagine you have a collection of actions in a text editor, like writing, deleting, formatting text. Different people use the text editor for different purposes, some with basic and some with advanced features.
Now, if we implement ISP, we should make things simple and not force anyone to deal with things they don't need. In our case, it means we shouldn't make everyone use all the text editor functions if they don't want to.
Hence, we started with a big set of text editor functions called TextEditor, which has everything - writing, deleting, and formatting. Now, we have two people - User 1 and User 2. User 1 only wants to write and delete text, but User 2 wants everything, including formatting. In the first part of our example, both users are using the same TextEditor. But User 1 has to deal with formatting functions even though they don't need them.
So, we fix it in the second part by creating different editors - BasicEditor and AdvancedEditor. Now, User 1 can use the BasicEditor and User 2 can use both BasicEditor and AdvancedEditor because they want all the features.
That's how the Interface Segregation Principle works. It's about giving people just what they need, and not making them deal with things they don't need.
Dependency Inversion Principle (DIP)
Dependency Inversion Principle (DIP) emphasizes that high-level modules should not have dependency on low-level modules. Instead, both should depend on abstractions. Additionally, abstractions should not depend on details; details should depend on abstractions. In simpler terms, it promotes a flexible architecture.
Let's understand with an example -
Imagine you have a high-level function that sends messages, and it depends on a low-level function for the actual sending. Following DIP means that both the high-level and low-level functions should depend on an abstraction (interface), and not on each other.
function sendEmail(message) {
console.log(`Email sent: ${message}`);
}
function sendMessage(message) {
sendEmail(message);
}
// Now, let's say we want to change how we send messages, for example, using SMS instead of email.
// In the current setup, we need to modify the high-level function, which is against DIP.
function sendMessageService(message) {
this.send = function () {};
}
function EmailService() {
sendMessageService.call(this);
this.send = function (message) {
console.log(`Email sent: ${message}`);
};
}
function SMSService() {
sendMessageService.call(this);
this.send = function (message) {
console.log(`SMS sent: ${message}`);
};
}
function sendMessage(message, service) {
service.send(message);
}
const emailService = new EmailService();
const smsService = new SMSService();
sendMessage('Hello, email!', emailService); // Email sent: Hello, email!
sendMessage('Hello, SMS!', smsService); // SMS sent: Hello, SMS!
Initially, we have a high-level function sendMessage that directly depends on a low-level function sendEmail. This tightly couples the high-level and low-level functions.
To follow the Dependency Inversion Principle, we introduce an sendMessageService which is an abstraction that both low-level services (EmailService and SMSService) implement. This made the high-level function and the low-level functions flexible to use.
The high-level function sendMessage now depends on the abstraction, allowing users to choose the service they want (email or SMS) without changing the high-level function. This implementation of the Dependency Inversion Principle, makes the code more flexible and maintainable.
Conclusion
In summary, the SOLID principles help create code that is:
Maintainable: Easier to understand, modify, and extend the codebase.
Flexible: It can adapt to changing requirements and new features without causing widespread code changes.
Testable: Smaller, more focused which is easier to test in isolation.
Reusable: Code adhering to SOLID principles tends to be more modular and, therefore, more reusable.
Less Prone to Bugs: By encouraging better design practices, SOLID principles reduce the likelihood of introducing bugs when making changes or adding new features.
If you are reading about SOLID principles for the first time, you might find them too difficult to understand and implement. But implementing them one by one will significantly improve the quality of your code. By keeping your codebase modular, flexible, and easy to maintain, you'll be better developer in handling changes and build software that stands the test of time.
These principles aren't rigid rules; they are guidelines that help us build better software!
Thanks a lot for reading this article! If you are someone looking for jobs, do checkout our Jobs page and Signup to Peerlist to be a part of this in-credible Professional Network for People In Tech.