DEV Community

Varun
Varun

Posted on • Updated on

Easiest explanations for SOLID Principles

The SOLID principles are a set of guidelines for writing software that is more understandable, flexible, and maintainable. The principles conceptualized by Robert C. Martin have become foundational in object-oriented design and programming. Each letter in "SOLID" stands for a different principle:

Single Responsibility Principle (SRP):

  • Definition: A class should have only one reason to change, meaning it should have only one job or responsibility.
  • Basically, A class should not have 4-5 methods which can be changed, or which have scopes to modify. Ideally class should have only 1 responsibility and rest of methods or functions each of them should have separate classes.
  • WHY: This principle aims to reduce the complexity of the class by limiting its responsibilities. It helps in minimizing the impact of changes by isolating them to specific components.

    Earlier Problem: A class that handles both user data management and user notification responsibilities. Now, if we need to fix one feature rest of the code can be prone to bugs. So, its recommended to have one responsibility.

    
    class UserManager {
        saveUser(user: User): void {
            // logic to save the user
        }
    
        sendEmail(user: User, message: string): void {
            // logic to send an email
        }
    }
    
    

    Solution it provides: Split the class into two distinct classes, each handling a single responsibility.

    class UserDataManager {
        saveUser(user: User): void {
            // logic to save the user
        }
    }
    
    class UserNotifier {
        sendEmail(user: User, message: string): void {
            // logic to send an email
        }
    }
    

Open/Closed Principle (OCP):

  • Definition: Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification. Suppose, a class with methods is developed, tested and live then why would you modify it, you should extend it and add methods related to it.

Modifying Existing class to add new methods is prone to bug.

  • WHY: This principle promotes the use of interfaces and abstract classes to enable the behavior of a module to be extended without modifying the source code of the module itself. This is useful for maintaining code health over time, particularly when changes are required.

    Earlier Problem: A class that needs modification every time a new filter condition needs to be added for a product.

    class ProductFilter {
        filterByColor(products: Product[], color: string): Product[] {
            return products.filter(product => product.color === color);
        }
    
        filterBySize(products: Product[], size: number): Product[] {
            return products.filter(product => product.size === size);
        }
    }
    
    

    Solution it provides: Use a specification pattern that allows for extending filter capabilities without modifying existing code. ie create interface of that class and with new classes implement or extend original class for standard method and for new methods separate new classes for each.
    So, in future when you want other new feature, create another class which will extend original class with new feature.

    interface Specification<T> {
        isSatisfied(item: T): boolean;
    }
    
    class ColorSpecification implements Specification<Product> {
        constructor(private color: string) {}
    
        isSatisfied(product: Product): boolean {
            return product.color === this.color;
        }
    }
    
    class SizeSpecification implements Specification<Product> {
        constructor(private size: number) {}
    
        isSatisfied(product: Product): boolean {
            return product.size === this.size;
        }
    }
    
    class ProductFilter {
        filter(products: Product[], spec: Specification<Product>): Product[] {
            return products.filter(product => spec.isSatisfied(product));
        }
    }
    
    

Liskov Substitution Principle (LSP):

  • Definition: Objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program. If Class B is subclass of Class A then we should be able to replace object of A with B without breaking behavior of our program.
  • WHY: This principle ensures that a subclass can stand in for its superclass without errors or unexpected behavior, promoting the reliability and reusability of components. Subclass should extend capability of parent class, should not narrow it down.

    Earlier Problem: A subclass that throws an exception for a method inherited from its superclass, which is not expected behavior.
    If a program is using class A now B is subclass of A with different implementation of methods extended from A. Then program should be able to replace A with B. Yes, B can have different outputs but it should not remove existing methods for B, ie raise errors for them / breaking the code.

    class Bird {
        fly(): void {
            console.log("Fly");
        }
    }
    
    class Duck extends Bird {}
    
    class Penguin extends Bird {
        fly(): void {
            throw new Error("Cannot fly");
        }
    }
    
    

    Solution it provides: Restructure the class hierarchy to ensure that subclasses can fully substitute their superclasses.
    Here Penguin will break the code for implementing the fly method which its extending from Bird Class.
    So, make parent class such that it has only common methods, and for additional methods create another subclass with the features required for other classes.

    class Bird {}
    
    class FlyingBird extends Bird {
        fly(): void {
            console.log("Fly");
        }
    }
    
    class Duck extends FlyingBird {}
    
    class Penguin extends Bird {}
    
    

