86.1 SOLID Principles Applied to Python
Right, let’s talk SOLID. You’ve probably seen these principles presented as a set of rigid, stone-tablet commandments handed down from on high. I’m here to tell you that’s nonsense. They’re more like guidelines from a very smart, very experienced architect friend. In Python’s wonderfully flexible and sometimes chaotic world, they’re less about strict rules and more about steering you toward code that doesn’t make you want to tear your hair out six months from now.
The goal isn’t to apply them dogmatically. It’s to understand the why—the underlying desire for maintainable, understandable, and flexible code—so you can apply the spirit of the principle, even if the Pythonic implementation looks a bit different than it would in Java or C#.
The Single Responsibility Principle
This one is the bedrock, and everyone gets it wrong the first ten times. A class should have one, and only one, reason to change. Notice it doesn’t say “do one thing.” It says “have one reason to change.” This is about people and the chaos they bring.
Think about an EmailSender class. Its job is to send emails. If it also builds the HTML template and logs to a database and manages the SMTP connection pool, what happens when the marketing team wants to change the email template? Or when the ops team changes the logging format? You’re touching the same class for three completely unrelated reasons. That’s a bug factory.
Here’s the wrong way, a class that’s begging for trouble:
# Bad: This class has multiple reasons to change.
class CustomerManager:
def __init__(self, customer_data):
self.data = customer_data
def get_customer(self, customer_id): ...
def update_customer(self, customer_id, new_data): ...
# Reason to change #1: Database schema changes
def save_to_database(self, customer): ...
# Reason to change #2: The format of the report changes
def generate_monthly_report(self): ...
# Reason to change #3: The email service API changes
def email_report_to_customer(self, customer_email): ...
And here’s the right way. We split the concerns. Now, a change to the reporting logic doesn’t threaten the database code.
# Good: Separate the concerns. Each class has one job.
class Customer:
# Handles core customer data and persistence
def __init__(self, data):
self.data = data
def save(self): ...
def update(self, new_data): ...
class ReportGenerator:
# Handles the specific logic of building reports
def generate_for_customer(self, customer): ...
class EmailService:
# Handles the nitty-gritty of sending emails
def send_email(self, to_address, content): ...
# The "orchestration" now happens elsewhere, like a function or service layer.
def monthly_report_pipeline(customer_id):
customer = database.get_customer(customer_id)
report = ReportGenerator().generate_for_customer(customer)
EmailService().send_email(customer.email, report)
The monthly_report_pipeline function coordinates the work, but each class is blissfully unaware of the others’ jobs. If the email provider changes, you change EmailService. If the report format changes, you change ReportGenerator. Beautiful.
Open/Closed Principle
This sounds more intimidating than it is. “Software entities should be open for extension, but closed for modification.” The goal is to avoid cracking open a tested, working class and mucking with its internals every time you need new behavior. You should be able to extend its behavior, not alter it.
Inheritance is the classic, often clunky, way to do this. But in Python, we have first-class functions and duck typing, which often leads to more elegant solutions. The key is to rely on abstractions (like protocols or abstract base classes) rather than concrete implementations.
Here’s a common violation. Imagine a payment processor that has to change every time we add a new payment method.
# Bad: The class must be modified for each new type.
class PaymentProcessor:
def process_payment(self, payment_type, amount):
if payment_type == "credit_card":
self._charge_credit_card(amount)
elif payment_type == "paypal":
self._handle_paypal(amount)
elif payment_type == "crypto":
self._mine_bitcoin(amount) # Clearly a questionable design choice
# ... and we add a new 'elif' for every new type. Yuck.
Let’s fix it. We define a simple abstraction—a protocol. Any class with a pay method that takes an amount will work.
# Good: Open for extension (new payment methods), closed for modification.
from typing import Protocol
class PaymentMethod(Protocol):
def pay(self, amount: float) -> None: ...
class CreditCardPayment:
def pay(self, amount):
print(f"Charging ${amount} to credit card")
class PayPalPayment:
def pay(self, amount):
print(f"Processing ${amount} via PayPal")
class CryptoPayment:
def pay(self, amount):
print(f"Transferring {amount} BTC... eventually.")
# The processor now depends on the abstraction. It never needs to change.
class PaymentProcessor:
def process_payment(self, payment_method: PaymentMethod, amount: float):
payment_method.pay(amount)
# Usage:
processor = PaymentProcessor()
processor.process_payment(CreditCardPayment(), 100.0)
processor.process_payment(CryptoPayment(), 0.005) # Good luck with that.
We can now add a BananaPayment class a year from now, and as long as it has a pay method, the PaymentProcessor couldn’t care less. It’s closed for modification but open for any extension we can dream up.
Liskov Substitution Principle
This is the fancy one, named after Barbara Liskov. It essentially says that if you have a class Duck that is a subclass of Bird, you should be able to use any Duck anywhere you expect a Bird without the program blowing up or doing something absurd. The child class shouldn’t break the expectations set by the parent.
Python’s duck typing (“if it quacks like a duck…”) makes this both easier and more dangerous. You don’t have to inherit to break LSP; you just have to implement the interface incorrectly.
The classic example is a rectangle and a square. In math, a square is-a rectangle. In code, this is a terrible idea.
# Bad: This violates LSP.
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Square(Rectangle):
def __init__(self, side):
# Here's the problem. A Square has a different constructor...
super().__init__(side, side)
# ...and if you try to set the width, you break the invariant.
@property
def width(self):
return self._width
@width.setter
def width(self, value):
self._width = value
self._height = value # This is the violation. Setting width also changes height.
# This function works perfectly with a Rectangle...
def test_rectangle_area(rect: Rectangle):
rect.width = 5
rect.height = 4
assert rect.area() == 20 # This will fail if you pass a Square!
# ...but fails spectacularly with a Square.
sq = Square(5)
test_rectangle_area(sq) # AssertionError: 20 != 25
The Square class violates the contract of the Rectangle class. Any code that relies on the independent mutability of width and height will break. The solution? Don’t make Square a subclass of Rectangle. They might share an interface for area, but they are different entities with different rules. Use a common protocol instead.
Interface Segregation Principle
This principle tells us that clients shouldn’t be forced to depend on interfaces they don’t use. In Python, which doesn’t have formal interfaces, this translates to: “Don’t create massive, bloated abstract base classes (ABCs) that force implementers to define a bunch of methods they don’t need.”
Think of a multifunction printer that can print, scan, and fax. What if you have a simple desktop printer that only prints?
# Bad: A monolithic interface.
from abc import ABC, abstractmethod
class MultiFunctionPrinter(ABC):
@abstractmethod
def print(self, document): ...
@abstractmethod
def scan(self, document): ... # Our simple printer can't do this!
@abstractmethod
def fax(self, document): ... # Or this!
# This forces us to implement methods we can't support.
class SimplePrinter(MultiFunctionPrinter):
def print(self, document):
print("Printing...")
def scan(self, document):
raise NotImplementedError("Scanning not supported") # Yuck.
def fax(self, document):
raise NotImplementedError("Faxing not supported") # Double yuck.
The better approach is to break the large interface into smaller, specific ones.
# Good: Segregate the interfaces.
class Printer(ABC):
@abstractmethod
def print(self, document): ...
class Scanner(ABC):
@abstractmethod
def scan(self, document): ...
class Fax(ABC):
@abstractmethod
def fax(self, document): ...
# Now our simple printer only implements what it needs.
class SimplePrinter(Printer):
def print(self, document):
print("Printing...")
# And a fancy printer can compose multiple abilities.
class FancyOfficePrinter(Printer, Scanner, Fax):
def print(self, document): ...
def scan(self, document): ...
def fax(self, document): ...
Now, a function that only needs to print can depend on the Printer protocol alone. It doesn’t care if the object can also scan or fax. This reduces coupling and makes your code much more flexible.
Dependency Inversion Principle
This one has a confusing name. It means you should depend on abstractions, not on concrete implementations. High-level modules (like your business logic) shouldn’t depend on low-level modules (like your database code). Both should depend on abstractions.
This is how you avoid the nightmare of having your entire application tightly coupled to, say, PostgreSQL. If you want to switch to MongoDB, it shouldn’t require a rewrite.
Here’s the tightly coupled way that will make your life miserable:
# Bad: High-level logic depends on a low-level concrete class.
class PostgreSQLDatabase:
def query(self, sql):
# Specific PostgreSQL connection logic
print(f"Running PostgreSQL query: {sql}")
class BusinessLogic:
def __init__(self):
# This is the problem. It's hardwired to PostgreSQL.
self.database = PostgreSQLDatabase()
def get_data(self):
return self.database.query("SELECT * FROM data")
Let’s invert that dependency. The high-level module defines what it needs (an abstraction), and the low-level module implements it.
# Good: Both depend on an abstraction.
from abc import ABC, abstractmethod
class DatabaseInterface(ABC):
@abstractmethod
def query(self, sql): ...
class PostgreSQLDatabase(DatabaseInterface):
def query(self, sql):
print(f"Running PostgreSQL query: {sql}")
class MongoDBDatabase(DatabaseInterface):
def query(self, query_dict): # Note: even the signature might be different!
print(f"Running MongoDB query: {query_dict}")
# The high-level module now accepts any implementation of the interface.
class BusinessLogic:
def __init__(self, database: DatabaseInterface): # <- Depends on abstraction
self.database = database
def get_data(self):
return self.database.query("SELECT * FROM data")
# Now you can swap implementations easily.
postgres_db = PostgreSQLDatabase()
logic_with_postgres = BusinessLogic(postgres_db)
mongo_db = MongoDBDatabase()
logic_with_mongo = BusinessLogic(mongo_db)
See what happened? The power has been inverted. The BusinessLogic is in charge. It declares what it needs (“something that can run a query”), and it’s the job of the rest of the application to provide it. This makes testing a breeze—you can just pass in a mock object—and allows you to change foundational infrastructure without touching your core logic. This is the single biggest win for long-term maintainability in the SOLID set.