Understanding SOLID Design Principles in Software Development
In the world of software development, creating robust and maintainable code is essential. One approach that has stood the test of time in achieving this goal is adhering to SOLID principles. SOLID is an acronym for five design principles that promote modular and maintainable software design. In this blog post, we will explore each of these principles and provide examples in Python to illustrate violations and adherence to them.
Single Responsibility Principle (SRP)
The Single Responsibility Principle states that a class should have only one reason to change. In other words, a class should have a single responsibility. If a class has multiple responsibilities, it becomes harder to maintain and modify over time.
Violation
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
def calculate_salary(self):
# Calculate salary based on complex formula
pass
def save_to_database(self):
# Save employee data to the database
pass
In this example, the Employee class violates SRP because it has both the responsibility of calculating salary and saving to the database.
Adherence
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
class EmployeeDatabase:
def save_to_database(self, employee):
# Save employee data to the database
pass
class SalaryCalculator:
def calculate_salary(self, employee):
# Calculate salary based on complex formula
pass
Here, we have separated the responsibilities into three distinct classes: Employee, EmployeeDatabase, and SalaryCalculator, adhering to SRP.
Open-Closed Principle (OCP)
The Open-Closed Principle states that software entities (classes, modules, functions) should be open for extension but closed for modification. In other words, you should be able to add new functionality without changing existing code.
Violation
class Shape:
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
In this case, if we want to add a new shape (e.g., a circle), we would need to modify the Shape class, violating OCP.
Adherence
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 * self.radius
Here, we have adhered to OCP by creating an abstract Shape class and allowing new shapes to be added without modifying existing code.
Liskov Substitution Principle (LSP)
The Liskov Substitution Principle states that objects of a derived class should be able to replace objects of the base class without affecting the correctness of the program.
Violation
class Bird:
def fly(self):
pass
class Ostrich(Bird):
def fly(self):
raise NotImplementedError("Ostrich cannot fly")
ostrich = Ostrich()
ostrich.fly() # Raises an error
In this example, the Ostrich class violates LSP by throwing an error when calling fly, which is not expected behavior for a subclass of Bird.
Adherence
class Bird(ABC):
@abstractmethod
def move(self):
pass
class Sparrow(Bird):
def move(self):
return "Sparrow can fly"
class Ostrich(Bird):
def move(self):
return "Ostrich cannot fly"
By adhering to LSP, both Sparrow and Ostrich classes provide a valid implementation of the move method without causing unexpected errors.
Interface Segregation Principle (ISP)
The Interface Segregation Principle (ISP) states that a client should not be forced to implement interfaces they do not use. In other words, it's better to have many specific interfaces rather than a single general-purpose interface.
Violation
from abc import ABC, abstractmethod
class Worker(ABC):
@abstractmethod
def install_wiring(self):
pass
@abstractmethod
def fix_plumbing(self):
pass
@abstractmethod
def build_furniture(self):
pass
Each specific type of worker needs to implement this interface:
class Electrician(Worker):
def install_wiring(self):
print("Installing electrical wiring.")
def fix_plumbing(self):
raise NotImplementedError("Electricians don't fix plumbing!")
def build_furniture(self):
raise NotImplementedError("Electricians don't build furniture!")
class Carpenter(Worker):
def install_wiring(self):
raise NotImplementedError("Carpenters don't install wiring!")
def fix_plumbing(self):
raise NotImplementedError("Carpenters don't fix plumbing!")
def build_furniture(self):
print("Building furniture.")
class Plumber(Worker):
def install_wiring(self):
raise NotImplementedError("Plumbers don't install wiring!")
def fix_plumbing(self):
print("Fixing plumbing.")
def build_furniture(self):
raise NotImplementedError("Plumbers don't build furniture!")
The violation of the Interface Segregation Principle is evident. Each class is forced to implement methods that it doesn't actually use. This results in NotImplementedError being raised unnecessarily, cluttering the code and making it less intuitive. It also leads to a less maintainable system, as any changes to the Worker interface might force changes across all implementing classes, even when they are irrelevant to those classes.
Adherence
To comply with ISP, we should break down the Worker interface into more specific interfaces that reflect the actual responsibilities of the classes:
from abc import ABC, abstractmethod
class ElectricianTasks(ABC):
@abstractmethod
def install_wiring(self):
pass
class CarpenterTasks(ABC):
@abstractmethod
def build_furniture(self):
pass
class PlumberTasks(ABC):
@abstractmethod
def fix_plumbing(self):
pass
Now, each worker class only implements the interface(s) relevant to their role:
class Electrician(ElectricianTasks):
def install_wiring(self):
print("Installing electrical wiring.")
class Carpenter(CarpenterTasks):
def build_furniture(self):
print("Building furniture.")
class Plumber(PlumberTasks):
def fix_plumbing(self):
print("Fixing plumbing.")
Dependency Inversion Principle (DIP)
The Dependency Inversion Principle states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. Additionally, abstractions should not depend on details, but details should depend on abstractions.
Violation
To illustrate a violation of DIP, let's consider a scenario where we have a NotificationService that sends notifications through different channels like email and SMS. A naive implementation might directly depend on specific classes for email and SMS notifications.
class EmailService:
def send_email(self, message: str):
print(f"Sending email: {message}")
class SMSService:
def send_sms(self, message: str):
print(f"Sending SMS: {message}")
class NotificationService:
def __init__(self):
self.email_service = EmailService()
self.sms_service = SMSService()
def send_notification(self, message: str):
self.email_service.send_email(message)
self.sms_service.send_sms(message)
This design violates the Dependency Inversion Principle in two significant ways:
-
High-Level Module Dependency: The NotificationService (high-level module) is directly dependent on EmailService and SMSService (low-level modules). If we wanted to add a new notification channel or change the implementation details of the existing ones, we'd need to modify the NotificationService, which goes against the open/closed principle as well.
-
Tight Coupling: The direct dependency on specific classes makes the codebase less flexible and harder to maintain. Any change in the low-level modules (e.g., how SMS or email is sent) could force changes in the high-level module.
Adherence
To comply with DIP, we should introduce an abstraction, such as an interface or abstract base class, that the NotificationService depends on. The low-level modules will then implement this abstraction.
from abc import ABC, abstractmethod
class NotificationChannel(ABC):
@abstractmethod
def send(self, message: str):
pass
class EmailService(NotificationChannel):
def send(self, message: str):
print(f"Sending email: {message}")
class SMSService(NotificationChannel):
def send(self, message: str):
print(f"Sending SMS: {message}")
class NotificationService:
def __init__(self, channels: list[NotificationChannel]):
self.channels = channels
def send_notification(self, message: str):
for channel in self.channels:
channel.send(message)
Now, the NotificationService depends on the abstraction NotificationChannel, not on the concrete implementations EmailService or SMSService.