SOLID Principles

Mastering Code Quality with SOLID Principles

SOLID Principles

Hello readers 👋! In this blog, we'll delve into the SOLID Principles. As a software engineer, it's not enough to be proficient at building logic; you must also excel at writing programs that are easy to read, make sense, and provide flexibility for extending or modifying the code. A great programmer writes code that is not only functional but also maintainable and understandable, enabling the creation of extendible systems.

To achieve these goals, we need design patterns or principles. They offer standard terminology and specific solutions to common problems in software development, providing a proven template for solving issues in various situations. Among the numerous principles and patterns available, SOLID Principles stand out as one of the most significant and sophisticated design patterns. In this blog, we'll explore how employing SOLID Principles can help us write better, and more robust code!

What Are SOLID Principles?

SOLID is an acronym that represents five key principles of object-oriented programming and design. These principles, when applied together, aim to make software designs more understandable, flexible, and maintainable.

The five SOLID principles are:

  1. Single Responsibility Principle (SRP)

  2. Open-Closed Principle (OCP)

  3. Liskov Substitution Principle (LSP)

  4. Interface Segregation Principle (ISP)

  5. Dependency Inversion Principle (DIP)

In this blog, you will be introduced to each principle individually to understand how SOLID can help make you a better developer. By mastering these principles, you will enhance your ability to write clean, maintainable, and scalable code, ultimately becoming a more effective and efficient software engineer.

For the sake of understanding, we'll consider the example of User Management System using Python throughout the blog. This will help show how these principles work together in a cohesive system.

Single Responsibility Principle (SRP)

A class should have one and only one reason to change, meaning that a class should have only one job.

The Single Responsibility Principle (SRP) emphasizes that each class should have only one functionality or one feature and should stick to it. This principle promotes high cohesion and helps create more maintainable and understandable code. When a single class has more than one responsibility, changes in one function can impact other functions within the same class, leading to unintended consequences and increased complexity.

Lets take an example:

  • Without Single Responsibility Principle
# Violating Single Responsibility Principle
class User:
    def __init__(self, name: str):
        self.name = name

    def get_name(self) -> str:
        return self.name

    def save(self):
        # Code to save user to database
        print(f"Saving user {self.name} to database")

    def send_email(self, message: str):
        # Code to send email
        print(f"Sending email to {self.name}: {message}")

The User class has multiple responsibilities: managing user data (name), saving user data to the database using (save method), and sending emails using (send_email method).

  • With Single Responsibility Principle-Compliant
# Following Single Responsibility Principle
class User:
    def __init__(self, name: str):
        self.name = name

    def get_name(self) -> str:
        return self.name

class UserRepository:
    @staticmethod
    def save(user: User):
        # Code to save user to database
        print(f"Saving user {user.get_name()} to database")

class EmailService:
    @staticmethod
    def send_email(user: User, message: str):
        # Code to send email
        print(f"Sending email to {user.get_name()}: {message}")

Each class (User, UserRepository, EmailService) now has a single responsibility. User manages user data, UserRepository manages database operations, and EmailService handles email sending.

Key difference:

  1. Without SRP

    • Adding new features or integrating new technologies e.g., different database types, email providers) related to user management or email functionalities would further complicate the User class. It would grow larger and more unwieldy, making it harder to extend or reuse in other parts of the application.

    • Maintenance becomes challenging as changes to one responsibility may require understanding and potentially modifying unrelated parts of the class.

    • This violates SRP because the class should ideally have only one reason to change. If any aspect of user management, database operations, or email sending needs to be modified, the User class itself would need to be changed.

  2. With SRP

    • Adding new features or integrating with new technologies (e.g., different database types, email providers) can be done without affecting the existing functionality of other classes.

    • Modifications or enhancements to one functionality do not affect the others, promoting a more modular and maintainable codebase.

Open/Closed Principle (OCP)

Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.

This principle suggests that we should be able to extend a class's behavior without modifying its existing code. It's particularly useful when we need to add new functionality to our system but without making any changes in existing code.

  • Without Open/Closed Principle
# Without Open/Closed Principle
from abc import ABC, abstractmethod

