SOLID, from the problems it actually solves
Each of the five letters exists to prevent one specific, recognizable kind of mess. Worked through small before/after examples instead of one-line definitions.
4 min read
Single Responsibility — one reason to change
The problem it prevents: a class that does two unrelated jobs forces every change to one job to risk breaking the other.
# Before — Invoice handles both business logic AND formatting/persistence
class Invoice:
def calculate_total(self):
...
def save_to_database(self):
...
def print_receipt(self):
...If the receipt format changes, you're editing the same class that calculates totals — and a typo in the printing code now sits in the same file, same review, same deploy as billing logic. Split by reason to change:
class Invoice:
def calculate_total(self):
...
class InvoiceRepository:
def save(self, invoice):
...
class ReceiptPrinter:
def print(self, invoice):
...Now a formatting change touches ReceiptPrinter only — nothing that computes money is anywhere near that diff.
Open/Closed — extend without editing
The problem it prevents: every new case requires modifying code that already works and is already tested.
# Before — every new discount type means editing this function again
def apply_discount(order, discount_type):
if discount_type == "percentage":
order.total *= 0.9
elif discount_type == "flat":
order.total -= 10
# adding "loyalty" means changing this function, risking the existing branches# After — new discount types are added, not edited in
class Discount:
def apply(self, order): ...
class PercentageDiscount(Discount):
def apply(self, order):
order.total *= 0.9
class LoyaltyDiscount(Discount):
def apply(self, order):
order.total -= order.total * 0.05A new discount type is a new class. The code that already works — PercentageDiscount — is never touched again, so it can never be broken by an unrelated change.
Liskov Substitution — a subtype must not break the parent's contract
The problem it prevents: code written against the base type breaks silently when handed a subtype, because the subtype secretly behaves differently.
class Rectangle:
def set_width(self, w): self.width = w
def set_height(self, h): self.height = h
def area(self): return self.width * self.height
class Square(Rectangle):
def set_width(self, w):
self.width = self.height = w # forces both — breaks the parent's contract
def set_height(self, h):
self.width = self.height = hdef resize(rect: Rectangle):
rect.set_width(4)
rect.set_height(5)
assert rect.area() == 20 # passes for Rectangle, FAILS for Square (area is 25)Square is a textbook "is-a" relationship in the real-world sense, and still breaks Liskov substitution, because resize() was written assuming width and height vary independently — a valid assumption for Rectangle, silently violated by Square. The fix usually isn't a patch — it's recognizing that Square shouldn't inherit from a mutable Rectangle at all.
Interface Segregation — don't force classes to implement methods they don't need
The problem it prevents: a fat interface forces every implementer to stub out methods that make no sense for them.
# Before — every worker must implement scan(), even ones that can't scan
class Worker(ABC):
def work(self): ...
def eat(self): ...
def scan(self): ...
class RobotWorker(Worker):
def scan(self):
raise NotImplementedError # a fake implementation just to satisfy the interface# After — separate, focused interfaces; implement only what applies
class Workable(ABC):
def work(self): ...
class Scannable(ABC):
def scan(self): ...
class RobotWorker(Workable): # doesn't implement Scannable at all — nothing to fake
def work(self): ...Dependency Inversion — depend on an abstraction, not a concrete implementation
The problem it prevents: high-level business logic gets welded to one specific low-level implementation, making it untestable and hard to swap.
# Before — OrderService is hard-wired to EmailSender specifically
class OrderService:
def __init__(self):
self.notifier = EmailSender() # can't test without actually sending email
def place_order(self, order):
...
self.notifier.send(order.customer_email, "Order confirmed")# After — OrderService depends on an abstraction; the concrete sender is injected
class Notifier(ABC):
def send(self, to, message): ...
class OrderService:
def __init__(self, notifier: Notifier):
self.notifier = notifier # any Notifier works — real, test double, SMS, push
def place_order(self, order):
...
self.notifier.send(order.customer_email, "Order confirmed")In a test, OrderService(FakeNotifier()) never touches a real email provider. In production, OrderService(EmailSender()) does. Nothing about OrderService changed between those two cases — that's the entire point.
The five principles at a glance
| Principle | Problem it prevents | Key signal |
|---|---|---|
| Single Responsibility | One change forces risky edits across unrelated concerns | Class has more than one reason to change |
| Open/Closed | Adding a new case requires editing already-working code | if/elif chains that grow with every new type |
| Liskov Substitution | Subtype silently breaks behaviour the parent guarantees | Subclass raises NotImplementedError or violates parent assumptions |
| Interface Segregation | Implementers must stub out methods that don't apply to them | Fat interface with unrelated methods lumped together |
| Dependency Inversion | Business logic welded to a concrete implementation, unswappable & untestable | __init__ that hard-constructs its own dependencies |
Further reading
- Wikipedia — SOLID, for the origin and full definition of all five principles.
- Wikipedia — Liskov substitution principle, for the formal version of the Rectangle/Square problem above.
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What real problem does Single Responsibility prevent?
2. In the Open/Closed example, what does adding a new discount type require after refactoring to use classes?
3. Why does Square inheriting from Rectangle violate Liskov Substitution, even though a square genuinely 'is a' rectangle in real life?
4. What does Dependency Inversion change about OrderService's relationship with its notifier?