There are 5 SOLID Principles that is used in software development that helps the developers to maintain, test and extensible the code as it grows.

Before we jump into know each of the S.O.L.I.D acronyms and why we need to apply these principles into our project, first we should know what is SOLID stands for.

S - Single Responsibility Principle

Its is the first of the five design principle in object oriented design. It states that a class should have only one reason to change, meaning it should have only one job or responsibility. This principle helps make the code more modular, easier to understand, maintain, and less prone to errors. For example, if you have a class that handles both user authentication and logging, it violates the SRP. Instead, you should split it into two separate classes: one for authentication and one for logging. This way, changes in authentication logic don’t affect logging and vice versa.

If you see the below example it violates the Single Responsibility Principle

class User {
    constructor(name, email) {
        this.name = name;
        this.email = email;
    }

    getUserInfo() {
        return `Name: ${this.name}, Email: ${this.email}`;
    }

    printUserInfo() {
        console.log(this.getUserInfo());
    }
}

const user = new User("John Doe", "john.doe@example.com");
user.printUserInfo();

As you could see , above class does two things, one is that it gets the user-info and printout user information. Two methods does two different things. Instead we could separate the two functionality into two classes. Such as

class User {
    constructor(name, email) {
        this.name = name;
        this.email = email;
    }

    getUserInfo() {
        return `Name: ${this.name}, Email: ${this.email}`;
    }
}

class UserPrinter {
    printUserInfo(user) {
        console.log(user.getUserInfo());
    }
}

const user = new User("John Doe", "john.doe@example.com");
const userPrinter = new UserPrinter();
userPrinter.printUserInfo(user);

Now, the User class has a single responsibility: managing user data. The UserPrinter class is responsible for printing user information. This way, each class has only one reason to change, adhering to the Single Responsibility Principle.

O - Open / Closed Principle

The Open/Closed Principle (OCP) is another one of the SOLID principles of object-oriented design. It states that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. This means you should be able to add new functionality to a class without changing its existing code.

Here’s an example in JavaScript to illustrate this principle.

Suppose we initially have a Discount class that applies different types of discounts to a product:


class Discount {
    applyDiscount(product, type) {
        if (type === 'percentage') {
            return product.price - (product.price * 0.10);
        } else if (type === 'fixed') {
            return product.price - 10;
        } else {
            return product.price;
        }
    }
}

const product = { name: "Laptop", price: 1000 };
const discount = new Discount();
console.log(discount.applyDiscount(product, 'percentage')); // 900
console.log(discount.applyDiscount(product, 'fixed')); // 990

In this example, if we need to add a new type of discount, we have to modify the applyDiscount method, which violates the OCP.

To adhere to the OCP, we can refactor the code by using inheritance or composition to add new types of discounts without changing the existing code. Here’s the refactored version:


class Discount {
    apply(product) {
        return product.price;
    }
}

class PercentageDiscount extends Discount {
    apply(product) {
        return product.price - (product.price * 0.10);
    }
}

class FixedDiscount extends Discount {
    apply(product) {
        return product.price - 10;
    }
}

const product = { name: "Laptop", price: 1000 };

const percentageDiscount = new PercentageDiscount();
console.log(percentageDiscount.apply(product)); // 900

const fixedDiscount = new FixedDiscount();
console.log(fixedDiscount.apply(product)); // 990

And now, the Discount class is open for extension but closed for modification. We can add new types of discounts by creating new classes that extend the Discount class without modifying the existing Discount class or its subclasses. For example, if we want to add a new SeasonalDiscount, we can do so by creating a new class:

class SeasonalDiscount extends Discount {
    apply(product) {
        return product.price - (product.price * 0.15);
    }
}

const seasonalDiscount = new SeasonalDiscount();
console.log(seasonalDiscount.apply(product)); // 850

by adhering this principle we can have couple few benefits such as

  • Easier Maintenance: Since existing code doesn’t need to be modified to add new functionality, there’s a lower risk of introducing bugs or breaking existing features. This makes the codebase more stable and easier to maintain.

  • Enhanced Flexibility: By allowing new functionality to be added through extensions (like subclasses or new components), the code becomes more adaptable to changing requirements. This makes it easier to implement new features or changes without disrupting the existing system.

L - Liskov substitution principle

The Liskov Substitution Principle (LSP) is another SOLID principle that states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program. In simpler terms, if you have a class hierarchy, you should be able to use any subclass wherever the superclass is expected without breaking the functionality.

here is the example of the below code snippet.


class Shape {
    area() {
        throw new Error('Method not implemented');
    }
}

class Rectangle extends Shape {
    constructor(width, height) {
        super();
        this.width = width;
        this.height = height;
    }

    area() {
        return this.width * this.height;
    }
}

class Square extends Shape {
    constructor(sideLength) {
        super();
        this.sideLength = sideLength;
    }

    area() {
        return this.sideLength * this.sideLength;
    }
}

Now, let’s demonstrate LSP by using these classes interchangeably where a Shape is expected:


function calculateArea(shape) {
    console.log(`Area: ${shape.area()}`);
}

const rectangle = new Rectangle(5, 10);
const square = new Square(5);

calculateArea(rectangle); // Outputs: Area: 50
calculateArea(square); // Outputs: Area: 25

Both Rectangle and Square can be used interchangeably in the calculateArea() function, which expects a Shape. This demonstrates LSP because substituting one subclass (Rectangle or Square) for another (Shape) doesn’t affect the behavior of the program—they both correctly compute and return their areas.

