SOLID Principles: Build Strong And Maintainable Software

In the world of software engineering, creating robust, scalable, and maintainable code is essential for long-term success. However, achieving these goals can be challenging, especially as projects grow in complexity. This is where SOLID principles come into play.

Originally introduced by Robert C. Martin, SOLID is an acronym for five design principles that, when followed, help developers create software that is easy to understand, maintain, and extend over time.

  1. S – Single Responsibility Principle (SRP)
  2. O – Open/Closed Principle (OCP)
  3. L – Liskov Substitution Principle (LSP)
  4. I – Interface Segregation Principle (ISP)
  5. D – Dependency Inversion Principle (DIP)

1. Single Responsibility Principle (SRP):

The Single Responsibility Principle states that a class should have only one reason to change, meaning it should have only one responsibility or job. This principle encourages modular and focused classes, which are easier to understand, test, and maintain.

By adhering to SRP, developers can isolate changes, minimizing the impact of modifications and improving code readability.

In this following example, the UserManager class is responsible for both managing users (adding them to the database) and sending welcome emails.

The UserManager class can contain removeUser or updateUser methods because these are related to user management tasks but sending email is different type of task or responsibility. Therefore this violates the Single Responsibility Principle.

class UserManager {
    constructor() {
        this.users = [];
    }

    addUser(user) {
        // Adds a user to the database
        this.users.push(user);
        // Sends a welcome email to the user
        this.sendWelcomeEmail(user);
    }

    sendWelcomeEmail(user) {
        // Code to send a welcome email
    }
}

In this Following SRP example, the responsibilities of managing users and sending emails are separated into different classes: UserRepository and EmailService. The UserManager class now has a single responsibility: coordinating the actions of adding a user and sending a welcome email.

class UserManager {
    constructor(userRepository, emailService) {
        this.userRepository = userRepository;
        this.emailService = emailService;
    }

    addUser(user) {
        this.userRepository.add(user);
        this.emailService.sendWelcomeEmail(user);
    }
}

class UserRepository {
    add(user) {
        // Code to add user to the database
    }
}

class EmailService {
    sendWelcomeEmail(user) {
        // Code to send a welcome email
    }
}

This adheres to the Single Responsibility Principle because each class has only one reason to change. If the way users are managed or the way emails are sent changes, only the relevant class needs to be modified.

2. Open/Closed Principle (OCP):

The Open/Closed Principle emphasizes that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. In other words, the behavior of a component should be extendable without modifying its source code. This principle encourages the use of abstraction and polymorphism, allowing developers to add new functionality without altering existing code.

Example of Violating OCP

In the following code, the Shape class is not closed for modification because adding a new shape (e.g., triangle) requires modifying the area method. This violates the Open/Closed Principle because the class should be closed for modification but open for extension.

class Shape {
    constructor(type) {
        this.type = type;
    }

    area() {
        if (this.type === 'rectangle') {
            return this.calculateRectangleArea();
        } else if (this.type === 'circle') {
            return this.calculateCircleArea();
        }
    }

    calculateRectangleArea() {
        // Calculation logic for rectangle area
    }

    calculateCircleArea() {
        // Calculation logic for circle area
    }
}
class Shape {
    area() {
        throw new Error('This method must be overridden');
    }
}

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 ** 2;
    }
}

Now the Shape class is closed for modification because its area method is abstract and must be overridden by subclasses. New shapes can be added by creating new subclasses of Shape without modifying the existing code, thereby adhering to the Open/Closed Principle.

3. Liskov Substitution Principle (LSP):

The Liskov Substitution Principle states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program. In simpler terms, any child class should be usable in place of its parent class without altering the desired behavior of the program. Violating this principle can lead to unexpected behavior and bugs.

In the following example, Square inherits from Rectangle, but it violates the Liskov Substitution Principle because it changes the behavior of the setWidth and setHeight methods. A square should not behave the same way as a rectangle when setting its width and height because a square’s width and height are always equal.

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

    setWidth(width) {
        this.width = width;
    }

    setHeight(height) {
        this.height = height;
    }

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