class User:
    def __init__(self, name: str):
        self.name = name

    def get_name(self) -> str:
        return self.name

class UserRepository:
    def save(self, user: User, storage_type: str):
        if storage_type == "database":
            self.save_to_database(user)
        elif storage_type == "file":
            self.save_to_file(user)
        else:
            raise ValueError("Unsupported storage type")

    def save_to_database(self, user: User):
        print(f"Saving user {user.get_name()} to database")

    def save_to_file(self, user: User):
        print(f"Saving user {user.get_name()} to file")

To add a new storage type (e.g., cloud storage), the UserRepository class must be modified. This violates the OCP because the class is not closed for modification.

  • With Open/Closed Principle
# With Open/Closed Principle
from abc import ABC, abstractmethod

class User:
    def __init__(self, name: str):
        self.name = name

    def get_name(self) -> str:
        return self.name

class UserRepository(ABC):
    @abstractmethod
    def save(self, user: U-ser):
        pass

class DatabaseUserRepository(UserRepository):
    def save(self, user: User):
        print(f"Saving user {user.get_name()} to database")

class FileUserRepository(UserRepository):
    def save(self, user: User):
        print(f"Saving user {user.get_name()} to file")

# If we want to add cloud storage, we can do so without modifying existing code:
class CloudUserRepository(UserRepository):
    def save(self, user: User):
        print(f"Saving user {user.get_name()} to cloud storage")

# We can also create a UserManager class that can work with any UserRepository
class UserManager:
    def __init__(self, repository: UserRepository):
        self.repository = repository

    def save_user(self, user: User):
        self.repository.save(user)

Adding new storage mechanisms (e.g., CloudUserRepository) does not require changes to existing classes. This makes the system easy to extend with new features.

Key differences:

  1. Without OCP:

    • We need to modify the UserRepository class each time we want to add a new storage type.

    • The save method uses conditionals to determine behavior, which can lead to a growing, complex method.

  2. With OCP:

    • We define an abstract UserRepository class that other repositories inherit from.

    • Each storage type is implemented in its own class.

    • New storage types can be added by creating new classes, without modifying existing code.

    • We can create a UserManager class that works with any UserRepository, making the system more flexible.

Liskov Substitution Principle (LSP)

Objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program.

This principle ensures that a derived class can stand in for its base class without causing any unexpected behavior. It promotes the creation of hierarchies that are easy to understand and maintain.

  • Without Liskov Substitution Principle
class User:
    def __init__(self, name: str, email: str):
        self.name = name
        self.email = email

    def get_name(self) -> str:
        return self.name

    def get_email(self) -> str:
        return self.email

class Admin(User):
    def __init__(self, name: str, email: str, access_level: int):
        super().__init__(name, email)
        self.access_level = access_level

    def get_email(self) -> str:
        return f"ADMIN: {self.email}"

def send_email_notification(user: User, message: str):
    user_email = user.get_email()
    print(f"Sending email to {user_email}: {message}")
  • With Liskov Substitution Principle
from abc import ABC, abstractmethod

class UserInterface(ABC):
    @abstractmethod
    def get_name(self) -> str:
        pass

    @abstractmethod
    def get_email(self) -> str:
        pass

class User(UserInterface):
    def __init__(self, name: str, email: str):
        self.name = name
        self.email = email

    def get_name(self) -> str:
        return self.name

    def get_email(self) -> str:
        return self.email

class Admin(UserInterface):
    def __init__(self, name: str, email: str, access_level: int):
        self.name = name
        self.email = email
        self.access_level = access_level

    def get_name(self) -> str:
        return self.name

    def get_email(self) -> str:
        return self.email

    def get_access_level(self) -> int:
        return self.access_level

def send_email_notification(user: UserInterface, message: str):
    user_email = user.get_email()
    print(f"Sending email to {user_email}: {message}")

