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:
- S - Single Responsibility Principle (SRP) / Separation Of Concerns (SOC)
- O - Open/Closed Principle (OCP)
- L - Liskov Substitution Principle (LSP)
- I - Interface Segregation Principle (ISP)
- D - Dependency Inversion Principle (DIP)
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")