Lina Brihoum
software development

Software Architecture Principle SOLID

Software Architecture Principle SOLID
9 min read
software development

Mastering SOLID Principles in Software Design

In software development - maintaining clean, manageable, and scalable code is paramount. The SOLID principles, introduced by Robert C. Martin, offer a robust framework to achieve these goals.

What are SOLID Principles?

SOLID is an acronym that stands for:

  1. Single Responsibility Principle (SRP)
  2. Open/Closed Principle (OCP)
  3. Liskov Substitution Principle (LSP)
  4. Interface Segregation Principle (ISP)
  5. Dependency Inversion Principle (DIP)
Image

These principles are designed to make software designs more understandable, flexible, and maintainable. Let's break down each principle.

S: Single Responsibility Principle (SRP)

What it is

A class should have only one reason to change, meaning it should have only one job or responsibility.

Why it is important

  • Ease of maintenance: It makes the code easier to understand and maintain. When a class has only one responsibility, you know exactly where to look when you need to make changes related to that responsibility.
  • Reduced risk of bugs: It reduces the risk of bugs since changes in one part of the code won't affect other unrelated parts.
  • Improved readability: It enhances reusability because classes with a single responsibility are more likely to be useful in different contexts.

Example

Imagine you are building a simple application that manages books in a library. You might be tempted to create a single Book class that handles everything related to a book, including its data and how it is saved to a database. This violates SRP.

class Book:
    def __init__(self, title, author):
        self.title = title
        self.author = author
    
    def save_to_database(self):
        # Code to save the book to a database
        pass
 
    def print_details(self):
        # Code to print book details
        print(f"{self.title} by {self.author}")

Instead, you should separate these responsibilities into different classes:

class Book:
    def __init__(self, title, author):
        self.title = title
        self.author = author
 
class BookDatabase:
    def save_to_database(self, book):
        # Code to save the book to a database
        pass
 
class BookPrinter:
    def print_details(self, book):
        # Code to print book details
        print(f"{book.title} by {book.author}")

Here, Book is only responsible for holding the book's data, BookDatabase handles saving the book to a database, and BookPrinter handles printing the book details. Each class has a single responsibility, making the code more modular and easier to manage.

O: Open/Closed Principle (OCP)

What it is

Software entities (such as classes, modules, and functions) should be open for extension but closed for modification. This means you should be able to add new functionality without changing existing code.

Why it is important

  • Enhances flexibility: It promotes code reusability and maintainability. When you need to add new features, you can do so without risking breaking existing functionality.
  • Improves maintainability: It makes the code more robust and easier to test since existing code remains unchanged.

Example

Let's say you are building a notification system that currently supports sending notifications via email. Initially, you might have a simple Notification class that handles email notifications:

class Notification:
    def send_email(self, message):
        print(f"Sending email with message: {message}")

Now, if you want to add SMS notifications, you would have to modify the Notification class.

class Notification:
    def send_email(self, message):
        print(f"Sending email with message: {message}")
 
    def send_sms(self, message):
        print(f"Sending SMS with message: {message}")

Instead, you should design the system so that you can extend it without modifying the existing code. One way to achieve this is by using interfaces or abstract classes:

from abc import ABC, abstractmethod
 
class Notifier(ABC):
    @abstractmethod
    def send(self, message):
        pass
 
class EmailNotifier(Notifier):
    def send(self, message):
        print(f"Sending email with message: {message}")
 
class SMSNotifier(Notifier):
    def send(self, message):
        print(f"Sending SMS with message: {message}")
 
class PushNotifier(Notifier):
    def send(self, message):
        print(f"Sending push notification with message: {message}")

Here, Notifier is an abstract base class with a send method that different notifier types (like EmailNotifier, SMSNotifier, and PushNotifier) can implement. To add a new type of notification, you simply create a new class that inherits from Notifier and implements the send method, without changing any existing classes.

L: Liskov Substitution Principle (LSP)

What it is

Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. Essentially, if class B is a subclass of class A, you should be able to replace A with B without altering the desirable properties of the program.

Why it is important

  • Promotes reliable polymorphism: It ensures that a subclass can stand in for its superclass without causing errors or unexpected behavior, making the code more flexible and robust.
  • Enhances flexibility: It promotes reliable polymorphism, allowing objects to be treated as instances of their parent class.

Example

Imagine you have a base class Bird and a subclass Penguin. If the Bird class has a method fly, the Penguin class, which cannot fly, would violate the Liskov Substitution Principle if it inherits from Bird.

class Bird:
    def fly(self):
        print("Flying")
 
class Sparrow(Bird):
    pass
 
class Penguin(Bird):
    def fly(self):
        raise NotImplementedError("Penguins cannot fly")

To adhere to LSP, you can refactor the design so that not all birds are expected to fly. One way to do this is by using composition instead of inheritance:

class Bird:
    pass
 
class FlyableBird(Bird):
    def fly(self):
        print("Flying")
 
class Sparrow(FlyableBird):
    pass
 
class Penguin(Bird):
    pass

In this design, FlyableBird is a subclass of Bird that adds the ability to fly. Sparrow inherits from FlyableBird, so it can fly, while Penguin directly inherits from Bird, avoiding the fly method altogether.

I: Interface Segregation Principle (ISP)

What it is

A class should not be forced to implement interfaces it does not use. Instead of one large interface, many smaller, more specific interfaces are preferred.

Why it is important

  • Reduces complexity: It reduces the complexity of the code by ensuring that classes only implement methods that are relevant to them, making the code easier to understand and maintain.
  • Enhances modularity: It promotes the design of cohesive and modular interfaces, which leads to more flexible and scalable code.

Example

Imagine you are designing a system for different types of printers. You might start with a single interface Printer that includes methods for printing, scanning, and faxing:

class Printer:
    def print(self, document):
        pass
 
    def scan(self, document):
        pass
 
    def fax(self, document):
        pass
 
class SimplePrinter(Printer):
    def print(self, document):
        print(f"Printing: {document}")
 
    def scan(self, document):
        raise NotImplementedError("SimplePrinter cannot scan")
 
    def fax(self, document):
        raise NotImplementedError("SimplePrinter cannot fax")

In this example, SimplePrinter is forced to implement scan and fax methods even though it does not support these functionalities, which violates ISP.

class Printer:
    def print(self, document):
        pass
 
class Scanner:
    def scan(self, document):
        pass
 
class Fax:
    def fax(self, document):
        pass
 
class SimplePrinter(Printer):
    def print(self, document):
        print(f"Printing: {document}")
 
class MultiFunctionPrinter(Printer, Scanner, Fax):
    def print(self, document):
        print(f"Printing: {document}")
 
    def scan(self, document):
        print(f"Scanning: {document}")
 
    def fax(self, document):
        print(f"Faxing: {document}")

In this design, SimplePrinter only implements the Printer interface, while MultiFunctionPrinter implements all three interfaces: Printer, Scanner, and Fax. This way, each class only implements the methods it needs, adhering to the Interface Segregation Principle.

D:Dependency Inversion Principle (DIP)

What it is

High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g., interfaces). Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.

Why it is important

  • Enhances modularity: It makes the system more modular and flexible. High-level components are less likely to be affected by changes in low-level components.
  • Improves testability: It enhances testability because dependencies can be easily mocked or replaced with stubs during testing.
  • Enhances modularity: It promotes loose coupling, which makes the code easier to maintain and extend.

Example

Imagine you are designing a system where a Database class is used by a UserService class to retrieve user data. Initially, you might have a direct dependency:

class Database:
    def get_user(self, user_id):
        # Code to retrieve user from database
        pass
 
class UserService:
    def __init__(self):
        self.database = Database()
    
    def get_user(self, user_id):
        return self.database.get_user(user_id)

In this example, UserService directly depends on the Database class, which violates DIP. To adhere to DIP, you should depend on abstractions instead of concrete implementations:

from abc import ABC, abstractmethod
 
class UserRepository(ABC):
    @abstractmethod
    def get_user(self, user_id):
        pass
 
class Database(UserRepository):
    def get_user(self, user_id):
        # Code to retrieve user from database
        pass
 
class UserService:
    def __init__(self, user_repository: UserRepository):
        self.user_repository = user_repository
    
    def get_user(self, user_id):
        return self.user_repository.get_user(user_id)

In this design, UserService depends on the UserRepository abstraction rather than the Database concrete implementation. This way, you can easily substitute the Database class with another implementation of UserRepository without changing the UserService class. For example, you can now create a mock repository for testing:

class MockRepository(UserRepository):
    def get_user(self, user_id):
        return {"user_id": user_id, "name": "Mock User"}
 
user_service = UserService(MockRepository())
print(user_service.get_user(1))  # Output: {'user_id': 1, 'name': 'Mock User'}

By adhering to DIP, the system becomes more modular, testable, and maintainable.

Conclusion

  • All SOLID principles aim to create more maintainable, understandable, and flexible code.

  • They promote separation of concerns, modularity, and loose coupling.

  • SRP focuses on the responsibility of a single class.

  • OCP emphasizes extending functionality without modifying existing code.

  • LSP ensures subclasses can substitute their base classes without altering the program's behavior.

  • ISP advocates for creating specific interfaces rather than general ones to avoid forcing classes to implement unused methods.

  • DIP promotes dependency on abstractions rather than concrete implementations, enhancing modularity and testability.

By adhering to SOLID principles, developers can create systems that are easier to manage, extend, and scale. These principles provide a solid foundation for high-quality software design, making your codebase robust and adaptable to change.