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 responsibilityO : Open-Close PrincipleL : Liskov Substitution principleI : 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.

class ECommerceSystem {
  constructor(order) {
    this.order = order;
  }
 
  placeOrder() {
    console.log(`Order for ${this.order.productName} placed.`);
    // Logic to place an order
  }
 
  makePayment(amount) {
    console.log(`Payment of $${amount} made.`);
    // Logic to process payment
  }
 
  sendEmail(emailType) {
    console.log(`Sending ${emailType} email.`);
    // Logic to send different types of emails
  }
 
  updateInventory(productId, quantity) {
    console.log(`Updating inventory for product ${productId}.`);
    // Logic to update inventory
  }
 
  calculateShippingCosts(destination) {
    console.log(`Calculating shipping costs to ${destination}.`);
    // Logic to calculate shipping costs
  }
 
  applyDiscount(code) {
    console.log(`Applying discount code ${code}.`);
    // Logic to apply discount code
  }
 
  validateCoupon(code) {
    console.log(`Validating coupon code ${code}.`);
    // Logic to validate coupon
  }
 
  logOrderActivity(activityType) {
    console.log(`Logging order activity: ${activityType}.`);
    // Logic to log order activity
  }
}
 

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.

class OrderManager {
  constructor(order) {
    this.order = order;
  }
 
  placeOrder() {
    console.log(`Order for ${this.order.productName} placed.`);
    // Logic to place an order
  }
 
  applyDiscount(code) {
    console.log(`Applying discount code ${code}.`);
    // Logic to apply discount code
  }
 
  validateCoupon(code) {
    console.log(`Validating coupon code ${code}.`);
    // Logic to validate coupon
  }
}
 
class PaymentProcessor {
  makePayment(amount) {
    console.log(`Payment of $${amount} made.`);
    // Logic to process payment
  }
}
 
class EmailService {
  sendEmail(emailType) {
    console.log(`Sending ${emailType} email.`);
    // Logic to send different types of emails
  }
}
 
class InventoryManager {
  updateInventory(productId, quantity) {
    console.log(`Updating inventory for product ${productId}.`);
    // Logic to update inventory
  }
}
 
class ShippingCalculator {
  calculateShippingCosts(destination) {
    console.log(`Calculating shipping costs to ${destination}.`);
    // Logic to calculate shipping costs
  }
}
 
class ActivityLogger {
  logOrderActivity(activityType) {
    console.log(`Logging order activity: ${activityType}.`);
    // Logic to log order activity
  }
}
 

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.

 
enum Colors {
  red = 'Red',
  green = 'green',
  blue = 'blue',
}
 
enum Size {
  small = 'Small',
  medium = 'Medium',
  large = 'Large',
}
 
  
 
class Product {
  constructor(
	  public name: string, 
	  public color: Colors, 
	  public size: Size
	  ) {}
}
 
// Initial implementation of the filterByColor method
class ProductFilter {
  filterByColor(products: Product[], color: Colors) {
    return products.filter((product) => product.color === color);
  }
}
 
// Create our dummy products
const pen = new Product('Pen', Colors.red, Size.small);
const book = new Product('Book', Colors.green, Size.medium);
const table = new Product('Table', Colors.blue, Size.large);
const chair = new Product('Chair', Colors.red, Size.medium);
 
const products = [pen, book, table, chair];  
 
const productsFilteredByColor=
	  new ProductFilter().filterByColor(products, Colors.red)
	  );
 
/* Well, you have made it 🎉this might have gained you a raise from your boss.
  [
  Product { name: 'Pen', color: 'Red', size: 'Small' },
  Product { name: 'Chair', color: 'Red', size: 'Medium' }
  ]
*/
 

❓ 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, and false otherwise.

enum Colors {
  red = 'Red',
  green = 'green',
  blue = 'blue',
}
 
enum Size {
  small = 'Small',
  medium = 'Medium',
  large = 'Large',
}
 
abstract class Specification<T> {
  /**
   * because we want to enforce that the isSatisfied method
   * is implemented in the derived class
   */
  abstract isSatisfied(item: T): boolean;
}
 
abstract class Filter<T> {
  // This abstract method will take an array of items and a specification
  abstract filter(items: T[], spec: Specification<T>): T[];
}
 
class Product {
  constructor(public name: string, public color: Colors, public size: Size) {}
}
 
class ColorSpecification implements Specification<Product> {
  constructor(private color: Colors) {}
 
