SOLID Principles: A Comprehensive Guide with Examples

Introduction:

In software development, writing clean, maintainable, and scalable code is crucial. The SOLID principles are a set of five design principles that provide a roadmap for creating robust and adaptable software systems. This post explores each SOLID principle with detailed explanations and practical JavaScript coding examples.

What are SOLID Principles?

The SOLID principles, introduced by Robert C. Martin, help create software that is:

The Five SOLID Principles (with JavaScript Examples):

The SOLID principles consist of five key concepts, each addressing a specific aspect of software design. Let's dive into each principle and explore how they contribute to building better software.

 

1. Single Responsibility Principle (SRP)

A class should have only one reason to change.

The Single Responsibility Principle states that a class should have only one reason to change. In other words, a class should have only one job or responsibility. This principle helps to avoid classes that become overly complex and difficult to maintain.

Consider a class that is responsible for both calculating the area of a rectangle and printing the rectangle to the console. This class has two responsibilities, and therefore violates the SRP. To adhere to the SRP, we should separate these responsibilities into two distinct classes: one for calculating the area and one for printing the rectangle.

 

Example (Violation):

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

  saveUser() {
    // Save user to database
    console.log(`Saving user ${this.name} to database`);
  }

  sendEmail(message) {
      // Send email logic
      console.log(`Sending email to ${this.email}: ${message}`);
  }
}

const user = new User("John Doe", "john.doe@example.com");
user.saveUser();
user.sendEmail("Welcome!");

 

This User class has two responsibilities: user persistence and email sending.

Example (SRP Applied):

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

class UserRepository {
  save(user) {
    // Save user to database
    console.log(`Saving user ${user.name} to database`);
  }
}

class EmailService {
    sendEmail(email, message) {
        console.log(`Sending email to ${email}: ${message}`);
    }
}

const user = new User("John Doe", "john.doe@example.com");
const userRepository = new UserRepository();
const emailService = new EmailService();

userRepository.save(user);
emailService.sendEmail(user.email, "Welcome!");

 Now, each class has a single responsibility.

 

2. Open/Closed Principle (OCP)

Software entities should be open for extension but closed for modification.

The Open/Closed Principle states that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. This means that you should be able to add new functionality to a class without modifying its existing code. This can be achieved through techniques such as inheritance and composition.

Suppose we have a class that calculates the area of different shapes. If we want to add support for a new shape, such as a circle, we should not modify the existing class. Instead, we should create a new class that inherits from the original class and adds the necessary functionality for calculating the area of a circle.

 

Example (Violation):

class AreaCalculator {
  calculateArea(shapes) {
    let area = 0;
    for (const shape of shapes) {
      if (shape.type === 'rectangle') {
        area += shape.width * shape.height;
      } else if (shape.type === 'circle') {
        area += Math.PI * shape.radius * shape.radius;
      }
    }
    return area;
  }
}

Adding a new shape requires modifying the calculateArea method.

Example (OCP Applied):

class Shape {
  area() {
    throw new Error("Area method must be implemented.");
  }
}

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

class Circle extends Shape {
    constructor(radius) {
        super();
        this.radius = radius;
    }
    area() {
        return Math.PI * this.radius * this.radius;
    }
}

class AreaCalculator {
    calculateArea(shapes) {
        let area = 0;
        for (const shape of shapes) {
            area += shape.area();
        }
        return area;
    }
}

const shapes = [new Rectangle(10, 20), new Circle(5)];
const calculator = new AreaCalculator();
console.log(calculator.calculateArea(shapes));

Now, adding a new shape only requires creating a new class that extends Shape.

 

3. Liskov Substitution Principle (LSP)

Subtypes should be substitutable for their base types without altering the correctness of the program.

The Liskov Substitution Principle states that subtypes should be substitutable for their base types without altering the correctness of the program. This means that if a class inherits from another class, it should be able to replace the parent class without causing any unexpected behavior.

Consider a class called "Bird" with a method called "fly." If we create a subclass called "Penguin" that cannot fly, it violates the LSP. A penguin is a bird, but it cannot perform the "fly" action, which can lead to unexpected behavior if a penguin object is used in place of a bird object.

Example (Violation):

class Bird {
    fly() {
        console.log("I can fly!");
    }
}

class Penguin extends Bird {
    fly() {
        throw new Error("Penguins can't fly!");
    }
}

function makeBirdFly(bird) {
    bird.fly();
}

const bird = new Bird();
const penguin = new Penguin();

makeBirdFly(bird); // Output: I can fly!
makeBirdFly(penguin); // Throws an error!

