SOLID design principles

Design patterns are proven solutions to common software design problems. They provide reusable architectural approaches that improve code structure, maintainability, and scalability. Design patterns are language-independent and categorized into three main types (Gamma classification): creational, structural, and behavioral.

Most of the design patterns, except for the "concrete" implementations, should feature an abstraction that defines an interface for the implementations to adhere to. However, I will not create them for every example because it’s unnecessary for simple use cases or when the purpose of the example is to illustrate a specific behavior or concept rather than flood you with two-page extended code examples.

The SOLID design principles are five principles aimed at improving software design and making it more understandable, flexible, and maintainable. They are frequently mentioned along design patterns but are not a part of that group. The acronym SOLID stands for:

Single Responsibility Principle (SRP) / Separation Of Concerns (SOC)

A class should have only one primary responsibility, and it mustn't have more. It should not be overloaded with "things" it can do. Different classes should handle different tasks so they can be, e.g., changed independently. A God object is a class with too many responsibilities, and it is considered an antipattern.

Issue


class Book:
    def __init__(self, title, author):
        self.title = title
        self.author = author

    def print_details(self):
        print(f"Title: {self.title}, Author: {self.author}")

    def save_to_file(self, filename):
        with open(filename, "w") as file:
            file.write(f"Title: {self.title}, Author: {self.author}")

book = Book("1984", "George Orwell")
book.print_details()
book.save_to_file("book.txt")
                                    

The Book class has multiple responsibilities: managing book data, printing details, and saving data to a file. For example, the method that handles saving to a file should be placed in another class so it can be modified once for all objects using it when switching to a database. Each concern should be separated into a different class.

Solution


class Book:
    def __init__(self, title, author):
        self.title = title
        self.author = author

class BookPrinter:
    @staticmethod
    def print_details(book):
        print(f"Title: {book.title}, Author: {book.author}")

class BookSaver:
    @staticmethod
    def save_to_file(book, filename):
        with open(filename, "w") as file:
            file.write(f"Title: {book.title}, Author: {book.author}")

book = Book("1984", "George Orwell")
BookPrinter.print_details(book)
BookSaver.save_to_file(book, "book.txt")
                                    

Open/Closed Principle (OCP)

Software entities (classes, modules, functions) should be open for extension but closed for modification meaning we should be able to add a new functionality without changing the existing code. Functionalities should be added through extension, not modification.

Issue


class DiscountCalculator:
    def calculate(self, customer_type, price):
        if customer_type == "regular":
            return price * 0.9 # 10% discount
        elif customer_type == "premium":
            return price * 0.8 # 20% discount
        else:
            return price

calculator = DiscountCalculator()
print(calculator.calculate("regular", 100))
print(calculator.calculate("premium", 100))
print(calculator.calculate("new", 100))
                                    

Adding a new discount would require modifying the existing class, and modifications can introduce bugs and require retesting.

Solution


from abc import ABC, abstractmethod

class DiscountStrategy(ABC):
    @abstractmethod
    def apply_discount(self, price):
        pass

class RegularCustomerDiscount(DiscountStrategy):
    def apply_discount(self, price):
        return price * 0.9 # 10% discount

class PremiumCustomerDiscount(DiscountStrategy):
    def apply_discount(self, price):
        return price * 0.8 # 20% discount

class NoDiscount(DiscountStrategy):
    def apply_discount(self, price):
        return price # no discount

class DiscountCalculator:
    def __init__(self, discount_strategy: DiscountStrategy):
        self.discount_strategy = discount_strategy

    def calculate(self, price):
        return self.discount_strategy.apply_discount(price)

regular_discount = DiscountCalculator(RegularCustomerDiscount())
print(regular_discount.calculate(100))

premium_discount = DiscountCalculator(PremiumCustomerDiscount())
print(premium_discount.calculate(100))

no_discount = DiscountCalculator(NoDiscount())
print(no_discount.calculate(100))
                                    

Liskov Substitution Principle (LSP)

Objects of a superclass should be replaceable with objects of its subclass without affecting the correctness of the program. We should always be able to substitute a base type for a subtype.

Issue


class Rectangle:
    def __init__(self, width, height):
        self._height = height
        self._width = width

    @property
    def area(self):
        return self._width * self._height

    @property
    def width(self):
        return self._width

    @width.setter
    def width(self, value):
        self._width = value

    @property
    def height(self):
        return self._height

    @height.setter
    def height(self, value):
        self._height = value

class Square(Rectangle):
    def __init__(self, size):
        super().__init__(size, size)

    @Rectangle.width.setter
    def width(self, value):
        self._width = self._height = value

    @Rectangle.height.setter
    def height(self, value):
        self._height = self._width = value

def calculate(obj):
    w = obj.width
    obj.height = 10
    print(f"Expected: {w*10}, got: {obj.area}")

rectangle = Rectangle(2, 3)
calculate(rectangle)

square = Square(5)
calculate(square)
                                    

