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.

Code blocks with sockets and a new block plugging in without touching the others

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 if branches.
  • L, Liskov substitution. A subclass must work wherever its parent is used, with no surprises. If StudentDiscount threw an error where PercentOff returns 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 Discount classes 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 (smtplib inside 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.

Keep reading