DEV Community

Cover image for SOLID Principles with Examples in Python
Pavel Loginov
Pavel Loginov

Posted on • Originally published at Medium

SOLID Principles with Examples in Python

Let's examine the SOLID principles through clear examples of Python code and simplify complex definitions into human language.

Note: The term β€œclient” appears in the text. Client means a programming entity that uses another programming entity (e.g. one class uses another class inside, so the first class is a client).

SOLID is an acronym for a set of design principles created for developing software using object-oriented languages.

The SOLID principles are designed to encourage the creation of code that is simpler, more dependable, and easier to enhance. Each letter in SOLID stands for one design principle.

When implemented correctly, this makes your code more extensible, logical, maintainable, and easier to read.

To understand SOLID principles, you must have a good understanding of how interfaces are used.

Let's look at each principle one by one:

1. Single Responsibility Principle

The Single Responsibility Principle requires that each class should have a singular, clearly defined purpose.

The same applies to other programming entities. That is, it is necessary to decompose software entities so that each entity is responsible for the one job assigned to it. When a class assumes multiple responsibilities, it falls into an anti-pattern known as the God Object.

When a class takes on multiple responsibilities:

  • it becomes interdependent (modifying the behavior of one class operation leads to a change in another);
  • code readability declines;
  • testing becomes complex;
  • collaborative code development becomes challenging.
# Listing [1.1]
# An example of a class with many responsibilities.

class  User:
    def __init__(self, name):
        self.name = name

    def get_name(self):
        return self.name

    def save(self):
        ...

    def send(self):
        ...

    def log(self):
        ...
Enter fullscreen mode Exit fullscreen mode

We have a User class that handles multiple responsibilities - managing user properties, database operations, data transmission, and logging. If you modify the functionality of one of these tasks within the application, it may necessitate adjustments in others to accommodate the new changes. It's like a domino effect, tip one bone and it will drop everything after it.

In this case, we simply decompose the class, create separate classes that will take on one responsibility.

# Listing [1.2]
# An example of the decomposition of the `User` class.

class User:
    def __init__(self, name):
            self.name = name

    def get_name(self):
        pass


class Storage:
    def save(self, user: User):
        ...


class HttpConnection:
    def send(self, user: User):
        ...


class Logger:
    def log(self, user: User):
        ...
Enter fullscreen mode Exit fullscreen mode

Now our code is better structured. Individual entities are now smaller in size, making them easier to read and easier to work with. Now it is possible to give tasks to several developers to change different components at the same time, and no conflicts should arise.

2. Open-Closed Principle

Software entities (classes, modules, functions, etc.) must be open for extension but closed for modification.

Changing existing code is bad because it has already been tested and works. If we change the code, then we have to do regression testing. Therefore, when adding functionality, you should not change existing entities, but add new ones using composition or inheritance. Even with this approach, you may have to slightly edit the old code in order to prevent bugs or hacky code. But changing the old code should be avoided as much as possible.

Let's consider a scenario where you have an online store, and you offer a 20% discount to your preferred customers using the Discount class. If you decide to double the discount to 40% for VIP clients, you could extend the class as follows:

# Listing [2.1]
# An example of modifying a class when adding new functionality.

class Discount:
  def __init__(self, customer, price):
      self.customer = customer
      self.price = price

  def give_discount(self):
      if self.customer == 'favourite':
          return self.price * 0.2
      if self.customer == 'vip':
          return self.price * 0.4
Enter fullscreen mode Exit fullscreen mode

However, this approach violates the Open-Closed Principle, as the OCP discourages it. For example, if we want to give a new discount to a different type of customer, then this requires adding new logic. To follow the OCP principle, we will add a new class that will extend Discount. And in this new class we implement this logic:

# Listing [2.2]
# An example of adding functionality via inheritance.

class Discount:
    def __init__(self, customer, price):
      self.customer = customer
      self.price = price

    def get_discount(self):
      return self.price * 0.2

class VIPDiscount(Discount):
    def get_discount(self):
      return super().get_discount() * 2
Enter fullscreen mode Exit fullscreen mode

If you decide to give a discount to super VIP users, it will look like this:

# Listing [2.3]
# An example of adding functionality via inheritance (2).

class SuperVIPDiscount(VIPDiscount):
    def get_discount(self):
      return super().get_discount() * 2
Enter fullscreen mode Exit fullscreen mode

Thus, we do not modify the existing code (closed for modification), but add a new one (open for extension).