And the benefits of using this principle is that:

  • Enhanced Flexibility: It promotes polymorphism, allowing subclasses to be used interchangeably with their superclasses. This makes the code more flexible and adaptable to changes.

  • Improved Reusability: By adhering to LSP, subclasses are more reusable in different contexts. This reduces code duplication and improves the overall design of the system.

  • Behavioral Consistency: It ensures that subclasses maintain the expected behavior of their superclass, which leads to more predictable and reliable software systems.

I - Interface Segregation Principle

The Interface Segregation Principle (ISP) is one of the SOLID principles of object-oriented design. It states that a class should not be forced to implement interfaces it does not use. Instead of having one large, general-purpose interface, it’s better to have multiple smaller, more specific interfaces so that classes only need to be concerned with the methods that are relevant to them.

In simpler terms, ISP promotes the idea of creating fine-grained interfaces that are client-specific, avoiding “fat” interfaces that include methods unrelated to the actual responsibilities of the implementing class.

Consider an example where we have an interface for a multifunction printer that includes methods for printing, scanning, and faxing:

// Large interface
class MultifunctionPrinter {
    print(doc) {
        throw new Error('Method not implemented');
    }

    scan(doc) {
        throw new Error('Method not implemented');
    }

    fax(doc) {
        throw new Error('Method not implemented');
    }
}

class OldPrinter extends MultifunctionPrinter {
    print(doc) {
        console.log(`Printing: ${doc}`);
    }

    scan(doc) {
        // OldPrinter does not support scanning
        throw new Error('scan method not supported');
    }

    fax(doc) {
        // OldPrinter does not support faxing
        throw new Error('fax method not supported');
    }
}

Here, OldPrinter has to implement the scan and fax methods, even though it doesn’t support these functionalities. This violates the ISP.

To adhere to ISP, we can break down the MultifunctionPrinter interface into smaller, more specific interfaces:

	// Smaller, specific interfaces
class Printer {
    print(doc) {
        throw new Error('Method not implemented');
    }
}

class Scanner {
    scan(doc) {
        throw new Error('Method not implemented');
    }
}

class Fax {
    fax(doc) {
        throw new Error('Method not implemented');
    }
}

class OldPrinter extends Printer {
    print(doc) {
        console.log(`Printing: ${doc}`);
    }
}

Now, OldPrinter only implements the Printer interface, which aligns with its capabilities and responsibilities, adhering to the Interface Segregation Principle.

And few of the benefits are:

  • Improved Cohesion: Smaller, more specific interfaces ensure that implementing classes are only concerned with relevant methods, leading to higher cohesion within the classes.

  • Enhanced Flexibility: Changes to one interface do not affect classes that do not use that interface, making the codebase more flexible and easier to modify.

  • Simplified Testing: Testing is easier because classes have fewer methods, making it simpler to write and maintain test cases.

  • Reduced Complexity: By not forcing classes to implement methods they do not need, the overall complexity of the system is reduced, leading to clearer and more understandable code.

D - Dependency Inversion Principle (DIP)

The Dependency Inversion Principle (DIP) is the last of the SOLID principles and emphasizes decoupling software modules by ensuring that high-level modules do not depend on low-level modules. Instead, both should depend on abstractions (interfaces or abstract classes), and abstractions should not depend on details, but details should depend on abstractions.


// High-level module (Business Logic)
class PaymentProcessor {
    constructor(paymentGateway) {
        this.paymentGateway = paymentGateway;
    }

    processPayment(amount) {
        this.paymentGateway.pay(amount);
    }
}

// Low-level module (Details)
class CreditCardPaymentGateway {
    pay(amount) {
        console.log(`Paying $${amount} using Credit Card`);
        // Logic to process credit card payment
    }
}

// Abstraction (Interface)
class PaymentGateway {
    pay(amount) {
        throw new Error('pay method must be implemented');
    }
}

// Adapting the low-level module to the abstraction
class CreditCardAdapter extends PaymentGateway {
    constructor(creditCardPaymentGateway) {
        super();
        this.paymentGateway = creditCardPaymentGateway;
    }

    pay(amount) {
        this.paymentGateway.pay(amount);
    }
}

// Usage
const creditCardGateway = new CreditCardPaymentGateway();
const paymentProcessor = new PaymentProcessor(creditCardGateway);
paymentProcessor.processPayment(100);

In this example:

  • PaymentProcessor is a high-level module that depends on PaymentGateway, an abstraction.
  • CreditCardPaymentGateway is a low-level module that implements PaymentGateway.
  • CreditCardAdapter adapts CreditCardPaymentGateway to the PaymentGateway abstraction.

Advantages of Dependency Inversion Principle

  1. Reduced Coupling: DIP reduces the direct dependencies between modules by introducing abstractions. This makes the code more flexible and easier to change without affecting other parts of the system.

  2. Improved Testability: By using interfaces or abstractions, dependencies can be easily mocked or stubbed during unit testing. This improves the testability of the codebase.

  3. Promotes Reusability: Modules that depend on abstractions can be reused in different contexts, as they are not tightly coupled to specific implementations.

By adhering to DIP, software design becomes more modular, adaptable, and maintainable, as it promotes separation of concerns and facilitates easier extension and modification of code over time.

Also you can check out the below explanation of Robert Martin , in which he explains in detail of SOLID Principle.

IMAGE ALT TEXT HERE