SOLID principles and design patterns in Python: with code you can test
SOLID is five habits for code that is easy to change and test. See them in one small Python checkout example, and when patterns are too much.

SOLID is a set of five design habits that make code easier to change and to test. They are guidelines, not laws. Used lightly they help a lot, and used mechanically they produce a maze of tiny classes. The code and output below were run with Python 3 before publishing.
The five letters, in plain words
- S, Single responsibility. A piece of code has one reason to change. Pricing and sending email are different reasons.
- O, Open for extension, closed for modification. Add behaviour by adding new code (a new discount class), not by editing a tested function with more
ifbranches. - L, Liskov substitution. A subclass must work wherever its parent is used, with no surprises. If
StudentDiscountthrew an error wherePercentOffreturns a number, it would break this rule. - I, Interface segregation. Keep interfaces small. Do not force a class to implement methods it never uses.
- D, Dependency inversion. Depend on an abstraction, and pass dependencies in instead of creating them inside. This is what makes testing easy.
One small example
from typing import Protocol
# D: Checkout depends on abstractions (Notifier, Discount), not on concrete classes.
class Notifier(Protocol):
def send(self, to: str, text: str) -> None: ...
class EmailNotifier:
def __init__(self): self.sent = []
def send(self, to, text): self.sent.append(("email", to, text))
# O: new discount rules are new classes; the pricing code does not change.
class Discount(Protocol):
def apply(self, total: float) -> float: ...
class NoDiscount:
def apply(self, total): return total
class PercentOff:
def __init__(self, pct): self.pct = pct
def apply(self, total): return total * (1 - self.pct / 100)
class StudentDiscount(PercentOff):
def __init__(self): super().__init__(15)
# S: pricing only prices; sending a message happens elsewhere.
def price(items, discount: Discount) -> float:
return round(discount.apply(sum(p * q for p, q in items)), 2)
class Checkout:
def __init__(self, notifier: Notifier, discount: Discount):
self.notifier, self.discount = notifier, discount
def pay(self, user, items):
total = price(items, self.discount)
self.notifier.send(user, f"Paid {total}")
return total
Checkout does not know whether it sends email or SMS, or whether the discount is student or seasonal. That is S, O and D together. In Python, a Protocol describes the shape a class must have without needing inheritance.
Running it, with a test
items = [(20.0, 2), (10.0, 1)]
mail = EmailNotifier()
print(Checkout(mail, NoDiscount()).pay("a@example.com", items))
print(Checkout(mail, StudentDiscount()).pay("s@example.com", items))
print(mail.sent)
class FakeNotifier: # a test double, no email system needed
def __init__(self): self.calls = []
def send(self, to, text): self.calls.append((to, text))
fake = FakeNotifier()
assert Checkout(fake, PercentOff(50)).pay("x", items) == 25.0 and fake.calls == [("x", "Paid 25.0")]
print("test with a fake notifier passed, no email system needed")
50.0
42.5
[('email', 'a@example.com', 'Paid 50.0'), ('email', 's@example.com', 'Paid 42.5')]
test with a fake notifier passed, no email system needed
Two customers paid 50.0 and 42.5, which is 15% off for the student. The test used a fake notifier and a 50% discount. It needed no email system and no network, because Checkout received its notifier from outside.
Patterns you will meet
- Strategy. Swap an algorithm at run time. The
Discountclasses above are a strategy. - Factory. One function decides which class to create.
- Adapter. Wrap a library so your code sees the interface you prefer.
- Observer. Objects subscribe to events (callbacks, signals).
- Decorator. Wrap a function to add behaviour (caching, timing, logging).
Common mistakes
- Creating an abstraction for every class "just in case".
- A deep inheritance tree when composition (holding another object) is simpler.
- Hard-coding dependencies (
smtplibinside business logic), which makes tests slow and fragile. - Applying a pattern name before the problem exists.
- One giant class doing everything ("god object").
Main idea: keep each piece focused, add behaviour by adding code, and pass dependencies in so you can test with fakes. Use a pattern only when it removes a real problem.
Programming roadmaps series, Part 3 (writing better Python): 5 of 5. Previous: Error handling and logging in Python: fail clearly and leave a trail.