When you're designing your entity structure, it's essential to identify, at the earliest stages, those system entities that might undergo changes or expansions in the future and create appropriate abstractions for them.

Let's explore another example: we have a Weapon class and a Character class. In this program, the character has a weapon and can perform attacks with it.

# Listing [3.1]
# Example program, not easily extensible.

class Weapon:
    def __init__(self, name, damage):
        self.name = name
        self.damage = damage

    def attack(self):
        print(f"{self.name} strikes: -{self.damage} hp")


class Character:
    def __init__(self, name, weapon: Weapon):
        self.name = name
        self.weapon = weapon

    def change_weapon(self, weapon: Weapon):
        self.weapon = weapon

    def attack(self):
        self.weapon.attack()


sword = Weapon("Needle", 24, 3)
aria = Character("Aria", sword)
aria.attack() # Output: Needle strikes: -24 hp
Enter fullscreen mode Exit fullscreen mode

Now, we've made the decision to introduce a new weapon, the bow. This requires us to modify the Weapon.attack method and incorporate an additional type field to expand the output logic (changing "strikes" to "shoots" for the bow).

# Listing [3.2]
# An example of adding new functionality in violation of OCP.

class Weapon:
    def __init__(self, _type, name, damage):
        self.type = _type
        self.name = name
        self.damage = damage

    def attack(self):
        if self.type == "striking":
            print(f"{self.name} skrikes: -{self.damage} hp")
        elif self.type == "shooting":
            print(f"{self.name} shoots: -{self.damage} hp")


sword = Weapon("striking", "Needle", 24, 3)
aria = Character("Aria", sword)
aria.attack() # Output: Needle strikes: -24 hp

bow = Weapon("shooting", "Thread", 30, 100)
aria.change_weapon(bow)
aria.attack() # Output: Thread shoots: -30 hp
Enter fullscreen mode Exit fullscreen mode

As we discussed above, this approach violates the OCP. When writing the Weapon class, its extension for another gun types was not provided in advance. From the very beginning it was worth creating more abstract code.

# Listing [3.3]
# An example of a program that is amenable to extension.

class Attacker:
    """Interface for attacking classes."""
    def attack(): raise NotImplementedError


class Weapon(Attacker):
    """Defines a general structure for weapons."""
    def __init__(self, name, damage):
        self.name = name
        self.damage = damage


class Sword(Weapon):
    """
    Inherits the structure of the weapon
    and implements the attack interface.
    """
    def attack(self):
        print(f"{self.name} stirkes: -{self.damage} hp")


class Bow(Weapon):
    def attack(self):
        print(f"{self.name} shoots: -{self.damage} hp")


sword = Sword("Needle", 24, 3)
bow = Bow("Thread", 30, 100)

aria = Character("Aria", sword)
aria.attack() # Output: Needle strikes: -24 hp

aria.change_weapon(bow)
aria.attack() # Output: Thread shoots: -30 hp
Enter fullscreen mode Exit fullscreen mode

Code structured in this manner is more amenable to expansion, appears cleaner, and more professional. It should be noted that if you are absolutely sure that you will not have additional functionality in the future, then it is better to stick to the KISS (Keep It Short and Simple) principle and not create additional abstractions.

3. Liskov Substitution Principle

The main idea behind the Liskov Substitution Principle is that for any class, the client should be able to use any subclass of the base class without noticing the difference between them.

And therefore without any change in the execution behavior of the program. This means that the inherited class should complement, not replace, the behavior of the parent, and that the client is completely isolated and unaware of changes in the class hierarchy.

Subtype Requirement:
Let *
Ο†(x)** be a property provable about objects x of type T. Then Ο†(y) should be true for objects y of type S where S is a subtype of T.*

In simpler terms, if you disrupt the logic of the parent class within the child class, you are violate the LSP principle.

# Listing [4]
# An example of a program that violates the LSP principle.

class Develpoer:
    def write_code(self): ...


class Backend(Developer):
    def configure_server(self): ...


class DevOps(Developer):
    """
    Let's imagine a scenario where our DevOps team lacks coding skills.
    """
    def monitor_resources(self): ...

    def write_code(self):
        """
        We alter the implementation, consequently violating the LSP.
        """
        raise UnableToDo("DevOps cannot write code.")
Enter fullscreen mode Exit fullscreen mode

In listing above, the DevOps class violated the logic of his parent, thereby violating the LSP principle. According to this principle, a client who uses Developer should be able to replace it with any child class and not break the program. In the case of a child class DevOps, the program will raise an error.

