isaak.dev

The SOLID Principles for Solid Developers.

04 Aug 2023 18 mins read
edit

Let me guess, you were browsing jobs on LinkedIn and saw a requirement to understand SOLID principles and decided to google what the heck SOLID is? Either way, you’ve come to the right place.

This article is not meant to be SOLID-propaganda, and I’m not going to convince you that you are obligated to adhere to SOLID principles. My purpose is simple — to tell you what SOLID is and how it works. It’s up to you to apply these principles in your work or not. Anyway, after reading this article, you will be able to defend/hate SOLID with a frothing at the mouth.

Definition

In short, SOLID is a set of five principles of object-oriented design focused on making software more modular, maintainable, and scalable.

The acronym SOLID stands for:

  • S - Single Responsibility Principle (SRP)
  • O - Open/Closed Principle (OCP)
  • L - Liskov Substitution Principle (LSP)
  • I - Interface Segregation Principle (ISP)
  • D - Dependency Inversion Principle (DIP)

Let’s examine each of these principles separately.

Single Responsibility Principle

The Single Responsibility Principle (SRP) states that a class should have only one reason to change, meaning that it should have only one responsibility or task to perform. By following this principle, a class’s functionality is more focused, leading to improved manageability and comprehension.

First, let’s look at code which violates SRP:

class User:
    def __init__(self, name: str, email: str, password: str) -> None:
        self.name = name
        self.email = email
        self.password = password

    def login(self) -> None:
        print(f"Logging in user: {self}")

    def logout(self) -> None:
        print(f"Logging out user: {self}")

    def send_notification(self, message) -> None:
        print(f"Sending notification to {self}: {message}")

    def serialize(self) -> None:
        print(f"Serializing user: {self}")

It’s evident that the User class violates the SRP since it’s accountable for numerous tasks, namely:

  • Storing user information
  • Authenticating users
  • Sending notifications
  • Serializing users

If any one of these responsibilities changes, it may affect the entire User class. For instance, if the method for sending notifications needs to be updated or changed, it could potentially break the code for the entire User class, including the methods for logging in.

By following SRP, our goal is to separate the different responsibilities into their own classes, so that each class is responsible for only one thing. For example, a User class could be responsible for storing user information only, while separate Authentication, NotificationSender, and UserSerializer classes could be responsible for their respective tasks.

Here’s an example of how we can refactor the User class to adhere to the SRP:

class User:
    def __init__(self, name: str, email: str, password: str) -> None:
        self.name = name
        self.email = email
        self.password = password

    def __str__(self) -> str:
        return f"{self.name} <{self.email}>"

    def __repr__(self) -> str:
        return f"User('{self.email}')"


class UserSerializer:
    def __init__(self, user: User) -> None:
        self.user = user

    def serialize(self) -> None:
        print(f"Serializing user: {self.user}")


class Authentication:
    def login(self, user: User) -> None:
        print(f"Logging in user: {user}")

    def logout(self, user) -> None:
        print(f"Logging out user: {user}")


class NotificationSender:
    def send_notification(self, user: User, message: str) -> None:
        print(f"Sending notification to {user}: {message}")


if __name__ == "__main__":
    user = User(
        "Winston Churchill",
        "winston@churchill.uk",
        "we-shall-fight"
    )

    # Authentication
    auth = Authentication()
    auth.login(user)
    auth.logout(user)

    # Notification
    notification_sender = NotificationSender()
    notification_sender.send_notification(
        user, "Beware of the German U-boats!",
    )

    # Serialization
    user_serializer = UserSerializer(user)
    user_serializer.serialize()

As you can see, it’s pretty clear and I have nothing more to add here.

Open/Closed Principle

The Open/Closed Principle (OCP) states that a class should be open for extension but closed for modification. This means that we should be able to add new functionality to a class without changing its existing code.

Let’s examine the code that violates the OCP:

class NotificationService:
    def __init__(self, notification: str) -> None:
        self.notification = notification

    def send_notification(self, message: str) -> None:
        if self.notification == "email":
            print(f"Sending email with message: {message}")
        elif self.notification == "push":
            print(f"Sending SMS with message: {message}")
        elif self.notification == "slack":
            print(f"Sending whatsapp with message: {message}")


if __name__ == "__main__":
    notification_service = NotificationService("slack")
    notification_service.send_notification("Hello World")