The setters overriden in the Square class break the LSP because they modify the behavior of the Rectangle class in unexpected ways. Specifically, while the Rectangle class allows independent manipulation of width and height, the Square class forces both the width and height to be the same when either is set. This means that substituting a Square for a Rectangle in the code leads to inconsistent and unpredictable behavior. The calculate() function works only for Rectangle (a superclass) and not for Square (a subclass) when substituted.

Solution


class Rectangle:
    def __init__(self, width, height, is_square = False):
        self._height = height
        self._width = width
        self._is_square = is_square

    @property
    def area(self):
        return self._width * self._height

    @property
    def width(self):
        return self._width

    @width.setter
    def width(self, value):
        if self._is_square:
            self._height = value # if it's a square, adjust height as well
        self._width = value

    @property
    def height(self):
        return self._height

    @height.setter
    def height(self, value):
        if self._is_square:
            self._width = value # if it's a square, adjust width as well
        self._height = value

def calculate(obj):
    obj.height = 10
    print(f"Area: {obj.area}") # the output is the same as in the previous example, but the code works predictably

rectangle = Rectangle(2, 3)
calculate(rectangle)

square = Rectangle(5, 5, True)
calculate(square)
                                    

Interface Segregation Principle (ISP)

Clients should not be forced to implement interface methods they do not need, meaning we should use smaller, more specific interfaces over larger, more general ones. In compliance with the YAGNI rule (You Ain't Going To Need It), why would we force users to implement something they aren't going to need?

Issue


from abc import ABC, abstractmethod

class Printer(ABC):
    @abstractmethod
    def print_document(self, document):
        pass

    @abstractmethod
    def scan_document(self, document):
        pass

    @abstractmethod
    def fax_document(self, document):
        pass

class BasicPrinter(Printer):
    def print_document(self, document):
        print(f"Printing document: {document}")

    def scan_document(self, document):
        raise NotImplementedError("BasicPrinter does not support scanning.")

    def fax_document(self, document):
        raise NotImplementedError("BasicPrinter does not support faxing.")

basic_printer = BasicPrinter()
basic_printer.print_document("report.pdf")
basic_printer.scan_document("report.pdf")
                                    

Why would we even suggest using scan and fax for the basic printer if it can't perform these actions?

Solution


from abc import ABC, abstractmethod

class Printable(ABC):
    @abstractmethod
    def print_document(self, document):
        pass

class Scannable(ABC):
    @abstractmethod
    def scan_document(self, document):
        pass

class Faxable(ABC):
    @abstractmethod
    def fax_document(self, document):
        pass

class BasicPrinter(Printable):
    def print_document(self, document):
        print(f"Printing document: {document}")

class AdvancedPrinter(Printable, Scannable, Faxable):
    def print_document(self, document):
        print(f"Printing document: {document}")

    def scan_document(self, document):
        print(f"Scanning document: {document}")

    def fax_document(self, document):
        print(f"Faxing document: {document}")

basic_printer = BasicPrinter()
basic_printer.print_document("report.pdf")

advanced_printer = AdvancedPrinter()
advanced_printer.print_document("report.pdf")
advanced_printer.scan_document("report.pdf")
advanced_printer.fax_document("report.pdf")
                                    

Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules (e.g., dealing with storage is a low-level concern), but on abstractions (abstract classes). We want to depend on interfaces rather than concrete implementations because that way we can swap one for the other.

Issue


class MySQLDatabase:
    def connect(self):
        print("Connecting to MySQL database...")

    def save_data(self, data):
        print(f"Saving {data} to MySQL database.")

class DataHandler:
    def __init__(self):
        self.database = MySQLDatabase() # a direct dependency on a low-level module

    def save(self, data):
        self.database.connect()
        self.database.save_data(data)

data_handler = DataHandler()
data_handler.save("user data")
                                    

DataHandler should not depend directly on MySQLDatabase, a low-level module. This tight coupling means that if the database changes (e.g., to PostgreSQLDatabase), DataHandler must also be modified. This violates both the DIP and the OCP.

Solution


from abc import ABC, abstractmethod

class Database(ABC): # an abstraction for a database
    @abstractmethod
    def connect(self):
        pass

    @abstractmethod
    def save_data(self, data):
        pass

class MySQLDatabase(Database): # a low-level module: MySQL implementation
    def connect(self):
        print("Connecting to MySQL database...")

    def save_data(self, data):
        print(f"Saving {data} to MySQL database.")

class PostgreSQLDatabase(Database): # a low-level module: PostgreSQL implementation
    def connect(self):
        print("Connecting to PostgreSQL database...")

    def save_data(self, data):
        print(f"Saving {data} to PostgreSQL database.")

class DataHandler: # a high-level module depending on the abstraction
    def __init__(self, database: Database):
        self.database = database

    def save(self, data):
        self.database.connect()
        self.database.save_data(data)

mysql_db = MySQLDatabase()
postgres_db = PostgreSQLDatabase()

data_handler_mysql = DataHandler(mysql_db)
data_handler_postgres = DataHandler(postgres_db)

data_handler_mysql.save("user data")
data_handler_postgres.save("user data")