SOLID Design Principles with examples in Python

SOLID design principles of software engineering (with examples in Python)

BowTiedTechGuy
8 min readApr 19, 2024

--

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 a Triangle class is straightforward and does not require changes to AreaCalculator.
  • 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 and Square both extend Shape.
  • 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.

--

--

BowTiedTechGuy
BowTiedTechGuy

Written by BowTiedTechGuy

I like to learn and now learning to share

No responses yet