  isSatisfied(item: Product) {
    return item.color === this.color;
  }
}
 
class SizeSpecification implements Specification<Product> {
  constructor(private size: Size) {}
 
  isSatisfied(item: Product) {
    return item.size === this.size;
  }
}
 
class AndSpecification<T> implements Specification<T> {
  constructor(private specs: Specification<T>[]) {}
  isSatisfied(item: T) {
    return this.specs.every((spec) => spec.isSatisfied(item));
  }
}
 
class ProductFilter implements Filter<Product> {
  filter(items: Product[], spec: Specification<Product>) {
    return items.filter((item) => spec.isSatisfied(item));
  }
}
 
const products = [
  new Product('Pen', Colors.red, Size.small),
  new Product('Book', Colors.green, Size.medium),
  new Product('Table', Colors.blue, Size.large),
  new Product('Chair', Colors.red, Size.small),
];
 
const colorAndSizeSpec = new AndSpecification([
  new ColorSpecification(Colors.red),
  new SizeSpecification(Size.small),
]);
 
const product = new ProductFilter();
 
const filteredProducts = product.filter(products, colorAndSizeSpec);
 
console.log(filteredProducts);
 
/**
   * Now, you definetly deserve a raise, 
   * you have implemented the specification pattern
   * Your senior will pat you on the back and say "Good job"
Output: 
	[
      Product { name: 'Pen', color: 'Red', size: 'Small' },
      Product { name: 'Chair', color: 'Red', size: 'Small' }
    ]
   */
 

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

public async find(tx: Transaction, id: BookingID): Promise<Booking | null> {
    const maybeRow = await tx.oneOrNone<Row>(
      'SELECT * FROM bookings WHERE id = $(id) ',
      { id },
    )
    
    return maybeRow ? rowToDomain(maybeRow) : null
  }

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

public async find(
tx: Transaction, 
id: BookingID, 
includeDeleted: boolean = false): Promise<Booking | null> {
  
  const query = includeDeleted 
    ? 'SELECT * FROM bookings WHERE id = $(id)' 
    : 'SELECT * FROM bookings WHERE id = $(id) AND NOT is_deleted';
 
  const maybeRow = await tx.oneOrNone<Row>(query, { id });
 
  return maybeRow ? rowToDomain(maybeRow) : null;
}

✅ 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.

class httpService {
 
constructor(public endpoint: string) {}
 
  getAll<T>() {
    return apiClient.get<T[]>(this.endpoint)
  }
 
  get<T>(id: number | string) {
    return apiClient.get<T>(`${this.endpoint}/${id}`)
  }
 
  delete(id: string | number) {
    return apiClient.delete(`${this.endpoint}/${id}`)
  }
 
  create(data: object) {
    return apiClient.post(this.endpoint, data)
  }
 
  update(id: string | number, data: { [key: string]: string }) {
    return apiClient.patch(`${this.endpoint}/${id}`, data)
  }
}

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 a ReadOnlyResource 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.

class ReadOnlyResource<T> extends httpService {
  constructor(endpoint: string) {
    super(endpoint);
  }
 
  // Override the create method to prevent modification
  create(data: object): never {
    throw new Error("Nop, nott supported for read-only resources.");
  }
 
  // Override the update method to prevent modification
  update(id: string | number, data: { [key: string]: string }): never {
    throw new Error("Nop, not supported for read-only resources.");
  }
 
  // Override the delete method to prevent modification
  delete(id: string | number): never {
    throw new Error("Nop, not supported for read-only resources.");
  }
}
 

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 and get). This class provides a clear contract that it is intended solely for read operations.

class ReadOnlyAPIClient {
 
  constructor(public endpoint: string) {}
 
  getAll<T>(): Promise<T[]> {
    return apiClient.get<T[]>(this.endpoint);
  }
 
  get<T>(id: number | string): Promise<T> {
    return apiClient.get<T>(`${this.endpoint}/${id}`);
  }
}

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.

class ReadWriteAPIClient extends ReadOnlyAPIClient {
  create(data: object): Promise<any> {
    return apiClient.post(this.endpoint, data);
  }
 
  update(id: string | number, data: { [key: string]: any }): Promise<any> {
    return apiClient.patch(`${this.endpoint}/${id}`, data);
  }
 
  delete(id: string | number): Promise<any> {
    return apiClient.delete(`${this.endpoint}/${id}`);
  }
}
 

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! 🎉💻🤘