class Square extends Rectangle {
    setWidth(width) {
        this.width = width;
        this.height = width; // Violates LSP by changing behavior.
    }

    setHeight(height) {
        this.height = height;
        this.width = height; // Violates LSP by changing behavior.
    }
}

Here Square and Rectangle are both subclasses of Shape, and they adhere to the Liskov Substitution Principle because they maintain the expected behavior. A Square has a specific implementation for calculating its area, and it does not change the behavior of inherited methods. Each subclass is substitutable for its base class without altering the correctness of the program.

class Shape {
    area() {
        throw new Error("This method must be overridden");
    }
}

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

    setWidth(width) {
        this.width = width;
    }

    setHeight(height) {
        this.height = height;
    }

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

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

    setSide(side) {
        this.side = side;
    }

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

4. Interface Segregation Principle (ISP)

Clients should not be forced to depend on interfaces they do not use. This Interface Segregation Principle advocates for the creation of smaller, more specific interfaces rather than large, monolithic ones. By doing so, you can avoid imposing unnecessary dependencies on clients.

In the following, the MultifunctionalPrinter class implements both the print and scan methods from the Machine class. However, not all machines need both functionalities. This violates the Interface Segregation Principle because clients of the MultifunctionalPrinter may be forced to depend on methods they do not need, leading to unnecessary coupling.

class Machine {
    print() {
        console.log("Printing...");
    }

    scan() {
        console.log("Scanning...");
    }
}

class MultifunctionalPrinter extends Machine {
    // Implements both print and scan methods
}

Now we have separate interfaces for printing and scanning. The MultifunctionalMachine class depends on both the Printer and Scanner interfaces, allowing clients to use only the functionalities they require. This adheres to the Interface Segregation Principle because it avoids forcing clients to depend on methods they do not use.

class Printer {
    print() {
        console.log("Printing...");
    }
}

class Scanner {
    scan() {
        console.log("Scanning...");
    }
}

class MultifunctionalMachine {
    constructor(printer, scanner) {
        this.printer = printer;
        this.scanner = scanner;
    }

    print() {
        this.printer.print();
    }

    scan() {
        this.scanner.scan();
    }
}

5. Dependency Inversion Principle (DIP)

The Dependency Inversion Principle suggests that high-level modules should not depend on low-level modules. Both should depend on abstractions. Additionally, abstractions should not depend on details; rather, details should depend on abstractions.

This principle facilitates loose coupling, enabling easier changes and testing by decoupling higher-level components from lower-level implementation details.

In the following, the UserManager class directly creates an instance of EmailService. This violates the Dependency Inversion Principle because UserManager depends on a concrete implementation of EmailService rather than an abstraction. This tight coupling makes it harder to change or extend the behavior of UserManager.

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

class UserManager {
    constructor() {
        this.emailService = new EmailService(); // Violates DIP by depending on a concrete implementation.
    }

    sendWelcomeEmail(user) {
        const message = `Welcome, ${user}!`;
        this.emailService.sendEmail(message);
    }
}

Now in the following code the UserManager class depends on the NotificationService abstraction instead of a concrete implementation like EmailService. This adheres to the Dependency Inversion Principle because UserManager relies on an abstraction, allowing different notification services to be easily plugged in without modifying the UserManager class.

class NotificationService {
    sendNotification(message) {
        throw new Error("Method not implemented");
    }
}

class EmailService extends NotificationService {
    sendNotification(message) {
        console.log("Sending email:", message);
    }
}

class UserManager {
    constructor(notificationService) {
        this.notificationService = notificationService; // Depends on abstraction (NotificationService).
    }

    sendWelcomeEmail(user) {
        const message = `Welcome, ${user}!`;
        this.notificationService.sendNotification(message);
    }
}

In conclusion, SOLID principles provide a set of guidelines for designing software that is robust, maintainable, and adaptable to change.

By applying these principles, developers can create codebases that are easier to understand, test, and extend, ultimately leading to more scalable and maintainable software systems.

While adhering strictly to all five principles may not always be feasible or necessary, understanding and applying them judiciously can significantly improve the quality and longevity of software projects.