Penguin cannot be substituted for Bird without causing errors.

Example (LSP Applied - Better Design):

In this scenario, a better design might be to separate the concept of "flying" into an interface or separate hierarchy:

class Bird {
    walk() {
        console.log("I can walk!");
    }
}

interface IFlyer {
    fly(): void;
}

class FlyingBird extends Bird implements IFlyer {
    fly() {
        console.log("I can fly!");
    }
}

class Penguin extends Bird {
    // Penguins can walk, but not fly, so no fly method.
}

function makeBirdWalk(bird: Bird) {
    bird.walk();
}

function makeBirdFly(bird: IFlyer) {
    bird.fly();
}

const flyingBird = new FlyingBird();
const penguin = new Penguin();

makeBirdWalk(flyingBird);
makeBirdWalk(penguin);
makeBirdFly(flyingBird); // This works
// makeBirdFly(penguin); // This would cause a type error as penguin does not implement the IFlyer interface.

 

4. Interface Segregation Principle (ISP)

A class should not be forced to implement interfaces it does not use.

The Interface Segregation Principle states that a class should not be forced to implement interfaces it does not use. Instead of having large, monolithic interfaces, it is better to have smaller, more specific interfaces. This principle reduces the coupling between classes and makes the system more flexible.

Suppose we have an interface called "Worker" with methods like "work," "eat," and "takeBreak." If we have a class called "Robot" that can work but does not need to eat or take breaks, it would be forced to implement these unnecessary methods. To adhere to the ISP, we should create separate interfaces for "Workable," "Eatable," and "Breakable," and have classes implement only the interfaces they need.

Example (Violation):

interface Worker {
  work();
  eat();
  rest();
}

class HumanWorker implements Worker {
    work() { console.log("Working..."); }
    eat() { console.log("Eating..."); }
    rest() { console.log("Resting..."); }
}

class RobotWorker implements Worker {
    work() { console.log("Working tirelessly..."); }
    eat() { /* Robots don't eat. This is unnecessary. */ }
    rest() { /* Robots don't rest. This is unnecessary. */ }
}

RobotWorker is forced to implement unnecessary methods.

Example (ISP Applied):

interface Workable {
  work();
}

interface Eatable {
  eat();
}

interface Restable {
  rest();
}

class HumanWorker implements Workable, Eatable, Restable {
    work() { console.log("Working..."); }
    eat() { console.log("Eating..."); }
    rest() { console.log("Resting..."); }
}

class RobotWorker implements Workable {
    work() { console.log("Working tirelessly..."); }
}

Now, RobotWorker only implements the necessary interface.

 

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. Details should depend on abstractions.  

The Dependency Inversion Principle states that 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. This principle promotes loose coupling between classes and makes the system more modular and maintainable.

Consider a class called "Button" that depends on a class called "LightBulb" to turn on and off. This creates a tight coupling between the two classes. To adhere to the DIP, we should introduce an abstraction, such as an interface called "Switchable," and have both the "Button" and "LightBulb" depend on this abstraction. This way, the "Button" can work with any class that implements the "Switchable" interface, not just the "LightBulb."

 

Example (Violation):

class LightBulb {
    turnOn() { console.log("LightBulb: On"); }
    turnOff() { console.log("LightBulb: Off"); }
}

class Switch {
    constructor() {
        this.bulb = new LightBulb(); // High-level module depends on low-level module
    }

    operate() {
        this.bulb.turnOn();
    }
}

const mySwitch = new Switch();
mySwitch.operate();

Switch is tightly coupled to LightBulb.

Example (DIP Applied):

interface Switchable {
    turnOn(): void;
    turnOff(): void;
}

class LightBulb implements Switchable {
    turnOn() { console.log("LightBulb: On"); }
    turnOff() { console.log("LightBulb: Off"); }
}

class Fan implements Switchable {
    turnOn() { console.log("Fan: On"); }
    turnOff() { console.log("Fan: Off"); }
}

class Switch {
    constructor(device: Switchable) {
        this.device = device;
    }

    operate() {
        this.device.turnOn();
    }
}

const lightBulb = new LightBulb();
const fan = new Fan();
const lightSwitch = new Switch()

 

Benefits of SOLID Principles:

Adhering to the SOLID principles offers numerous benefits, including:

Conclusion:

The SOLID principles are essential guidelines for writing clean, maintainable, and scalable code. By understanding and applying these principles, developers can create software systems that are more robust, flexible, and adaptable to change. While it may take some effort to learn and implement these principles, the long-term benefits are well worth the investment.

 



Abid Raza

Editor

Leave a comment