SOLID was coined for statically-typed, class-heavy languages, and a literal port into Python often produces over-engineered code. This lesson covers what each principle actually protects against, and how to apply that intent ā not the letter ā in a language that also has functions, protocols, and duck typing as first-class design tools.
1SRP and OCP: Designing for the Next Change, Not Just Today
The Single Responsibility Principle is often summarized as 'a class should have one reason to change', which is more useful read backwards: ask what would force you to edit this class, and if the answer includes two unrelated things ā 'the report's formatting changes' *and* 'we switch email providers' ā that class is doing two jobs that should be able to change independently. Splitting ReportGenerator and EmailSender apart means a change to email delivery logic can never accidentally introduce a bug in report formatting, and vice versa; each class can be tested, reasoned about, and modified in isolation.
The Open/Closed Principle extends the same instinct to how a system grows over time: code should be extensible without requiring edits to existing, already-tested logic. An if/elif chain dispatching on a discount_type string violates this directly ā every new discount type means editing a function that every existing discount type also runs through, with real risk of breaking one type while adding another. Replacing the chain with a Discount Protocol (or an abstract base class) and one small class per discount type means adding SeasonalDiscount is purely additive: write a new class, register it, and every existing discount type's code path is never touched.
Both principles point at the same underlying value: isolating the blast radius of a change. SRP isolates it by responsibility (this class only breaks for this reason); OCP isolates it by extension point (adding new behavior doesn't risk existing behavior).
# Violates SRP: formatting AND delivery in one class
class ReportGenerator:
def generate(self, data): ...
def send_email(self, report, address): ...
# Follows SRP: each class has one job
class ReportGenerator:
def generate(self, data): ...
class EmailSender:
def send(self, content, address): ...One new class ā zero lines of existing code touched
2LSP: Subclassing Is a Behavioral Contract, Not Just a Type Relationship
The Liskov Substitution Principle states that if Square inherits from Rectangle, then any code written to work correctly with a Rectangle must continue to work correctly when given a Square instead ā substitutability, not just 'is-a' in a geometric or intuitive sense. The classic counterexample is exactly this pair: a Rectangle's implicit contract is that .width and .height vary independently; a Square that forces them to stay equal (changing one when the other is set) breaks that contract for any caller that relied on it, even though 'a square is a rectangle' is true mathematically.
This is why LSP is fundamentally about *behavior*, not type hierarchies in the abstract ā a violation doesn't show up as a type error, it shows up as a subtle correctness bug in code that assumed the parent class's guarantees still held. The practical warning sign is a subclass that overrides a method to do noticeably less, throw where the parent wouldn't, or introduce a side effect the parent's contract never promised (or explicitly promised *wouldn't* happen).
The professional habit LSP encourages is thinking about a base class (or Protocol) as a *contract*, not just a set of method names to implement ā documenting what each method promises (and doesn't), so that anyone writing a subclass knows exactly what substitutability requires, and reviewers have a concrete standard to check new subclasses against.
# Violates OCP: adding a discount type means editing this function
def apply_discount(order, discount_type):
if discount_type == "percent":
...
elif discount_type == "fixed":
...
# every new type = another elif here
# Follows OCP: new discount types are added WITHOUT touching existing code
class Discount(Protocol):
def apply(self, total: float) -> float: ...
class PercentDiscount:
def apply(self, total): return total * 0.9Breaks Rectangle's implicit "independent dimensions" contract
3ISP and DIP: Small Interfaces, and Depending on Abstractions
The Interface Segregation Principle argues against one large, do-everything interface in favor of several small, focused ones: a class implementing Printable (just to_pdf()) shouldn't be forced to also implement unrelated methods from a bloated Document interface it doesn't actually need. In Python, this maps directly onto the Protocol construct from the Type Hints lesson ā small, structural interfaces that a class satisfies simply by having the right methods, with no forced inheritance from a monolithic base class that drags in unrelated obligations.
The Dependency Inversion Principle says high-level code should depend on abstractions (interfaces/Protocols), not on concrete, low-level implementations directly ā def export(report: Printable) -> bytes: depends on the shape 'has a to_pdf() method', not on any specific report class. This is what makes the function usable with a PDFReport, a HTMLReport wrapped in an adapter, or a test double that fakes to_pdf(), all without the function itself ever changing.
Together, ISP and DIP are why professional Python code so often accepts a Protocol-typed parameter instead of a concrete class: it keeps the dependency direction pointing at a small, stable abstraction rather than a large, concrete, frequently-changing implementation ā exactly the property that makes a codebase's pieces replaceable and testable in isolation, which is the practical payoff every SOLID principle is ultimately in service of.
class Rectangle:
def __init__(self, w, h): self.w, self.h = w, h
def area(self): return self.w * self.h
class Square(Rectangle):
def __init__(self, side): super().__init__(side, side)
# If setting .w also silently forces .h to match, callers
# expecting independent w/h (like Rectangle's contract) get surprisedDepends on a small abstraction, not a concrete report class
4Step-by-Step Breakdown
SOLID isn't five arbitrary rules ā it's five answers to 'what tends to make code hard to change six months from now?' Let's see each one through a Python lens.
Single Responsibility: a class should have one reason to change. A ReportGenerator that also handles email sending has two.
Open/Closed: code should be open for extension, closed for modification. A discount system that needs an if/elif edited for every new type violates this.
Checkpoint: What does "closed for modification" mean in the Open/Closed Principle?
- āAdding new behavior should not require editing existing, already-tested code
- āAll methods on the class must be private
Liskov Substitution: a subclass must be usable anywhere its parent is expected, without breaking correctness.
Checkpoint: What does the Square/Rectangle example illustrate about Liskov Substitution?
- āA subclass that changes the parent's expected behavior can silently break code written against the parent
- āThat squares and rectangles should never be related by inheritance in any language
Interface Segregation and Dependency Inversion: depend on small, focused interfaces, and depend on abstractions rather than concrete implementations.
SOLID gives you principles; composition vs. inheritance is the concrete decision you'll apply them through most often.
Apply a Real Open/Closed Discount. Finish checkout_total(): adding a discount type never touches this function.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Apply SOLID for the problem it solves, not as a checklist to satisfy on every class
A three-method utility class doesn't need an interface and a factory to be 'SOLID compliant' ā over-applying these principles to simple, stable code adds indirection with no real payoff. Reach for them when a class's responsibilities or extension points are genuinely unclear or growing.
Use Protocol for ISP/DIP in Python instead of forcing ABC inheritance everywhere
Python's structural typing means you often don't need a formal interface hierarchy at all ā a Protocol describing exactly the methods a function needs achieves the same dependency-inversion benefit with less ceremony than Java/C#-style interfaces.
Frequent Bugs
Subclassing purely for code reuse ("this new class needs 80% of what Rectangle already does") without verifying the subclass actually honors the parent's full behavioral contract, silently violating LSP.
Before subclassing for reuse, check whether every method the parent class promises still behaves correctly (not just compiles) on the subclass. If the contract doesn't hold, prefer composition (the next lesson) over inheritance.
Real-World Examples
Refactoring a Discount if/elif Chain to Satisfy OCP
An e-commerce checkout function's discount logic has grown to a 15-branch if/elif chain, and every new promotion type requires editing that same function, with real risk of introducing a regression in an unrelated discount type.
from typing import Protocol
class Discount(Protocol):
def apply(self, total: float) -> float: ...
class PercentDiscount:
def __init__(self, percent: float):
self.percent = percent
def apply(self, total: float) -> float:
return total * (1 - self.percent / 100)
class FixedDiscount:
def __init__(self, amount: float):
self.amount = amount
def apply(self, total: float) -> float:
return max(0, total - self.amount)
def checkout_total(total: float, discount: Discount) -> float:
return discount.apply(total)