The SOLID principles are a set of design principles for writing maintainable and scalable OOP softwares. They are guidelines rather than strict rules, aimed at producing code that is easier to understand, maintain, and extend over time.
⭐ S :Single responsibility ⭐ O : Open-Close Principle ⭐ L : Liskov Substitution principle ⭐ I : Interface Segregation Principle (ISP) ⭐ D : Dependency Inversion Principle (DIP)
Single responsibility
Single responsibility principle is a simple principle, that tells you that a class should have a single primarily responsibility, and should only have one reason to change , and that reason should be somehow related to its responsibility
Let's imagine this Analogy
Imagine a restaurant where the chef is responsible for everything: cooking the food, serving it to the customers, handling the cash register, and even cleaning up afterward. This setup makes the chef extremely busy, increases the chances of mistakes (like burning the food while serving other customers), and makes it hard for the chef to excel in any specific area. The restaurant’s efficiency and quality of service would likely suffer.
If we apply the Single Responsibility Principle, we would divide these tasks among several people: ✅ A chef to cook the food, ✅ Servers to take orders and serve food, ✅ A cashier to handle payments, ✅ A cleaning crew to clean up.
Each person is responsible for one specific area, making the whole operation smoother, more efficient, and capable of delivering better service.
Applying SRP in JavaScript: User Management System
Now, let’s translate this analogy into a JavaScript example by considering a simple User Management System. We’ll start with a class that violates the SRP by having multiple responsibilities and then refactor it to adhere to the principle.
Before: A Class with Multiple Responsibilities
Also known as the God Object Spaghetti all over the place.
In this setup, the UserManager
class is like the chef in the restaurant analogy. It’s responsible for handling user data persistence, email notifications, and logging user activity — too many responsibilities for one class.
After: Applying SRP
Let’s refactor our system by breaking down the UserManager
class into smaller classes, each with a single responsibility.
Now, each class has a single responsibility:
✅ UserStorage
manages saving user data,
✅ UserEmailer
handles sending emails,
✅ UserLogger
takes care of logging activity.
This not only makes each class simpler and more focused but also improves the system’s maintainability and scalability.
If we need to change how we log user activity, for example, we only need to modify the
UserLogger
class, reducing the risk of inadvertently affecting unrelated functionality.
Separation of concerns
Single Responsibility Principle (SRP) directly promotes the concept of separation of concerns. Separation of concerns is a design principle for separating a computer program into distinct sections, such that each section addresses a separate concern. By applying SRP, developers ensure that each class, module, or function in a software system has one, and only one, reason to change, which typically means it’s focused on a single concern.
The SRP’s focus on giving a software component (like a class or module) only one responsibility helps to isolate that component’s concerns from those of other components.
This isolation simplifies understanding the system, as developers can focus on one aspect without needing to understand all other aspects immediately.
Open-Close Principle
The Open/Closed Principle (OCP) is one of the five SOLID principles of object-oriented design, which states that software entities (such as classes, modules, functions, etc.) should be open for extension, but closed for modification. This means that the behavior of a module can be extended without modifying its source code.
Why don’t I want to change the object ?
Modifying existing objects, especially those that have already been deployed, tested, or shipped, can introduce several risks and complications into a software system
Breaking Changes
- Introducing Bugs: Modifying an object’s behavior can inadvertently introduce bugs, especially if the changes impact areas of the codebase that rely on the object’s current behavior. These bugs might not be immediately apparent and could lead to unexpected system failures.
- Violation of Contracts: Objects often have implicit or explicit contracts with the rest of the system regarding their behavior. Changing an object can violate these contracts, leading to errors in the system’s operation.
2. Regression Testing
- Increased Testing Overhead: Every change to an object necessitates thorough regression testing to ensure that no existing functionality has been broken. This increases the testing burden and the risk of missing something, especially in complex systems.
- Hidden Dependencies: Objects might have hidden dependencies that are not immediately obvious. Modifying them without fully understanding these dependencies can cause issues in seemingly unrelated parts of the system.
3. Maintenance Complexity
- Understanding the Impact: Developers might not fully understand the impact of modifying an existing object, especially if documentation is lacking or the system’s complexity has grown over time.
- Code Fragility: Frequent modifications can make the code more fragile and harder to maintain, as the complexity and interdependencies between objects increase.
4. Customer Impact
- Unexpected Behavior for Users: Changes to objects could alter the system’s behavior in ways that users do not expect or desire, potentially disrupting their work or causing data loss.
- Compatibility Issues: If the software is part of a larger ecosystem, changes could lead to compatibility issues with other systems or plugins that rely on the software’s current behavior.
5. Deployment and Rollback Challenges
- Deployment Risks: Deploying changes to production environments always carries the risk of introducing issues that were not encountered during testing, potentially affecting all users of the system.
- Difficulty in Rollback: If the changes cause significant issues, rolling back to a previous version might be challenging, especially if data migrations or other non-reversible operations were involved.
In some cases you can do so
In some cases it’s fine, if you are in complete control of your code and there are no many dependencies to it, you can modify the code and still get along with it, but most likely, you might upset your senior and lose respect of your peers by modifying an object to feet your logic.
Example
Open Close principle being broken.
Let’s illustrate the Open/Closed Principle, focusing on a scenario where we might want to apply different types of filters to a product list.
❓ But what if you want to filter by size or color and size? ❓ What if you want to filter by 5 different criteria? ❓ you will have to write new methods for each filter!
State space explosion is knocking on the door
A state space explosion refers to the rapid, often exponential increase in the number of possible states a system can be in, due to a combination of variables, configurations, or interactions within the system.
This phenomenon is particularly relevant in complex software systems, where even seemingly simple changes can lead to a dramatic increase in the number of possible states, making the system difficult to manage, understand, and test.
❓ And this is not good for the open close principle! ❓ because you will have to modify the class to add a new filter
Specification pattern
Let's use the specification pattern to solve this problem.
The Specification pattern is a particular software design pattern that allows for business rules to be recombined by chaining the business rules together using boolean logic.
A specification is typically implemented as a class with a single method that checks whether or not a particular object meets the specification. This method returns a Boolean value:
true
if the object satisfies the criteria defined by the specification, andfalse
otherwise.
Nuanced version of OCP on a function
Sometimes you have an already implemented function that you would like to change. This might not be easy if you still want to adhere to the OCP. You can have some work around that would get you close to the idea behind OCP.
Let's say you have the following function
This function’s job is to get all bookings given a Transaction and a BookingID
The requirement changes, and now you have to find the booking only if it’s not marked as deleted. (
assuming that you have a soft deletion implemented on your database level.
)
Here's how you could modify the
find
function
✅ An optional parameter includeDeleted
is added, which defaults to false
. This maintains the function’s original behavior for existing callers (not fetching deleted records) unless they explicitly choose to include them.
✅ The SQL query is conditionally constructed based on the value of includeDeleted
. If includeDeleted
is true
, the function behaves as before. If false
, it adds the condition to exclude deleted records.
Advantages of This Approach
✅ Backward Compatibility: ✅ By using a default parameter value, the change is backward-compatible. Existing calls to the function will behave as before, which aligns with the principle of not altering existing behavior. ✅ Extension for New Use Cases: ✅ The function is extended to support new use cases (excluding deleted records) without altering the logic of the original function. This extension allows new functionality to be added without changing the function’s core purpose.
The example of adding an optional parameter to a function to change its behavior adheres to the spirit of OCP in that it does not modify the existing behavior for current clients, (thanks to default parameter values) and allows new behavior to be introduced.
However, it does technically modify the function's signature by adding a new parameter, which could be seen as a deviation from a strict interpretation of "closed for modification."
Liskov Substitution principle
A concept that sounds like it belongs in a physics textbook but is actually one of the coolest tricks in the programming playbook.
Imagine a place called, The gym, offering a general training program focused on the basics of web development: HTML, CSS, and JavaScript. This program was designed to make anyone competent in creating simple, static websites. As the demand for more specialized skills grew, The gym expanded its offerings to include specialized training programs for both frontend and backend development. These new programs built upon the foundational knowledge of HTML, CSS, and JavaScript but went further to dive deep into frontend and backend frameworks.
These specialized programs adhere to the principles of LSP by ensuring that anyone who completes them can handle the tasks of the original general web development program, plus more.
Breaking the Liskove substitution
Let's imagine you have an
httpService
class that interact with a given endpoint.
With the httpService above, you are almost sure to get a wife or husband or both
The
httpService
class provided is designed to be a generic handler for HTTP operations like GET, POST, DELETE, and PATCH against a specified endpoint. While this design is flexible and can be used for various types of resources, it could potentially break the Liskov Substitution Principle (LSP) in certain scenarios.
Imagine you have a specific type of resource that doesn't support all the operations defined in
httpService
. For example, consider aReadOnlyResource
that should only allow GET operations because it represents a resource that should not be modified.
What about the ReadOnlyResources ?
If you create a subclass of httpService
for ReadOnlyResource
, you would inherit methods like delete
, create
, and update
, which are not applicable for a read-only resource.
If these methods are called on the
ReadOnlyResource
service, it would either result in errors or require the subclass to override these methods to disable them
(e.g., by throwing an exception), which violates LSP. LSP states that subclasses should be substitutable for their base classes without altering the correctness of the program.
While overriding the methods in the ReadOnlyResource
class to throw errors for unsupported operations might initially seem like a good way to enforce its read-only nature, this approach can actually violate the Principle of Least Surprise in several ways:
💡 Unexpected Behavior for Subclass:
✅ Users of the httpService
class expect its subclasses to behave consistently. When a subclass like ReadOnlyResource
throws errors for methods that are logically part of its interface (due to inheritance), it introduces unexpected behavior. Consumers might not anticipate that a subclass of a service designed to handle HTTP operations would throw errors for certain operations.
💡 Inheritance Misuse:
✅ When a subclass cannot fulfill the contract of its superclass (in this case, supporting all HTTP operations defined by httpService
), it suggests a violation of the Liskov Substitution Principle (LSP), Which in itself can be surprising to developers expecting polymorphic behavior. A subclass that throws exceptions for methods it inherits suggests that inheritance might not be the best relationship model.
💡 Error Handling Overhead:
✅ From a practical standpoint, requiring consumers of the ReadOnlyResource
to wrap calls in try-catch blocks for operations that are common to the httpService
interface, but not supported by the subclass, increases the complexity and error-handling overhead of the code
A More elegant Approach
To better adhere to the Principle of Least Surprise, it’s beneficial to use composition over inheritance or to segregate the interface in such a way that the intent of the ReadOnlyResource
are clear from its API. Avoiding the inheritance of methods that it cannot support:
First, define a
ReadOnlyAPIClient
that includes only the methods for fetching data (getAll
andget
). This class provides a clear contract that it is intended solely for read operations.
Extend ReadOnlyAPIClient with a ReadWriteAPIClient
Next, extend ReadOnlyAPIClient
with a ReadWriteAPIClient
that adds create, update, and delete operations. This subclass is explicitly designed for scenarios where full CRUD functionality is required, making its capabilities and intended use clear.
Why is this approach better ?
Clarity and Explicitness: The separation into ReadOnlyAPIClient
and ReadWriteAPIClient
makes the capabilities of each client explicit. Consumers of these classes have a clear understanding of what operations are supported, reducing the chance of unexpected behavior and adhering to the Principle of Least Surprise.
Adherence to LSP and ISP: This design adheres to the Liskov Substitution Principle, as a ReadWriteAPIClient
can be used anywhere a ReadOnlyAPIClient
is expected without altering the correctness of the program. It also follows the Interface Segregation Principle by not forcing clients that only need read access to deal with a broader interface that includes write operations.
Interface Segregation Principle (ISP)
Hang tight, my fellow coder! We're almost through the intricacies of principles. Soon, you'll be doing a mojoman dance knowing these design patterns. Trust me, it's worth it! 🎉😄 And if you're starting to think you'd have been better off with a stethoscope instead of a keyboard, my apologies for the unexpected journey. We are stuck together and need to understand this stuffs to be good at our Job.
Interface Segregation aimed at reducing the unnecessary dependencies among classes. It states that no client should be forced to depend on methods it does not use. Essentially, ISP advocates for creating specific interfaces rather than one general-purpose interface.
The principle addresses the issues that arise when a class depends on interfaces it does not use, leading to tight coupling and making the system harder to refactor, change, or even understand.
Imagine you’re in a kitchen, and someone asks you for a simple butter knife to spread jam on their toast. Instead, you hand them a Swiss army knife labeled as the httpService
class ( getAll
, get
, delete
, create
, and update
. ). Suddenly, they’re not just spreading jam, they’ve got access to a can opener, a screwdriver, a rocket launcher, and even Linus Torvalds’ source code for both git and Linux!
Now, unless you’re Chuck Norris preparing breakfast, you probably don’t need all that firepower. Similarly, when you use the httpService
class to create a ReadOnlyResource
class without adhering to interface segregation principles, you end up with a ReadOnlyResource
that’s burdened with unnecessary methods like delete
, create
, and update
, even though it’s meant for read-only tasks.
Dependency Inversion Principle (DIP)
Imagine you’re at a birthday party, and you’ve been tasked with blowing up balloons. You could blow them up using your own breath, but that would be exhausting and inefficient. Instead, someone hands you a balloon pump. This pump is a tool provided to you from the outside, making your job easier and more efficient. This scenario illustrates the core concept of Dependency Injection (DI) in a simple, everyday context.
Dependency Injection is like being given the right tools (in this case, the balloon pump) rather than having to create them yourself (using your breath).
In software development, DI involves providing components (like classes or functions) with the dependencies (services, tools, configurations) they need from an external source rather than having them construct those dependencies themselves.
NestJS, a framework for building efficient and scalable server-side applications in Node.js, heavily leverages the Dependency Injection (DI) principle to promote loose coupling, modularity, and ease of testing. By integrating DI at its core, NestJS allows developers to build applications that are more maintainable and scalable.
Alright, buckle up baby chuck! We've made it. Now it's time to dive headfirst into the world of design patterns! 🎉💻🤘