Interface Segregation Principle (ISP):

  • Definition: No client should be forced to depend on methods it does not use. Interfaces should be such that the client should not implement unnecessary functions that they don't even need.
  • WHY: This principle encourages creating more specific interfaces rather than a general-purpose, do-it-all interface. It promotes smaller, more focused interfaces that better cater to specific clients, reducing the dependencies between clients and their classes.

    Interface is used for general template which will be used by specific classes based on their features.

    Earlier Problem: A bulky interface that forces implementations to implement methods they don't use.
    Generic interface should not implement any specific methods else those classes that extend this interface would need to implement these methods unnecessarily.

    
    interface Machine {
        print(): void;
        scan(): void;
        fax(): void;
    }
    
    

    Solution it provides: Break the interface into more specific parts. Different interfaces for different methods and these interfaces can be used as base templates for future classes.

    interface Printer {
        print(): void;
    }
    
    interface Scanner {
        scan(): void;
    }
    
    interface FaxMachine {
        fax(): void;
    }
    
    class AllInOnePrinter implements Printer, Scanner, FaxMachine {
        print(): void { /* logic to print */ }
        scan(): void { /* logic to scan */ }
        fax(): void { /* logic to fax */ }
    }
    
    

Dependency Inversion Principle (DIP):

  • Definition: High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g., interfaces). Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.

    Basically, class should depend on interfaces rather than concrete classes.

  • WHY: By depending on abstractions rather than concrete classes, this principle helps to decouple the high-level components from the low-level components, making the system easier to manage and modify.

    Earlier Problem: High-level modules directly depend on low-level modules.
    Suppose interfaces have defined certain classes, now a class wants to implement and use these classes/methods. Should it directly use these classes/methods? No.
    Instead of interface if we use concrete classes/methods directly then we won't be able to modify or extend it based on needs.

    class LightBulb {
        turnOn(): void {
            console.log("Light bulb turned on");
        }
    
        turnOff(): void {
            console.log("Light bulb turned off");
        }
    }
    
    class Switch {
        private bulb: LightBulb = new LightBulb();
        private on: boolean = false;
    
        press(): void {
            if (this.on) {
                this.bulb.turnOff();
                this.on = false;
            } else {
                this.bulb.turnOn();
                this.on = true;
            }
        }
    }
    
    

    Solution it provides: Use abstractions to decouple high-level modules from low-level modules.
    The class should implement the interface and from that it should decouple interface from specific methods. Create interface objects and using constructor injection we can use classes of that interface for implementation.

    interface Switchable {
        turnOn(): void;
        turnOff(): void;
    }
    
    class LightBulb implements Switchable {
        turnOn(): void {
            console.log("Light bulb turned on");
        }
    
        turnOff(): void {
            console.log("Light bulb turned off");
        }
    }
    
    class Switch {
        private on: boolean = false;
    
        constructor(private device: Switchable) {}
    
        press(): void {
            if (this.on) {
                this.device.turnOff();
                this.on = false;
            } else {
                this.device.turnOn();
                this.on = true;
            }
        }
    }
    
    

Following the SOLID principles helps in creating software that is:

  • Scalable: Easier to scale the software with fewer issues as new requirements arise.
  • Manageable: Simpler to manage as the software complexity grows.
  • Reusable: Increases the reusability of code through cleaner, more organized structures.
  • Extensible: Facilitates the extension of software functionalities without a significant rework.
  • Testable: Improves testability due to reduced dependencies and better separation of concerns.

Now we will go through real life application integrated with SOLID Principles.