Key differences:

  1. Without LSP:

    • The Admin class overrides the get_email method to prepend "ADMIN:" to the email, introducing behavior that deviates from the base User class.

    • The Admin class's get_email method alters the expected behavior by adding a prefix to the email. This breaks the substitutability of Admin for User because send_email_notification expects a regular email string.

    • The design mixes different behaviors within the same hierarchy, making it harder to predict and maintain the code.

  2. With LSP:

  • Both User and Admin classes implement the UserInterface and maintain consistent behavior for the get_email method, adhering to the interface's contract.

  • The Admin class does not modify the behavior of get_email. Both User and Admin classes return the email as a plain string, ensuring consistent behavior when used interchangeably.

  • The design uses an interface (UserInterface) to clearly define the expected behaviors, making it easier to extend and maintain.

Interface Segregation Principle (ISP)

Many client-specific interfaces are better than one general-purpose interface.

This Principle states that clients should not be forced to depend on interfaces they do not use. In other words, it's better to have many smaller, specific interfaces rather than a few large, general-purpose ones.

  • Without ISP
from abc import ABC, abstractmethod

class UserInterface(ABC):
    @abstractmethod
    def get_name(self) -> str:
        pass

    @abstractmethod
    def get_email(self) -> str:
        pass

    @abstractmethod
    def save_to_database(self):
        pass

    @abstractmethod
    def send_email(self, message: str):
        pass

    @abstractmethod
    def generate_report(self):
        pass

class RegularUser(UserInterface):
    def __init__(self, name: str, email: str):
        self.name = name
        self.email = email

    def get_name(self) -> str:
        return self.name

    def get_email(self) -> str:
        return self.email

    def save_to_database(self):
        print(f"Saving user {self.name} to database")

    def send_email(self, message: str):
        print(f"Sending email to {self.email}: {message}")

    def generate_report(self):
        raise NotImplementedError("Regular users cannot generate reports")

class AdminUser(UserInterface):
    def __init__(self, name: str, email: str):
        self.name = name
        self.email = email

    def get_name(self) -> str:
        return self.name

    def get_email(self) -> str:
        return self.email

    def save_to_database(self):
        print(f"Saving admin {self.name} to database")

    def send_email(self, message: str):
        print(f"Sending email to admin {self.email}: {message}")

    def generate_report(self):
        print(f"Admin {self.name} is generating a report")

All the methods which may or may not be required by the subclass are in the same super class, which results in violation of ISP.

  • With ISP
from abc import ABC, abstractmethod

class UserBasicInfo(ABC):
    @abstractmethod
    def get_name(self) -> str:
        pass

    @abstractmethod
    def get_email(self) -> str:
        pass

class DatabaseUser(ABC):
    @abstractmethod
    def save_to_database(self):
        pass

class EmailUser(ABC):
    @abstractmethod
    def send_email(self, message: str):
        pass

class ReportGenerator(ABC):
    @abstractmethod
    def generate_report(self):
        pass

class RegularUser(UserBasicInfo, DatabaseUser, EmailUser):
    def __init__(self, name: str, email: str):
        self.name = name
        self.email = email

    def get_name(self) -> str:
        return self.name

    def get_email(self) -> str:
        return self.email

    def save_to_database(self):
        print(f"Saving user {self.name} to database")

    def send_email(self, message: str):
        print(f"Sending email to {self.email}: {message}")

class AdminUser(UserBasicInfo, DatabaseUser, EmailUser, ReportGenerator):
    def __init__(self, name: str, email: str):
        self.name = name
        self.email = email

    def get_name(self) -> str:
        return self.name

    def get_email(self) -> str:
        return self.email

    def save_to_database(self):
        print(f"Saving admin {self.name} to database")

    def send_email(self, message: str):
        print(f"Sending email to admin {self.email}: {message}")

    def generate_report(self):
        print(f"Admin {self.name} is generating a report")

All the major functions have their separate class as they are not required by every particular user.