The following example demonstrates the client's ability to use a class and its children without breaking program logic.

# Listing [5]
# An example of a program that follows the LSP principle.

from dataclasses import dataclass


@dataclass
class Position:
    x: int = 0
    y: int = 0

    def __str__(self):
        return f"({self.x}, {self.y})"


class Character:
    """Base class of characters."""
    def __init__(self, name):
        self.name = name
        self.position = Position()

    def move(self, destination: Position):
        print("{name} moves from {start} to {end}".format(
            name=self.name, start=self.position, end=destination
        ))
        self.position = destination


class Human(Character):
    """Child class that follows parent's logic."""
    def move(self, destination: Position):
        print("{name} goes from {start} to {end}".format(
            name=self.name, start=self.position, end=destination
        ))
        self.position = destination

    def buy(self):
        """Adds aditional logic."""
        print("Buys an item.")


class Dragon(Character):
    """Child class that follows parent's logic."""
    def move(self, destination: Position):
        print("{name} flies from {start} to {end}".format(
            name=self.name, start=self.position, end=destination
        ))
        self.position = destination

    def attack(self):
        """Adds aditional logic."""
        print("Spews fire at the enemy.")


def move(character: Character, destination: Position):
    """
    A client that uses `Character` and its subclasses
    without noticing the difference.
    """
    character.move(destination)


spirit = Character("Spirit")
john = Human("John")
drogon = Dragon("Drogon")

meeting_point = Position(x=300, y=250)

move(spirit, meeting_point)
move(john, meeting_point)
move(drogon, meeting_point)

# Output:
# Spirit moves from (0, 0) to (300, 250)
# John goes from (0, 0) to (300, 250)
# Drogon flies from (0, 0) to (300, 250)
Enter fullscreen mode Exit fullscreen mode

As we can see, the move function can work both with Character and its subclasses without errors.

LSP is the basis of good object-oriented software design because it aligns with one of the fundamental principles of Object-Oriented Programming (OOP): polymorphism. The point is to create correct hierarchies such that classes derived from the base are polymorphic for their parent in relation to the methods of its interfaces. It is also interesting to note how this principle relates to the example of the previous principle. If we try to extend a class with a new incompatible class, then everything will break. Interaction with the client will be broken, and as a result, such an extension will not be possible (or, in order to make this possible, we would have to violate another principle and modify the client code, which should be closed for modification, this is highly undesirable and unacceptable).

Thoughtfully considering new classes in accordance with LSP facilitates the proper expansion of class hierarchies. Furthermore, LSP contributes to the Open-Closed Principle.

4. Interface Segregation Principle

Clients should not depend on interfaces they do not use. You shouldn't force a client to implement an interface that it does not use.

Create thin interfaces: many client-specific interfaces are better than one general-purpose interface. This principle eliminates the disadvantages of implementing large interfaces.

To illustrate this, let's take the following example. Suppose we were tasked with creating a Smartphone. We initially created the Device interface to accommodate it and future devices. Later, we needed to add a Laptop that couldn't make phone calls. At this point, we should recognize that our Device interface contradicts the Interface Segregation Principle, and it should be split. However, if we were unaware of ISP, we might have written Laptop as shown in Listing [6.1]. Then, when the task arose to add a Phone, we would also violate the principle. The resulting code would look like this:

# Listing [6.1]
# An example of a program that violates ISP.

# In Listing 6.*, by ... we mean a missing method implementation.

class Device:
    def call(self): raise NotImplementedError
    def send_file(self): raise NotImplementedError
    def browse_internet(self): raise NotImplementedError


class Smartphone(Device):
    def call(self): ...
    def send_file(self): ...
    def browse_internet(self): ...


class Laptop(Device):
    def call(self):
        raise BadOperation("A laptop cannot make calls.")

    def send_file(self): ...
    def browse_internet(self): ...


class Phone(Device):
    def call(self): ...

    def send_file(self):
        raise BadOperation("A phone cannot send files.")

    def browse_internet(self):
        raise BadOperation("A phone cannot access internet.")
Enter fullscreen mode Exit fullscreen mode

This is a clear illustration of the dependence of the Laptop and Phone clients on the Device interface, which they only partially implement.

A nice trick is that in our business logic, a single class can implement multiple interfaces when necessary. This allows us to provide a unified implementation for all shared methods across interfaces. In Python, this is easily achieved through multiple inheritance:

