SOLID design principles of software engineering (with examples in Python)
This post will cover the SOLID principles of software development with examples where each principle is and is not applied.
I am not a big fan of principles in software engineering. A lot of software engineering depends on context, customer and resources. With small set of resources, a monolithic, simple, SQL backed, single server solution is better even for medium sized products and services. The server can be distributed with three to five replicas of the service running and it is enough in a lot of cases. In 2013, entire GitHub was running with 150 servers. Most of architectural decisions are taken not because they are correct, but because they are correct in the context of available resources, time and organization structure.
But SOLID principles are one set that have stood the test of time. While mostly applicable to object-oriented design, it nevertheless applies to general approach to building anything which has multiple components under development by different teams, changing at different pace and expected to keep changing independently of each other. A lot of the principles are commonly confused or used in incorrect context, so this post will be helpful in architecting large scale solutions.
What are SOLID principles of software development?
The SOLID principle is a set of five design principles aimed at making software designs more understandable, flexible, and maintainable. Here’s a succinct overview of each principle:
1. Single Responsibility Principle (SRP): A class should have one and only one reason to change, meaning it should have only one job.
2. Open/Closed Principle (OCP): Objects or entities should be open for extension but closed for modification. This means you should be able to add new functionality without changing existing code.
3. Liskov Substitution Principle (LSP): Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. Essentially, subclasses need to be substitutable for their base classes.
4. Interface Segregation Principle (ISP): A client should not be forced to depend on interfaces it does not use. This principle advocates for smaller, more specific interfaces rather than larger, more general ones.
5. Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g., interfaces), not on concretions (e.g., specific classes). This means that dependencies on modules can be replaced with dependencies on interface or abstract class, which decreases coupling between the modules.
Each principle helps in reducing coupling and increasing the modularity of the code, making it easier to refactor, understand, and extend. Let's dive into each of them one by one:
Single Responsibility Principle (SRP):
A class should have one and only one reason to change, meaning it should have only one job.
Example: User Management System. Let’s consider an example in the context of a user management system.
Before Applying SRP: Here, a class UserManager
handles multiple responsibilities related to users:
# SRP Principle not followed.
class UserManager:
def add_user(self, user):
# Logic to add user to the database
print(f"User {user} added to the database.")
def send_email(self, user, message):
# Logic to send an email to the user
print(f"Email sent to {user}: {message}")
def generate_report(self, user):
# Logic to generate a user report
print(f"Report generated for {user}.")
In this scenario, UserManager
has multiple responsibilities:
- Managing user records in a database.
- Sending emails to users.
- Generating user reports.
These different tasks are reasons for the class to change, which violates SRP. After Applying SRP: To adhere to SRP, we can refactor the code by splitting these responsibilities into separate classes:
# SRP Principle followed.
class UserDatabase:
def add_user(self, user):
# Logic to add user to the database
print(f"User {user} added to the database.")
class EmailService:
def send_email(self, user, message):
# Logic to send an email to the user
print(f"Email sent to {user}: {message}")
class ReportGenerator:
def generate_report(self, user):
# Logic to generate a user report
print(f"Report generated for {user}.")
Now each class has one responsibility:
UserDatabase
manages user records.EmailService
handles sending emails.ReportGenerator
is responsible for generating reports.
Open/Closed Principle (OCP):
Objects or entities should be open for extension but closed for modification. This means you should be able to add new functionality without changing existing code. This takes the form of versioning in micro-services architecture so that once you release a version of a service, you don’t modify that version, but provide a new version with any modifications.
Example: Shape Area Calculator. Before Applying OCP: Imagine you have a system designed to calculate the area of various shapes.
# OCP Principle not followed.
class AreaCalculator:
def calculate_area(self, shapes):
total_area = 0
for shape in shapes:
if isinstance(shape, Rectangle):
total_area += shape.width * shape.height
elif isinstance(shape, Circle):
total_area += 3.14 * shape.radius * shape.radius
return total_area
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
class Circle:
def __init__(self, radius):
self.radius = radius
In this implementation, every time you need to support a new shape, you have to modify the AreaCalculator
by adding a new conditional block. This violates OCP because the class is not closed for modification.
After Applying OCP: You can redesign the system to adhere to OCP by using polymorphism:
# OCP Principle followed.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14 * self.radius ** 2
class AreaCalculator:
def calculate_area(self, shapes):
total_area = 0
for shape in shapes:
total_area += shape.area()
return total_area
In the refactored code, Shape
is an abstract base class with an abstract method area()
. Each shape class (like Rectangle
and Circle
) implements this method. AreaCalculator
now simply calls the area()
method on each shape, oblivious to the shape's type. This design adheres to OCP because:
- Open for extension: You can add new shapes by simply creating new classes that implement the
Shape
interface. For example, adding aTriangle
class is straightforward and does not require changes toAreaCalculator
. - Closed for modification:
AreaCalculator
does not need to be changed to support new types of shapes.
Liskov Substitution Principle (LSP):
Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. Essentially, subclasses need to be substitutable for their base classes.
Example: Shape Area Calculation. Suppose you have a base class Shape
with a method calculateArea()
. You have different shapes like Rectangle
and Square
that inherit from Shape
# LSP Principle followed.
class Shape:
def calculate_area(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def calculate_area(self):
return self.width * self.height
class Square(Shape):
def __init__(self, side):
self.side = side
def calculate_area(self):
return self.side * self.side
In this setup:
Rectangle
andSquare
both extendShape
.- Both implement the
calculate_area
method.
According to LSP, you should be able to replace Shape
with any of its subclasses (Rectangle
or Square
) in your code without causing any errors or changing the program's behavior. Here's an example usage:
def print_area(shape: Shape):
print(f"Area: {shape.calculate_area()}")
# Using Rectangle
rect = Rectangle(10, 20)
print_area(rect) # Output: Area: 200
# Using Square
sq = Square(10)
print_area(sq) # Output: Area: 100
In this case, the function print_area
works correctly regardless of whether it receives a Rectangle
or a Square
object, adhering to the Liskov Substitution Principle.
Interface Segregation Principle (ISP):
A client should not be forced to depend on interfaces it does not use. This principle advocates for smaller, more specific interfaces rather than larger, more general ones.
Example: Document Printer Scenario. Let’s consider an example involving a printer system in an office environment. Before Applying ISP: Suppose you have a single interface Machine
that includes methods for various functionalities:
# ISP Principle not followed.
class Machine:
def print(self, document):
pass
def scan(self, document):
pass
def fax(self, document):
pass
Now, if you have a basic printer that can only print but not scan or fax, it still needs to implement the Machine
interface:
class BasicPrinter(Machine):
def print(self, document):
print("Printing:", document)
def scan(self, document):
raise NotImplementedError("Scan function not supported.")
def fax(self, document):
raise NotImplementedError("Fax function not supported.")
Here, BasicPrinter
is forced to implement methods (scan
and fax
) that it does not use, violating the ISP.
After Applying ISP: To adhere to the ISP, you would segregate the Machine
interface into smaller interfaces:
# ISP Principle followed.
class Printer:
def print(self, document):
pass
class Scanner:
def scan(self, document):
pass
class FaxMachine:
def fax(self, document):
pass
Each class then implements only the interfaces that are relevant:
class BasicPrinter(Printer):
def print(self, document):
print("Printing:", document)
class SuperMachine(Printer, Scanner, FaxMachine):
def print(self, document):
print("Printing:", document)
def scan(self, document):
print("Scanning:", document)
def fax(self, document):
print("Faxing:", document)
Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g., interfaces), not on concretions (e.g., specific classes). This means that dependencies on modules can be replaced with dependencies on interface or abstract class, which decreases coupling between the modules.
Example: Reporting System. Let’s take an example of a reporting system where reports can be generated and saved in different formats like JSON or XML. Before Applying DIP: Here’s a typical implementation without using DIP:
# DIP Principle not followed.
class ReportGenerator:
def generate_report(self, data):
# Logic to generate the report
return "Report data"
class JSONReportSaver:
def save(self, report_data):
print(f"Saving report in JSON format: {report_data}")
class ReportService:
def __init__(self):
self.generator = ReportGenerator()
self.saver = JSONReportSaver()
def create_report(self, data):
report = self.generator.generate_report(data)
self.saver.save(report)
In this scenario, ReportService
directly depends on concrete classes ReportGenerator
and JSONReportSaver
. This design makes it difficult to change or extend the behavior of ReportService
, such as adding support for saving reports in XML.
After Applying DIP: We can redesign the system to use abstractions:
# DIP Principle followed.
from abc import ABC, abstractmethod
class ReportGenerator(ABC):
@abstractmethod
def generate_report(self, data):
pass
class JSONReportGenerator(ReportGenerator):
def generate_report(self, data):
return "JSON Report Data"
class XMLReportGenerator(ReportGenerator):
def generate_report(self, data):
return "XML Report Data"
class ReportSaver(ABC):
@abstractmethod
def save(self, report_data):
pass
class JSONReportSaver(ReportSaver):
def save(self, report_data):
print(f"Saving report in JSON format: {report_data}")
class XMLReportSaver(ReportSaver):
def save(self, report_data):
print(f"Saving report in XML format: {report_data}")
class ReportService:
def __init__(self, generator: ReportGenerator, saver: ReportSaver):
self.generator = generator
self.saver = saver
def create_report(self, data):
report = self.generator.generate_report(data)
self.saver.save(report)
SOLID Principles in Micro-Services architecture
- In microservices architecture, the SOLID principles are particularly beneficial and applicable as they support the development of services that are loosely coupled, independently deployable, and organized around business capabilities. Each microservice ideally focuses on a single responsibility (SRP), which simplifies understanding, development, and testing of the service.
- The Open/Closed Principle is crucial in a microservices context where services are expected to evolve rapidly without impacting existing clients. By designing microservices that are open for extension but closed for modification, new features can be added with minimal impact on existing functionality.
- Liskov Substitution Principle ensures that new versions or variants of services can replace older ones without affecting the behavior of the systems that depend on them. This principle supports backward compatibility and service substitution without breaking the functionality.
- Interface Segregation aligns well with defining microservices APIs that are minimal and client-specific, reducing the overhead of having to deal with bulky, unnecessary interfaces. This principle can drive the design of endpoint routing and communication protocols within a microservices landscape.
- Dependency Inversion in microservices helps in creating a system where implementation details of services can change as long as they adhere to the agreed contracts or interfaces, fostering better service interoperability and easier integration.
- Overall, applying SOLID principles in microservices architecture leads to systems that are easier to manage, scale, and evolve, aligning well with the dynamic and distributed nature of microservices.
References: R. C. Martin, Clean Architecture: A Craftsman’s Guide to Software Structure and Design. Prentice Hall, 2017.