Logo
Software

Understanding SOLID Design Principles in Software Development

Understanding SOLID Design Principles in Software Development
6 min read
#Software

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:

  1. 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.

  2. 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.