# Listing [6.2]
# An example of a program that complies with ISP.

class CallDevice:
    def call(self): raise NotImplementedError


class FileTransferDevice:
    def send_file(self): raise NotImplementedError


class InternetDevice:
    def send_file(self): raise NotImplementedError


class Smartphone(CallDevice, FileTransferDevice, InternetDevice):
    def call(self): ...
    def send_file(self): ...
    def browse_internet(self): ...


class Laptop(FileTransferDevice, InternetDevice):
    def send_file(self): ...
    def browse_internet(self): ...


class Phone(CallDevice):
    def call(self): ...
Enter fullscreen mode Exit fullscreen mode

We now see fine-grained interfaces and eliminate methods within software entities that they do not use. This results in more predictable behavior, and the code becomes less tightly coupled.

Segregated interfaces force us to think more about our code from the client's point of view, which will lead us to less dependency and easier testing. This way, not only did we make our code better for the client, but it also made it easier for us to understand, test, and implement the code for ourselves.

5. Dependency Inversion Principle

Dependency should be on abstractions, not on specifics.

Modules at higher levels should not depend on modules at low levels. Both upper and lower level classes must depend on the same abstractions. Abstractions should not depend on details. Details must depend on abstractions.

As development progresses, there comes a point where our application predominantly consists of modules. At this stage, it becomes essential to enhance our code using dependency injection. The functionality of high-level components relies on low-level components. You can utilize inheritance or interfaces to achieve specific behaviors.

Let's look at a bad example first. Suppose we have a Post entity, and we've assigned three programmers to implement various storage solutions for posts. Unfortunately, they didn't agree on naming conventions and created storages with different method names. This is problematic, because the entity that will use the storages to save posts is highly dependent on the specific storage implementation and will have to adapt to each repository each time they change.

# Listing [7.1]
# An example of unorganized code.

class Post:
    title: str
    content: str


class PostLocalStorage:
    def fetch_all(self): ...
    def get_one(self): ...
    def save(self): ...


class PostCacheDict:
    def get_all(self): ...
    def get(self): ...
    def set(self): ...


class PostDBStorage:
    def select_all(self): ...
    def select_one(self): ...
    def insert(self): ...
Enter fullscreen mode Exit fullscreen mode

The first step to structuring your code and getting rid of dependencies is to create a common interface for storages.

# Listing [7.2]
    # An example of a common interface for storage classes.

class Storage:
    def get_all(self): raise NotImplementedError
    def get(self): raise NotImplementedError
    def save(self): raise NotImplementedError


class PostLocalStorage(Storage):
    def get_all(self): ...
    def get(self): ...
    def save(self): ...

# Other storages also inherit from `Storage`.
...
Enter fullscreen mode Exit fullscreen mode

Now all storages use the same method names, which allows the client to use the repository without knowing its type. However, an even better approach is to introduce an abstraction and interact directly with it. This abstraction will receive a storage object and delegate calls to specific methods to the underlying storage.

# Listing [7.3]
# An example of introducing a general abstraction for storage.

class StorageClient(Storage):
    def __init__(self, storage: Storage):
        self.storage = storage

    def get_all(self):
        return self.storage.get_all()

    def get(self, *args):
        return self.storage.get(*args)

    def save(self, *args):
        return self.storage.save(*args)
Enter fullscreen mode Exit fullscreen mode

With this approach:

  • the client always works with the storage abstraction StorageClient;
  • it offers a clear and transparent interface;
  • the client remains independent of the specific storage implementation.

Note: storage client != StorageClient (storage client is the one who saves posts using StorageClient).

If we reflect the final program on the definition, then the client now depends on the StorageClient abstraction rather than on specific implementations like PostLocalStorage, and so on. The top-level module (storage client) is independent of lower-level modules (storage implementations). Both upper (storage client) and lower (storage implementation) classes depend on the same abstraction - StorageClient. StorageClient does not depend on storage implementation details, it simply delegates the execution of common interface methods. The implementation details depend on and are guided by StorageClient.


By incorporating these principles into your software development process, you can enhance the quality of your codebase, making it easier to scale and adapt to evolving requirements. Embracing SOLID principles is not just a best practice; it's a path to crafting software that stands the test of time.

Top comments (2)

Collapse
 
raidteyar profile image
Raid Teyar

i love this, great article!

Collapse
 
welel profile image
Pavel Loginov

Thank you!