Web Application: User Account Management

1. Single Responsibility Principle (SRP)

We'll create separate classes for handling user data management and user authentication.

// UserDataManager.ts
class UserDataManager {
    saveUser(user: User): void {
        // logic to save user data
    }

    deleteUser(userId: string): void {
        // logic to delete user data
    }
}

// UserAuthenticator.ts
class UserAuthenticator {
    authenticateUser(username: string, password: string): boolean {
        // logic to authenticate user
        return true; // or false
    }
}

Enter fullscreen mode Exit fullscreen mode

2. Open/Closed Principle (OCP)

We'll create an interface for authentication strategies, allowing us to add new authentication methods without modifying existing code.

// AuthenticationStrategy.ts
interface AuthenticationStrategy {
    authenticate(username: string, password: string): boolean;
}

// UsernamePasswordStrategy.ts
class UsernamePasswordStrategy implements AuthenticationStrategy {
    authenticate(username: string, password: string): boolean {
        // logic to authenticate using username and password
        return true; // or false
    }
}

// OAuthStrategy.ts
class OAuthStrategy implements AuthenticationStrategy {
    authenticate(username: string, password: string): boolean {
        // logic to authenticate using OAuth
        return true; // or false
    }
}

Enter fullscreen mode Exit fullscreen mode

3. Liskov Substitution Principle (LSP)

Our authentication strategies can be used interchangeably without affecting the correctness of the authentication process.

const usernamePasswordStrategy: AuthenticationStrategy = new UsernamePasswordStrategy();
const oauthStrategy: AuthenticationStrategy = new OAuthStrategy();

usernamePasswordStrategy.authenticate("user", "password");
oauthStrategy.authenticate("user", "token");

Enter fullscreen mode Exit fullscreen mode

4. Interface Segregation Principle (ISP)

We'll create separate interfaces for user data management and authentication to avoid implementing unnecessary methods.

// UserDataManagement.ts
interface UserDataManagement {
    saveUser(user: User): void;
    deleteUser(userId: string): void;
}

// UserAuthentication.ts
interface UserAuthentication {
    authenticateUser(username: string, password: string): boolean;
}

Enter fullscreen mode Exit fullscreen mode

5. Dependency Inversion Principle (DIP)

Our high-level modules will depend on abstractions rather than concrete implementations.

// UserAccountController.ts
class UserAccountController {
    private userDataManager: UserDataManagement;
    private userAuthenticator: UserAuthentication;

    constructor(userDataManager: UserDataManagement, userAuthenticator: UserAuthentication) {
        this.userDataManager = userDataManager;
        this.userAuthenticator = userAuthenticator;
    }

    registerUser(user: User): void {
        this.userDataManager.saveUser(user);
    }

    loginUser(username: string, password: string): boolean {
        return this.userAuthenticator.authenticateUser(username, password);
    }
}

Enter fullscreen mode Exit fullscreen mode

Explanation of SOLID Principles Integration

  • SRP: UserDataManager is responsible for managing user data, while UserAuthenticator handles user authentication. Each class has a single responsibility.
  • OCP: By introducing the AuthenticationStrategy interface, we can add new authentication methods like OAuth without modifying existing authentication logic.
  • LSP: The UsernamePasswordStrategy and OAuthStrategy can be used interchangeably, fulfilling the same contract of the AuthenticationStrategy interface.
  • ISP: We split interfaces UserDataManagement and UserAuthentication into smaller, focused interfaces to avoid implementing unnecessary methods.
  • DIP: The UserAccountController depends on abstractions (UserDataManagement and UserAuthentication interfaces) rather than concrete implementations, allowing for easy substitution and testing.

Now, by using these principles, our web application becomes more modular, easier to maintain, and less prone to bugs when adding new features or changing existing ones. Each component is highly cohesive and loosely coupled, promoting flexibility and scalability.

Top comments (1)

Collapse
 
kachukmarcelo profile image
kachukmarcelo

Thanks for the explanation! Very useful and clear.