This code violates the OCP because the NotificationService class is not closed for modification. If we want to add a new notification method, we have to modify the existing code in the send_notification method.

Let’s modify the code to follow to the OCP:

import abc


class Notification(abc.ABC):

    @abc.abstractmethod
    def send(self, message: str) -> None:
        raise NotImplementedError


class PushNotification(Notification):
    def send(self, message: str) -> None:
        print("Sending push notification...")


class EmailNotification(Notification):
    def send(self, message: str) -> None:
        print("Sending email notification...")


class SlackNotification(Notification):
    def send(self, message: str) -> None:
        print("Sending slack notification...")


class NotificationService:
    def __init__(self, notification: Notification) -> None:
        self.notification = notification

    def send_notification(self, message: str) -> None:
        self.notification.send(message)


if __name__ == "__main__":
    notification_service = NotificationService(SlackNotification())
    notification_service.send_notification("German U-boats spotted!")

In this example, the NotificationService class is closed for modification because we can add new notification methods without changing the existing code. For instance, if we want to add a new SMSNotification class, we can simply create a new class that extends the Notification class and implement the send method. Such a design allows us to add new notification methods without changing the existing code.

Liskov Substitution Principle

The LSP states that a subclass should be substitutable for its superclass without changing the correctness of the program. In other words, if we replace an instance of a superclass with an instance of its subclass, the program should still behave as expected.

Let’s take a look at the following example which violates the LSP:

import abc


class ICharacter(abc.ABC):
    @abc.abstractmethod
    def greet(self) -> None:
        raise NotImplementedError


class Human(ICharacter):
    def greet(self) -> None:
        print("Hello, I am a human")


class Polish(Human):
    def greet(self) -> None:
        print("Dzień dobry, kurwa!")


class French(Human):
    def greet(self) -> None:
        print("Bonjour!")


class German(Human):
    def greet(self) -> None:
        raise RuntimeError("Nien!")

In this example, the German class is a subclass of Human. However, it violates the LSP because it changes the behavior of the German. The Human class can greet, but the German class cannot. This means that code that expects a Human instance may behave unexpectedly or fail when given a German instance.

Let’s refactor code to make it follow LSP:

import abc


class ICharacter(abc.ABC):
    @abc.abstractmethod
    def greet(self) -> None:
        raise NotImplementedError


class Human(ICharacter):
    def greet(self) -> None:
        print("Hello, I am a human")


class Polish(Human):
    def greet(self) -> None:
        print("Dzień dobry, kurwa!")


class French(Human):
    def greet(self) -> None:
        print("Bonjour!")


class German(Human):
    def greet(self) -> None:
        print("Hallo!")

Interface Segregation Principle

The Interface Segregation Principle (ISP) states that a class shouldn’t be forced to depend on methods it does not use. In other words, we should break down large interfaces into smaller ones, more focused on the needs of each class.

Let’s take a look at the following example which violates the ISP:

import abc


class ICharacter(abc.ABC):
    @abc.abstractmethod
    def attack(self) -> None:
        raise NotImplementedError

    @abc.abstractmethod
    def walk(self) -> None:
        raise NotImplementedError

    @abc.abstractmethod
    def pull_out_rpg(self) -> None:
        raise NotImplementedError


class Warrior(ICharacter):
    def attack(self) -> None:
        print("Warrior is attacking")

    def walk(self) -> None:
        print("Warrior is walking")

    def pull_out_rpg(self) -> None:
        print("Pulling out motherfucking RPG-7")


class Civilian(ICharacter):

    def pull_out_rpg(self) -> None:
        # Jesus Christ, what kind of civilian is this? 
        # Where the hell did this bastard get a thermoboric RPG shell?
        print("Pulling out motherfucking RPG-7 with thermobaric shell")

    def attack(self) -> None:
        # Why the hell would a civilian attack?
        print("Civilian is attacking")

    def walk(self) -> None:
        print("Civilian is walking")

In this example, we define an ICharacter interface that has three methods:

  • attack()
  • walk()
  • pull_out_rpg()

We have a violation of the ISP in the implementation of the ICharacter interface, where we define two concrete classes Warrior and Civilian. The problem is that Civilian is forced to implement the attack and pull_out_rpg methods, even though it may not (haha) need to use them.

If we don’t implement these methods in the Civilian class, we’ll get an error:

