SOLID PRINCIPLES : A Project-based approach
Introduction:
Crafting a project using object-oriented programming (OOP) involves more than just writing code — it requires thoughtful planning of how classes and objects collaborate to address specific challenges. This critical phase is known as Object-Oriented Design (OOD). Fortunately, if you find yourself grappling with the design of your classes, there’s a set of principles that can serve as your guiding light — the SOLID principles.
SOLID represents a collection of five foundational principles in object-oriented design. These principles act as a compass, steering you towards creating code that is not only functional but also maintainable, flexible, and scalable. By adhering to SOLID, you pave the way for well-crafted, cleanly structured classes that stand as pillars of best practices in object-oriented design.
Lead In:
Embark on a journey into the realm of SOLID principles here, as we explore the intricacies of designing classes for a project focused on generating and dispatching diverse reports, through the practical application of fundamental SOLID concepts, including the Single Responsibility Principle (SRP), Open/Closed Principle (OCP), Liskov Substitution Principle (LSP), Interface Segregation Principle (ISP), and Dependency Inversion Principle (DIP). To illustrate these principles in action, we’ll showcase an example featuring three distinct report types -PDFReport, HTMLReport, and ExcelReport — while seamlessly integrating a mechanism for report dispatching.
Dive In:
Single-responsibility principle (SRP)
“A class should have one, and only one, reason to change.’’
In essence, the Single Responsibility Principle dictates that a class should possess a singular responsibility, as indicated by its methods. If a class finds itself managing multiple tasks, it is advisable to segregate those tasks into distinct classes.
Let’s start with an example of a Single Responsibility Principle (SRP) violation in Python, followed by a refactored version that adheres to SRP.
SRP Violation Example:
Consider a Report class that not only generates a report but also handles the responsibility of persisting the report to a file. This violates the SRP, as the class has more than one reason to change (report generation and file persistence).
class Report:
def __init__(self, content):
self.content = content
def generate_report(self):
# Generate the report content
def save_to_file(self, filename):
# Save the report content to a file
with open(filename, 'w') as file:
file.write(self.content)
In this example, the Report is responsible for both generating the report content and saving it to a file. If either of these responsibilities changes, the class needs to be modified.
SRP-Compliant Example:
Let’s refactor the code to adhere to the SRP by separating the concerns of report generation and file persistence into two distinct classes.
class Report:
def __init__(self, content):
self.content = content
def generate_report(self):
# Generate the report content
class ReportSaver:
@staticmethod
def save_to_file(report, filename):
# Save the report content to a file
with open(filename, 'w') as file:
file.write(report.content)
Now, we have two classes: Report focuses solely on generating report content, and ReportSaver handles the responsibility of saving the report to a file. This adheres to the Single Responsibility Principle, as each class has a single reason to change.
This separation allows for more flexibility and maintainability. If the report generation logic or the file-saving mechanism needs to change, it can be done in the respective class without affecting the other.
Open–closed principle (OCP)
“You should be able to extend a class behavior, without modifying it.”
In simpler terms, this principle encourages designing software components in a way that allows for easy extension or addition of new features without modifying the existing code. The emphasis is on avoiding changes to the existing, well-tested codebase when introducing new functionality.
Here’s a breakdown of the Open/Closed Principle:
- Open for Extension:
- The design should allow for adding new functionality or features easily.
- You should be able to extend the behavior of a module without altering its source code.
2. Closed for Modification:
- Once a module (class, function, etc.) is stable and in use, its source code should not be changed.
- Existing components should remain untouched when adding new features.
Let’s start with an example of an Open–closed principle (OCP) violation in Python, followed by a refactored version that adheres to OCP.
OCP Violation Example:
Let’s consider a scenario where we have a system that generates reports, and we want to add new types of reports. In this example, when we want to introduce a new report type (HTML report), we need to modify the existing Report class, violating the Open/Closed Principle.
class Report:
def __init__(self, data):
self.data = data
def generate_report(self, report_type):
if report_type == 'pdf':
# Generate PDF report
print(f"Generating PDF report for data: {self.data}")
elif report_type == 'excel':
# Generate Excel report
print(f"Generating Excel report for data: {self.data}")
else:
raise ValueError("Unsupported report type")
# Later, we want to add a new report type (HTML report)
class Report:
def __init__(self, data):
self.data = data
def generate_report(self, report_type):
if report_type == 'pdf':
print(f"Generating PDF report for data: {self.data}")
elif report_type == 'excel':
print(f"Generating Excel report for data: {self.data}")
# New condition for HTML report
elif report_type == 'html':
print(f"Generating HTML report for data: {self.data}")
else:
raise ValueError("Unsupported report type")
OCP Compliant Example:
To adhere to the Open/Closed Principle, we can design the system to be open for extension by using a strategy pattern.
from abc import ABC, abstractmethod
class Report(ABC):
def __init__(self, data):
self.data = data
@abstractmethod
def generate_report(self):
pass
class PDFReport(Report):
def generate_report(self):
print(f"Generating PDF report for data: {self.data}")
class ExcelReport(Report):
def generate_report(self):
print(f"Generating Excel report for data: {self.data}")
class HTMLReport(Report):
def generate_report(self):
print(f"Generating HTML report for data: {self.data}")
# Usage
data = {'title': 'Sales Report', 'values': [100, 150, 200, 120]}
pdf_report = PDFReport(data)
pdf_report.generate_report()
excel_report = ExcelReport(data)
excel_report.generate_report()
html_report = HTMLReport(data)
html_report.generate_report()
Now, with the introduction of the Report base class and separate classes for each report type (PDFReport, ExcelReport, and HTMLReport), we can easily extend the system by adding new report types without modifying the existing code. This adheres to the Open/Closed Principle.
Liskov substitution principle (LSP)
“Derived classes must be substitutable for their base classes, i.e., if S is a subtype of T, then objects of type T may be replaced by objects of type S, without breaking the program.”
This principle aims to ensure that a subclass can seamlessly take the position of its superclass without encountering errors. If the code is compelled to check the class type explicitly, it indicates a violation of this principle.
Let’s start with an example of an LSV violation in Python, followed by a refactored version that adheres to LSV.
LSV Violation Example:
Let’s use our Report example.
def send_report(report_list: list):
for report_data in report_list:
if isinstance(report_data, HTMLReport):
print(send_html_report(report_data))
elif isinstance(report_data, PDFReport):
print(send_pdf_report(report_data))
elif isinstance(report_data, ExcelReport):
print(send_excel_report(report_data))
LSV Compliant Example:
def send_report_based_on_type(reports: list):
for report in reports:
print(report.send_report())
send_report(reports)
The send_report_based_on_type function cares less about the type of report passed, it just calls the send_report method. All it knows is that the parameter must be of a report type, either the report class or its sub-class. The report class now has to implement/define a send_report method. And its sub-classes have to implement the send_report method.
from abc import ABC, abstractmethod
class Report(ABC):
def __init__(self, data):
self.data = data
@abstractmethod
def generate_report(self):
pass
@abstractmethod
def send_report(self):
pass
class HTMLReport(Report):
def generate_report(self):
print("Generating HTML report")
def send_report(self):
print("sending report")
class PDFReport(Report):
def generate_report(self):
print("Generating PDF report")
def send_report(self):
print("sending report")
Interface segregation principle (ISP)
“A class should not be forced to implement interfaces it does not use.”
Put simply, the Interface Segregation Principle (ISP) advises that a class shouldn’t be forced to include methods it doesn’t use. Instead of having big interfaces with everything bundled together, it’s more beneficial to have smaller, focused interfaces tailored for specific tasks. This way, we steer clear of unnecessary requirements, and classes are only required to implement methods directly related to their specific tasks. This practice reduces dependencies and ensures that classes are only responsible for what’s truly relevant to their functionality.
Let’s start with an example of an ISP violation in Python, followed by a refactored version that adheres to ISP.
ISP Violation Example:
from abc import ABC, abstractmethod
class Report(ABC):
def __init__(self, data):
self.data = data
@abstractmethod
def generate_report(self):
pass
@abstractmethod
def send(self):
pass
class HTMLReport(Report):
def generate_report(self):
print("Generating HTML report")
def send(self):
print("sending report")
class PDFReport(Report):
def generate_report(self):
print("Generating PDF report")
def send(self):
print("sending report")
class ExcelReport(Report):
def generate_report(self):
print("Generating Excel report")
def send(self):
raise NotImplementedError("Excel is only downloadable!")
Here, even though the Excel Report is not sendable, it had to implement the send method only to raise an exception. This violates the ISP because the interface enforces the implementation of methods that are not relevant to all classes implementing it.
A better approach would be to create separate interfaces for report generation and report sending, allowing classes to implement only the methods they actually need.
ISP Compliant Example:
With separate interfaces (Report and SendReport), classes like ExcelReport only need to implement the methods that are relevant to their functionality. This adheres to the Interface Segregation Principle.
from abc import ABC, abstractmethod
class Report(ABC):
def __init__(self, data):
self.data = data
@abstractmethod
def generate_report(self):
pass
class SendReport(ABC):
@abstractmethod
def send(self):
pass
class HTMLReport(Report, SendReport):
def generate_report(self):
print("Generating HTML report")
def send(self):
print("sending report")
class PDFReport(Report, SendReport):
def generate_report(self):
print("Generating PDF report")
def send(self):
print("sending report")
class ExcelReport(Report):
def generate_report(self):
print("Generating Excel report")
Dependency inversion principle (DIP)
“Depend on abstractions, not on concretions.”
The Dependency Inversion Principle is intended to guide the organization of software components and the relationships between them.
- High-level modules should not depend upon low-level modules. Both should depend upon abstractions.
- Abstractions should not depend on details. Details should depend upon abstractions.
In simpler terms, the Dependency Inversion Principle encourages the use of abstractions (such as interfaces or abstract classes) to decouple high-level modules from low-level modules. This inversion of dependencies helps to create a more flexible and maintainable system.
Key points of the Dependency Inversion Principle:
- High-level modules: These are the modules that contain the main logic or business rules of the application.
- Low-level modules: These are the modules that deal with the implementation details, often interacting with external systems or performing specific tasks.
- Abstractions: Interfaces or abstract classes that define the contract between high-level and low-level modules. High-level modules depend on abstractions, not on concrete implementations.
- Details: Concrete implementations that depend on abstractions.
By following the Dependency Inversion Principle, you create a system where changes in low-level modules do not directly affect high-level modules, promoting a more modular and easily maintainable codebase. It also facilitates the use of dependency injection, allowing dependencies to be injected rather than hard-coded, which enhances testability and flexibility.
Let’s start with an example of a DIP violation in Python, followed by a refactored version that adheres to DIP.
DIP Violation Example:
class ReportGenerator:
def generate_report(self, report_type):
if report_type == "PDF":
pdf_report = PDFReport()
pdf_report.generate()
elif report_type == "HTML":
html_report = HTMLReport()
html_report.generate()
class PDFReport:
def generate(self):
print("Generating PDF report")
class HTMLReport:
def generate(self):
print("Generating HTML report")
# Violation: ReportGenerator (high-level module) directly depends on PDFReport and HTMLReport (low-level modules)
generator = ReportGenerator()
generator.generate_report("PDF")
generator.generate_report("HTML")
In this example, the ReportGenerator class (high-level module) directly depends on the PDFReport and HTMLReport classes (low-level modules). This violates the Dependency Inversion Principle.
DIP Compliant Example:
Using an abstraction to decouple high-level and low-level modules:
from abc import ABC, abstractmethod
# Abstraction (interface)
class Report(ABC):
@abstractmethod
def generate(self):
pass
# Low-level modules implementing the abstraction
class PDFReport(Report):
def generate(self):
print("Generating PDF report")
class HTMLReport(Report):
def generate(self):
print("Generating HTML report")
# High-level module depending on the abstraction, not the concrete implementations
class ReportGenerator:
def generate_report(self, report_type: Report):
report_type.generate()
# Compliance: ReportGenerator (high-level module) depends on Report (abstraction), not PDFReport and HTMLReport (low-level modules)
pdf_report = PDFReport()
html_report = HTMLReport()
generator = ReportGenerator()
generator.generate_report(pdf_report)
generator.generate_report(html_report)
In the compliant version, the ReportGenerator class depends on the Report abstraction (interface), and the PDFReport and HTMLReport classes implement this abstraction. This adheres to the Dependency Inversion Principle by decoupling high-level and low-level modules through the use of an abstraction. Now, you can easily extend the system by adding more report types that implement the Report interface without modifying the ReportGenerator class.
Final Code:
Let’s examine the entire code collectively and assess its adherence to SOLID principles.
from abc import ABC, abstractmethod
class Report(ABC):
def __init__(self, content):
self.content = content
class ReportSaver:
@staticmethod
def save_to_file(report, filename):
# Save the report content to a file
with open(filename, 'w') as file:
file.write(report.content)
class Generateable(ABC):
@abstractmethod
def generate(self):
pass
class Sendable(ABC):
@abstractmethod
def send(self):
pass
class PDFReport(Report, Generateable, Sendable):
def generate(self):
return "Generated PDF report"
def send(self):
print("Sending PDF report")
class HTMLReport(Report, Generateable, Sendable):
def generate(self):
return "Generated HTML report"
def send(self):
print("Sending HTML report")
class ExcelReport(Report, Generateable):
def generate(self):
return "Generated Excel report"
class ReportSender:
def __init__(self, send_strategy):
self.send_strategy = send_strategy
def send(self, report):
print(f"Sending report: {self.send_strategy.send(report)}")
def send_report(report):
if isinstance(report, Sendable):
sender = ReportSender(report)
sender.send(report)
else:
print(f"Report type {type(report).__name__} is not Sendable. Cannot send.")
# Usage Example
pdf_report = PDFReport()
html_report = HTMLReport()
excel_report = ExcelReport()
# other business logic
# ...
send_report(pdf_report)
send_report(html_report)
send_report(excel_report) # Handle the case where the report is not Sendable
Let’s briefly go through each SOLID principle and see how our code aligns with them:
- Single Responsibility Principle (SRP):
- Report, ReportSaver, Generateable, Sendable, PDFReport, HTMLReport, ExcelReport, ReportSender: Each class seems to have a single responsibility, such as representing a report, saving a report, generating a report, sending a report, etc.
2. Open/Closed Principle (OCP):
- Our code is open for extension (e.g., by adding new types of reports) and closed for modification. You can easily introduce new report types without modifying existing code.
3. Liskov Substitution Principle (LSP):
- The PDFReport, HTMLReport, and ExcelReport classes can be used interchangeably as Report objects, which suggests adherence to the Liskov Substitution Principle.
4. Interface Segregation Principle (ISP):
- The interfaces (Generateable and Sendable) are focused and have only the methods relevant to their specific responsibilities. Classes that implement these interfaces are not forced to implement unnecessary methods.
5. Dependency Inversion Principle (DIP):
- Dependencies are injected, and high-level modules (e.g., ReportSender) depend on abstractions (e.g., Sendable) rather than concrete implementations.
Overall, our code appears to follow the SOLID principles well. It’s modular, extensible, and adheres to good object-oriented design practices.
Thanks for reading ;)
Rohit Kumar is a passionate software evangelist. Who loves implementing, breaking and engineering software products. He actively engages on platforms such as LinkedIn, GitHub, & Medium through email.