SOLID Principles Explained: A Guide to Better Object-Oriented Design
You’ve probably heard of SOLID principles. especially if you’re already familiar with Object Oriented Programming.
These five principles of software development are important guidelines to follow when developing software, as they help make it easier to scale and maintain. They were popularized by software engineer Robert C. Martin (Uncle Bob).
These principles aim to make the code more readable, easy to maintain, extensible, reusable and without repetition.
Some of these principles may seem very similar, but they aim at different goals and even though they are similar it is very easy to satisfy one while violating the other.
Note: They can be applied not only to classes, but also to modules, functions or methods. For the sake of simplicity, in this article I will only use classes as an example.
Also, I will use Python as an example language ̶b̶e̶c̶a̶u̶s̶e̶ ̶I̶ ̶w̶a̶n̶t̶ ̶t̶o̶ because I think it is easy to understand even for those who are not familiar with Python.
These are the SOLID principles:
S — Single Responsibility Principle
O — Open-Closed Principle
L — Liskov Substitution Principle
I — Interface Segregation Principle
D — Dependency Inversion Principle
1. SRP — Single Responsibility Principle
The Single Responsibility Principle (SRP) is one of the five SOLID principles of object-oriented programming and software design. It states that a class should have only one reason to change, meaning it should have only one responsibility or job. This principle aims to make the system more modular, easier to understand, maintain, and less prone to bugs.
Key points of SRP:
- Single Responsibility: Each class or module should only have one responsibility. This means it should focus on a single part of the functionality provided by the software, making it easier to manage and modify.
- Separation of Concerns: By ensuring that each class or module addresses only one concern, SRP promotes separation of concerns. This separation helps in isolating changes and understanding the system better.
- Ease of Maintenance: When a class has only one responsibility, any change related to that responsibility affects only that class. This makes the code easier to maintain and less error-prone.
- Improved Readability: A class with a single responsibility is generally smaller and more focused, which makes it easier to read and understand.
- Facilitates Testing: Classes with a single responsibility are easier to test because they have fewer dependencies and a clear, concise behavior.
Example:
Suppose we have a class that does more than one thing: manages user information and also handles data persistence in a file.
class UserManager:
def __init__(self, name, age, email):
self.name = name
self.age = age
self.email = email
def get_user_info(self):
return {
'name': self.name,
'age': self.age,
'email': self.email
}
def save_to_file(self, filename):
with open(filename, 'w') as file:
file.write(f"Name: {self.name}\n")
file.write(f"Age: {self.age}\n")
file.write(f"Email: {self.email}\n")
Explanation:
UserManager
class: This class is responsible for both managing user information and saving that information to a file.- Violating SRP: According to the SRP, a class should have only one reason to change. In this example,
UserManager
has two reasons to change:
1. If the way user information is managed changes.
2. If the way user information is saved to a file changes.
To adhere to the Single Responsibility Principle (SRP), we can divide the functionality into two classes: one to manage user information and another to handle data persistence. Here is an example of how this can be done:
class User:
def __init__(self, name, age, email):
self.name = name
self.age = age
self.email = email
def get_user_info(self):
return {
'name': self.name,
'age': self.age,
'email': self.email
}
class UserPersistence:
@staticmethod
def save_to_file(user, filename):
with open(filename, 'w') as file:
file.write(f"Name: {user.name}\n")
file.write(f"Age: {user.age}\n")
file.write(f"Email: {user.email}\n")
user = User("John Doe", 30, "john.doe@example.com")
print(user.get_user_info())
persistence = UserPersistence()
persistence.save_to_file(user, "user_info.txt")
Explanation:
User
class: Responsible only for managing user information.UserPersistence
class: Responsible only for handling the persistence of user data (saving to a file in this case).
This separation ensures that each class has a single responsibility and a single reason to change, adhering to the SRP of SOLID.
2. OCP — Open Closed Principle
The Open/Closed Principle (OCP) is another of the SOLID principles, stating that software entities (such as classes, modules, or functions) should be open for extension but closed for modification. In other words, you should be able to add new functionality without changing the existing code.
Key points of OCP:
1 — Open for Extension: The system should be designed so that you can add new features or behaviors without modifying the code that is already in production. This is typically achieved using abstractions, interfaces, or base classes that can be extended.
2 — Closed for Modification: Once code is written and tested, it should not be altered to add new functionalities. Modifying existing code can introduce bugs and affect the system’s behavior.
3 — Use of Polymorphism: OCP is often implemented using polymorphism. You define an interface or a base class with a set of methods, and then create subclasses that implement or extend these methods to add new functionalities.
4 — Ease of Maintenance: By adhering to OCP, you can add new functionalities to the system without having to change existing code, which reduces the likelihood of introducing new bugs and makes maintenance easier.
Example:
Let’s assume we have a Discount Calculator class that calculates discounts for different types of customers:
class DiscountCalculator:
def calculate_discount(self, customer_type, amount):
if customer_type == "regular":
return amount * 0.05
elif customer_type == "vip":
return amount * 0.1
else:
return 0
calculator = DiscountCalculator()
print(calculator.calculate_discount("regular", 100))
print(calculator.calculate_discount("vip", 100))
Explanation:
- This implementation violates OCP because if we want to add a new type of customer, such as a “premium” customer, we will have to modify the
DiscountCalculator
class.
To adhere to the OCP Now let’s see how we can refactor this code to adhere to OCP using inheritance and polymorphism:
from abc import ABC, abstractmethod
class DiscountStrategy(ABC):
@abstractmethod
def calculate_discount(self, amount):
pass
class RegularCustomerDiscount(DiscountStrategy):
def calculate_discount(self, amount):
return amount * 0.05
class VIPCustomerDiscount(DiscountStrategy):
def calculate_discount(self, amount):
return amount * 0.1
class PremiumCustomerDiscount(DiscountStrategy):
def calculate_discount(self, amount):
return amount * 0.15
class DiscountCalculator:
def __init__(self, discount_strategy: DiscountStrategy):
self.discount_strategy = discount_strategy
def calculate_discount(self, amount):
return self.discount_strategy.calculate_discount(amount)
regular_discount = DiscountCalculator(RegularCustomerDiscount())
vip_discount = DiscountCalculator(VIPCustomerDiscount())
premium_discount = DiscountCalculator(PremiumCustomerDiscount())
print(regular_discount.calculate_discount(100))
print(vip_discount.calculate_discount(100))
print(premium_discount.calculate_discount(100))
Explanation:
DiscountStrategy
,RegularCustomerDiscount
,VIPCustomerDiscount
, andPremiumCustomerDiscount
classes: Each customer type has its own discount class that extendsDiscountStrategy
. This allows for adding new types of discounts without modifying existing code.DiscountCalculator
class: This class now takes a discount strategy and calculates the discount using that strategy. This makes the class open for extension (new discount strategies can be added) and closed for modification (we don’t need to changeDiscountCalculator
to add new discount types).
This approach respects OCP because you can extend the behavior of the application (adding new discount types) without modifying the existing code.
3. LSP — Liskov Substitution Principle
The Liskov Substitution Principle (LSP) is another SOLID principle. It was introduced by Barbara Liskov in her “Data abstraction” conference in 1987.
This principle states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. In other words, subclasses should be able to stand in for their parent classes without altering the desirable properties of the program.
Here are the key points of LSP:
- Substitutability: Derived classes must be substitutable for their base classes. This means that any instance of a base class should be replaceable with an instance of a derived class without altering the correctness of the program.
- Behavior Consistency: Subclasses should adhere to the behavior expected by the base class. This includes not violating any invariants or assumptions made by the base class.
- Preconditions and Postconditions: Subclasses should not strengthen the preconditions or weaken the postconditions of the base class methods. This ensures that derived classes do not introduce stricter requirements or less strict guarantees.
Example:
Consider a base class License
with a method calc_fee
, and a subclass PersonalLicense
that extends License
:
class License:
def calc_fee(self):
pass
class PersonalLicense(License):
def calc_fee(self):
return 100
class BusinessLicense(License):
def calc_fee(self, business_type):
if business_type == 'small':
return 200
elif business_type == 'medium':
return 300
elif business_type == 'large':
return 500
else:
raise ValueError("Unknown type of business")
class Billing:
def __init__(self, license: License):
self.license = license
def calculate(self):
return self.license.calc_fee()
personal_license = PersonalLicense()
billing_personal = Billing(personal_license)
print(billing_personal.calculate()) # Works
business_license = BusinessLicense()
billing_business = Billing(business_license)
print(billing_business.calculate()) # Will raise an exception because `calc_fee` requires a parameter.
Explanation:
- The
BusinessLicense
class violates LSP because itscalc_fee
method has a different signature than theLicense
base class, which causes problems when it is used by theBilling
class.
To adhere to the LSP we need to allow instances of License
to be replaced by instances of its subclasses without changing the correct behavior of the application.
from abc import ABC, abstractmethod
class License(ABC):
@abstractmethod
def calc_fee(self):
pass
class PersonalLicense(License):
def calc_fee(self):
return 100
class BusinessLicense(License):
def __init__(self, business_type):
self.business_type = business_type
def calc_fee(self):
if self.business_type == 'small':
return 200
elif self.business_type == 'medium':
return 300
elif self.business_type == 'large':
return 500
else:
raise ValueError("Unknown type of business")
class Billing:
def __init__(self, license: License):
self.license = license
def calculate(self):
return self.license.calc_fee()
personal_license = PersonalLicense()
billing_personal = Billing(personal_license)
print(billing_personal.calculate()) # Works
business_license = BusinessLicense('medium')
billing_business = Billing(business_license)
print(billing_business.calculate()) # Works, returns 300
Explanation:
- Both
PersonalLicense
andBusinessLicense
correctly implement theLicense
interface. Thecalc_fee
method in both classes has the same signature, ensuring that they can be used interchangeably in the Billing class.
This adheres to the Liskov Substitution Principle, allowing instances of License to be replaced by instances of its subclasses without altering the correct behavior of the application.
4. ISP — Interface Segregation Principle
The Interface Segregation Principle (ISP) is another SOLID principle. It was introduced by Robert C. Martin and emphasizes the design of fine-grained interfaces that are client-specific.
This principle states that no client should be forced to depend on interfaces it does not use. Instead of having one large, general-purpose interface, it’s better to create multiple smaller, more specific interfaces. This way, clients can only depend on the methods that are relevant to them, leading to a more modular and maintainable codebase.
The key points of ISP:
- Client-Specific Interfaces: Interfaces should be tailored to the specific needs of each client. This means breaking down large interfaces into smaller ones.
- Cohesion: Each interface should group methods that are logically related, ensuring high cohesion.
- Reduced Coupling: By depending only on the interfaces they need, clients reduce unnecessary dependencies, leading to lower coupling.
Example:
Consider an interface FileOperations
with methods read
, write
, and delete
, and two classes ReadFile
and WriteFile
that implement FileOperations
:
from abc import ABC, abstractmethod
class FileOperations(ABC):
@abstractmethod
def read(self):
pass
@abstractmethod
def write(self, data):
pass
@abstractmethod
def delete(self):
pass
class ReadFile(FileOperations):
def read(self):
print("Reading file")
def write(self, data):
raise NotImplementedError("ReadFile does not support write operation")
def delete(self):
raise NotImplementedError("ReadFile does not support delete operation")
class WriteFile(FileOperations):
def read(self):
raise NotImplementedError("WriteFile does not support read operation")
def write(self, data):
print("Writing to file")
def delete(self):
raise NotImplementedError("WriteFile does not support delete operation")
Explanation:
- The
ReadFile
andWriteFile
classes violate ISP because they have to implement methods that are irrelevant to them. This makes the code less clear and introduces unnecessary complexity.
To adhere to the ISP, we need to create separate interfaces for read, write, and delete operations, allowing each class to implement only what it needs.
from abc import ABC, abstractmethod
class Readable(ABC):
@abstractmethod
def read(self):
pass
class Writable(ABC):
@abstractmethod
def write(self, data):
pass
class Deletable(ABC):
@abstractmethod
def delete(self):
pass
class ReadFile(Readable):
def read(self):
print("Reading file")
class WriteFile(Writable):
def write(self, data):
print("Writing to file")
class DeleteFile(Deletable):
def delete(self):
print("Deleting file")
Explanation:
- By splitting the
FileOperations
interface intoReadable
,Writable
, andDeletable
, we ensure thatReadFile
,WriteFile
, andDeleteFile
only implement the methods they need.
This adheres to the Interface Segregation Principle, making the codebase more modular and maintainable.
5. DIP — Dependency Inversion Principle
The Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. Furthermore, abstractions should not depend on details; details should depend on abstractions.
Here are the key points of DIP:
- High-Level Modules: High-level modules should define the business logic and should not depend on the details of how lower-level modules implement their functionality.
- Abstractions Over Details: Both high-level and low-level modules should depend on abstractions (interfaces or abstract classes), rather than on concrete implementations.
- Dependency Injection: One way to achieve DIP is through dependency injection, where dependencies are provided to a class rather than the class creating them internally.
Example:
Consider a high-level class OrderService
that depends on a low-level class EmailService
:
class EmailService:
def send_email(self, recipient, subject, body):
print(f"Sending email to {recipient}")
class OrderService:
def __init__(self):
self.email_service = EmailService()
def process_order(self, order):
self.email_service.send_email(order.customer_email, "Order Confirmation", "Your order has been processed")
Explanation:
- OrderService class: Directly depends on
EmailService
, making it difficult to change the email service or use a different implementation.
To adhere to DIP, introduce an abstraction:
from abc import ABC, abstractmethod
class EmailService(ABC):
@abstractmethod
def send_email(self, recipient, subject, body):
pass
class SmtpEmailService(EmailService):
def send_email(self, recipient, subject, body):
print(f"Sending SMTP email to {recipient} with subject: {subject}")
class ImapEmailService(EmailService):
def send_email(self, recipient, subject, body):
print(f"Sending IMAP email to {recipient} with subject: {subject}")
class OrderService:
def __init__(self, email_service: EmailService):
self.email_service = email_service
def process_order(self):
self.email_service.send_email("customer@example.com", "Order Confirmation", "Your order has been processed")
smtp_email_service = SmtpEmailService()
order_service = OrderService(smtp_email_service)
imap_email_service = ImapEmailService()
order_service = OrderService(imap_email_service)
Explanation:
- EmailService interface: Defines an abstract contract for email sending.
- SmtpEmailService class: Implements the
EmailService
interface for a specific email service. - OrderService class: Depends on the
EmailService
abstraction rather than a concrete implementation.
This respects DIP by ensuring that high-level modules depend on abstractions rather than concrete details.
Conclusion
The SOLID principles provide a strong foundation for writing robust, flexible, and maintainable code. They help avoid common design and development issues such as high coupling and low cohesion, promoting more modular and scalable systems.
To recap:
- Single Responsibility Principle (SRP): A class should have only one responsibility.
- Open/Closed Principle (OCP): Software should be open for extension but closed for modification.
- Liskov Substitution Principle (LSP): Objects of a base class should be replaceable with objects of their subclasses without affecting functionality.
- Interface Segregation Principle (ISP): Clients should not be forced to depend on interfaces they do not use.
- Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules; both should depend on abstractions.
By applying these principles, you can significantly improve the quality of your code, making it easier to maintain, scale, and reuse. Remember, the SOLID principles are not strict rules but guidelines to help make better design decisions in your software. Continuously revisit and refine your code to ensure it continues to adhere to these principles as your project evolves.