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:
Single Responsibility Principle (SRP)
Open-Closed Principle (OCP)
Liskov Substitution Principle (LSP)
Interface Segregation Principle (ISP)
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:
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.
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:
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.
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 anyUserRepository
, 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:
Without LSP:
The
Admin
class overrides theget_email
method to prepend "ADMIN:" to the email, introducing behavior that deviates from the baseUser
class.The
Admin
class'sget_email
method alters the expected behavior by adding a prefix to the email. This breaks the substitutability ofAdmin
forUser
becausesend_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.
With LSP:
Both
User
andAdmin
classes implement theUserInterface
and maintain consistent behavior for theget_email
method, adhering to the interface's contract.The
Admin
class does not modify the behavior ofget_email
. BothUser
andAdmin
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:
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 implementgenerate_report
, even though it doesn't make sense for a regular user to generate reports, leading to aNotImplementedError
.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.
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 implementReportGenerator
, avoiding unnecessary method implementations and theNotImplementedError
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:
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 theUser
class.Testing the
User
class is more challenging because it is directly tied to theMySQLDatabase
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 ownMySQLDatabase
instance, leading to tight coupling and making the class less reusable.
With DIP
The
User
class is flexible and extensible. You can easily switch database implementations (e.g., from MySQL to PostgreSQL) without modifying theUser
class, as long as the new implementation adheres to theDatabaseInterface
.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 theDatabaseInterface
, 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! 🧱✨