TypeError: Can't instantiate abstract class Civilian with abstract methods attack, pull_out_rpg

To avoid such problems, it’s highly recommendable to break down large interfaces into smaller ones, more focused on the needs of each class. Let’s refactor the code to make it follow ISP:

import abc


class ICharacter(abc.ABC):

    @abc.abstractmethod
    def walk(self) -> None:
        raise NotImplementedError


class IWarrior(ICharacter):

    @abc.abstractmethod
    def attack(self) -> None:
        raise NotImplementedError

    @abc.abstractmethod
    def pull_out_rpg(self) -> None:
        raise NotImplementedError


class Warrior(IWarrior):
    def attack(self) -> None:
        print("Warrior is attacking")

    def walk(self) -> None:
        print("Warrior is walking")

    def pull_out_rpg(self) -> None:
        print("Pulling out motherfucking RPG-7")


class Civilian(ICharacter):

    def walk(self) -> None:
        print("Civilian is walking")

Now we no longer arm civilians with hand-held anti-tank grenade launchers, and the world is a little safer.

Dependency Inversion Principle

The Dependency Inversion Principle (DIP) states that high-level classes should not depend on low-level classes. Instead, they should both depend on abstractions. Abstractions should not depend on details, but details should depend on abstractions.

Let’s examine DIP violation:

class StripePaymentGateway:
    def pay(self) -> None:
        print("Paying with Stripe")


class KlarnaPaymentGateway:
    def pay(self) -> None:
        print("Paying with Klarna")


class BitPayPaymentGateway:
    def pay(self) -> None:
        print("Paying with BitPay")


class PaymentProcessor:
    def __init__(self) -> None:
        self.stripe = StripePaymentGateway()
        self.klarna = KlarnaPaymentGateway()
        self.bitpay = BitPayPaymentGateway()

    def process_payment(self, payment_gateway: str) -> None:
        if payment_gateway == "stripe":
            self.stripe.pay()
        elif payment_gateway == "klarna":
            self.klarna.pay()
        elif payment_gateway == "bitpay":
            self.bitpay.pay()


if __name__ == "__main__":
    payment_processor = PaymentProcessor()
    payment_processor.process_payment('stripe')
    payment_processor.process_payment('klarna')
    payment_processor.process_payment('bitpay')

In this example, we have a PaymentProcessor class which has a hard dependencies on concrete implementations of payment gateways. This is a violation of the DIP because the PaymentProcessor class is a high-level class that depends on low-level classes. This means that if we want to add a new payment gateway, we’ll have to modify the PaymentProcessor class which potentially can break the code.

Let’s refactor our payment gateway to make it follow DIP:

import abc


class PaymentGateway(abc.ABC):
    @abc.abstractmethod
    def pay(self) -> None:
        raise NotImplementedError


class StripePaymentGateway(PaymentGateway):
    def pay(self) -> None:
        print("Paying with Stripe")


class KlarnaPaymentGateway(PaymentGateway):
    def pay(self) -> None:
        print("Paying with Klarna")


class BitPayPaymentGateway(PaymentGateway):
    def pay(self) -> None:
        print("Paying with BitPay")


class PaymentProcessor:
    def __init__(self, payment_gateway: PaymentGateway) -> None:
        self.payment_gateway = payment_gateway

    def pay(self) -> None:
        self.payment_gateway.pay()


if __name__ == "__main__":
    payment_processor = PaymentProcessor(StripePaymentGateway())
    payment_processor.pay()

    payment_processor = PaymentProcessor(KlarnaPaymentGateway())
    payment_processor.pay()

    payment_processor = PaymentProcessor(BitPayPaymentGateway())
    payment_processor.pay()

In this example, we have a PaymentProcessor class that depends on an abstraction PaymentGateway. This means that we can add as many payment gateways as we want without modifying the PaymentProcessor class itself.

Conclusion

In this article, we have explored the SOLID principles and their implementation in Python. We have discussed how the SRP can help in creating more organized and manageable classes, and how the OCP can make our code more extensible. We have also examined the LSP to achieve more flexible code, the ISP for maintainable code, and even had a bit of fun disarming civilians. Finally, we have explored how the DIP can increase code reusability.

That’s all for today. See you next time!

Further Reading

  • Robert C. Martin - Clean Architecture: A Craftsman’s Guide to Software Structure and Design.