Key difference:

  1. Without ISP

    • The UserInterface contains methods for all possible actions (get_name, get_email, save_to_database, send_email, generate_report), forcing all implementing classes to provide implementations for all methods, even those they don't need.

    • RegularUser is forced to implement generate_report, even though it doesn't make sense for a regular user to generate reports, leading to a NotImplementedError.

    • The single interface combines methods for basic info retrieval, database operations, email operations, and report generation, leading to a lack of separation of concerns.

    • Clients of UserInterface might have to deal with methods they don't use or care about.

  2. With ISP

    • The responsibilities are broken down into smaller, more specific interfaces (UserBasicInfo, DatabaseUser, EmailUser, ReportGenerator). Classes only implement the interfaces relevant to their functionality.

    • RegularUser does not implement ReportGenerator, avoiding unnecessary method implementations and the NotImplementedError problem.

    • Each smaller interface focuses on a specific concern, promoting better separation of concerns and cleaner, more modular code.

    • New functionality can be added by creating new interfaces without impacting existing ones. Changes are isolated to specific interfaces and their implementing classes, making the system more scalable and easier to maintain.

    • Clients can depend on specific interfaces tailored to their needs, reducing unnecessary dependencies and making the system more flexible.

Dependency Inversion Principle (DIP)

Depend upon abstractions, not concretions.

  • Without DIP
class MySQLDatabase:
    def save(self, data: dict):
        print(f"Saving data to MySQL database: {data}")

class User:
    def __init__(self, name: str, email: str):
        self.name = name
        self.email = email
        self.database = MySQLDatabase()  # Direct dependency on MySQLDatabase

    def save(self):
        self.database.save({"name": self.name, "email": self.email})

In this example, the User class has a direct dependency on the MySQLDatabase class. This violates the Dependency Inversion Principle

  • With DIP
from abc import ABC, abstractmethod

# Abstract base class (interface) for database operations
class DatabaseInterface(ABC):
    @abstractmethod
    def save(self, data: dict):
        pass

# Concrete implementation of DatabaseInterface for MySQL
class MySQLDatabase(DatabaseInterface):
    def save(self, data: dict):
        print(f"Saving data to MySQL database: {data}")

# Concrete implementation of DatabaseInterface for PostgreSQL
class PostgreSQLDatabase(DatabaseInterface):
    def save(self, data: dict):
        print(f"Saving data to PostgreSQL database: {data}")

class User:
    def __init__(self, name: str, email: str, database: DatabaseInterface):
        self.name = name
        self.email = email
        self.database = database  # Dependency injection

    def save(self):
        self.database.save({"name": self.name, "email": self.email})

The User class depends on an abstract interface (DatabaseInterface) rather than a concrete implementation. This follows the Dependency Inversion Principle (DIP), where high-level modules should not depend on low-level modules but rather on abstractions.

Key difference:

  1. Without DIP

    • The User class is rigid and inflexible. If you want to change the database implementation (e.g., switch to PostgreSQL), you need to modify the User class.

    • Testing the User class is more challenging because it is directly tied to the MySQLDatabase implementation. You need to have an actual MySQL database or mock it specifically.

    • The User class is responsible for knowing which database to use, violating the single responsibility principle (SRP).

    • The User class creates its own MySQLDatabase instance, leading to tight coupling and making the class less reusable.

  2. With DIP

    • The User class is flexible and extensible. You can easily switch database implementations (e.g., from MySQL to PostgreSQL) without modifying the User class, as long as the new implementation adheres to the DatabaseInterface.

    • Testing is easier because you can inject any implementation of the DatabaseInterface, including mock objects, making unit testing more straightforward and isolated.

    • The User class only concerns itself with user data and delegates the persistence responsibility to the DatabaseInterface, adhering to the single responsibility principle.

    • The User class receives its database dependency through dependency injection, promoting loose coupling and enhancing reusability.

Conclusion

We have seen how using SOLID Principles together could improve the code quality increase readability which could be understandable for any programmer who may and may have not worked in the particular software, also suppress errors and most importantly it gives us a template that can be used in software development. IIn some cases, applying SOLID Principles might seem futile, but it is particularly beneficial in complex projects where the application has diverse methods and functions. Remember, writing SOLID code isn't just about following rules—it's about crafting maintainable, flexible, and robust software. So go ahead, embrace SOLID principles, and make your code rock-solid! Happy coding! 🧱✨

Did you find this article valuable?

Support Arif Shaikh by becoming a sponsor. Any amount is appreciated!