SOLID Principles in Python: A Realistic Refactor, Step by Step
ProgrammingLesson 4 of 47 min read
What you'll learn
- State each SOLID principle in one sentence and spot the code smell that points to it
- Refactor a class that changes for several reasons into small, focused collaborators
- Use dependency injection to test business logic without email servers or files
- Recognise a Liskov Substitution violation and fix it by splitting an interface
On this page
Monday: sales wants a CSV export of invoices. Tuesday: a nonprofit plan at four rupees a seat. Wednesday: finance wants emails sent through a different provider. In the class below, each of those three requests means editing the same method, and each edit risks breaking the other two. This lesson refactors that class in stages and names the principle behind each move.
The class that changes for three reasons
class InvoiceService:
def create_invoice(self, customer, plan, seats, fmt):
# --- pricing rules ---
if plan == "basic":
unit = 10
elif plan == "team":
unit = 8
elif plan == "enterprise":
unit = 6
else:
raise ValueError(f"unknown plan {plan}")
subtotal = unit * seats
if plan == "enterprise" and seats >= 100:
subtotal = subtotal * 0.90
tax = round(subtotal * 0.18, 2)
total = subtotal + tax
# --- formatting ---
if fmt == "text":
body = (f"Invoice for {customer}\n"
f" {plan} plan x {seats} seats: {subtotal:.2f}\n"
f" tax (18%): {tax:.2f}\n"
f" total: {total:.2f}")
elif fmt == "csv":
body = f"{customer},{plan},{seats},{subtotal:.2f},{tax:.2f},{total:.2f}"
else:
raise ValueError(f"unknown format {fmt}")
# --- delivery ---
print(f"[email to billing@{customer.lower()}.example]")
return body
print(InvoiceService().create_invoice("Acme", "team", 25, "text"))Output
[email to billing@acme.example] Invoice for Acme team plan x 25 seats: 200.00 tax (18%): 36.00 total: 236.00
It works, and for a small script it is fine. The trouble shows up with change. The method contains four different jobs: pricing rules, tax, formatting and delivery. Adding a plan edits the if chain. Adding a format edits another if chain. Testing the totals requires going through formatting and an email call, because everything is welded together. There is no one place to put a test or a change.
SOLID is a set of five design guidelines popularised by Robert C. Martin. They are less commandments than names for the things that go wrong in code like this.
| Letter | Principle in one line | Smell in the before-code | Fix in the after-code |
|---|---|---|---|
| S | A class should have one reason to change | One method prices, formats and emails | Invoice, PricingRule, Renderer and Mailer each own one job |
| O | Add behaviour by adding code, not editing working code | if plan == ... chains | Plans live in a dictionary of rule objects; new plan means a new entry |
| L | A subtype must be usable wherever its parent is expected | (not in this class; see the storage example below) | Split what cannot be honoured into a separate interface |
| I | Clients should not depend on methods they do not use | (see below) | Small Readable and Writable protocols |
| D | Depend on abstractions, not concrete details | The method calls print as a stand-in for a mail server | BillingService receives a Renderer and Mailer from outside |
Refactor: split by reason, then open for extension
Step one is to pull the data out as a plain immutable Invoice. Step two is to turn each branch of the plan if chain into a pricing rule: an object with a price(seats) method. The enterprise discount becomes a VolumeDiscount that wraps another rule, so the rule for "more than 100 seats" no longer lives inside the enterprise branch. Step three splits formatting into renderers and delivery into a mailer. Step four makes the service receive all of them rather than create them.
from dataclasses import dataclass
from typing import Protocol
# ---- Domain data: a plain, immutable record
@dataclass(frozen=True)
class Invoice:
customer: str
plan: str
seats: int
subtotal: float
tax: float
@property
def total(self):
return self.subtotal + self.tax
# ---- Open/Closed: new plans are new objects, not new branches
class PricingRule(Protocol):
def price(self, seats: int) -> float: ...
class PerSeat:
def __init__(self, unit):
self.unit = unit
def price(self, seats):
return self.unit * seats
class VolumeDiscount:
"""Wraps any rule and gives a discount from a seat threshold."""
def __init__(self, inner: PricingRule, threshold: int, discount: float):
self.inner, self.threshold, self.discount = inner, threshold, discount
def price(self, seats):
base = self.inner.price(seats)
return base * (1 - self.discount) if seats >= self.threshold else base
PLANS: dict[str, PricingRule] = {
"basic": PerSeat(10),
"team": PerSeat(8),
"enterprise": VolumeDiscount(PerSeat(6), threshold=100, discount=0.10),
}
# ---- Single Responsibility: each of these changes for exactly one reason
class Renderer(Protocol):
def render(self, invoice: Invoice) -> str: ...
class TextRenderer:
def render(self, inv):
return (f"Invoice for {inv.customer}\n"
f" {inv.plan} plan x {inv.seats} seats: {inv.subtotal:.2f}\n"
f" tax (18%): {inv.tax:.2f}\n"
f" total: {inv.total:.2f}")
class CsvRenderer:
def render(self, inv):
return (f"{inv.customer},{inv.plan},{inv.seats},"
f"{inv.subtotal:.2f},{inv.tax:.2f},{inv.total:.2f}")
class Mailer(Protocol):
def send(self, to: str, body: str) -> None: ...
class ConsoleMailer:
def send(self, to, body):
print(f"[email to {to}]")
# ---- Dependency Inversion: the service receives abstractions, it never builds them
class BillingService:
def __init__(self, plans, renderer: Renderer, mailer: Mailer):
self.plans, self.renderer, self.mailer = plans, renderer, mailer
def bill(self, customer, plan, seats):
try:
rule = self.plans[plan]
except KeyError:
raise ValueError(f"unknown plan {plan}") from None
subtotal = rule.price(seats)
invoice = Invoice(customer, plan, seats, subtotal, round(subtotal * 0.18, 2))
body = self.renderer.render(invoice)
self.mailer.send(f"billing@{customer.lower()}.example", body)
return body
# same behaviour as before, composed from parts
service = BillingService(PLANS, TextRenderer(), ConsoleMailer())
print(service.bill("Acme", "team", 25))
# a test needs no email server: inject a fake
class FakeMailer:
def __init__(self):
self.sent = []
def send(self, to, body):
self.sent.append((to, body))
fake = FakeMailer()
csv_service = BillingService(PLANS, CsvRenderer(), fake)
assert csv_service.bill("Globex", "enterprise", 120) == "Globex,enterprise,120,648.00,116.64,764.64"
assert fake.sent[0][0] == "billing@globex.example"
print("enterprise discount, csv and fake mailer verified")
# new plan, no edit to BillingService, PerSeat or either renderer
PLANS["nonprofit"] = PerSeat(4)
print(BillingService(PLANS, CsvRenderer(), fake).bill("Library", "nonprofit", 30))Output
[email to billing@acme.example] Invoice for Acme team plan x 25 seats: 200.00 tax (18%): 36.00 total: 236.00 enterprise discount, csv and fake mailer verified Library,nonprofit,30,120.00,21.60,141.60
The first output block is identical to the original. That matters: a refactor changes structure, not behaviour. The assert lines are tests. Because BillingService takes its mailer as a constructor argument, the test passes a FakeMailer that records messages in a list, so it verifies the enterprise discount (120 seats at 6 less 10 percent is 648.00) without sending anything. The last two lines show the open/closed payoff: the nonprofit plan arrived as one dictionary entry and BillingService, PerSeat and both renderers were not edited.
Liskov and interface segregation: contracts you can trust
The Liskov Substitution Principle, named after Barbara Liskov, says that if code works with a parent type, it must keep working when handed any subtype. The classic violation is a subclass that cannot do something its parent promised. Below, ReadOnlyArchive is declared to be a Storage, but it refuses to save, so a function written against Storage fails the moment it receives one.
class Storage:
def save(self, name, data):
raise NotImplementedError
def load(self, name):
raise NotImplementedError
class MemoryStorage(Storage):
def __init__(self):
self.items = {}
def save(self, name, data):
self.items[name] = data
def load(self, name):
return self.items[name]
class ReadOnlyArchive(Storage): # "is a" Storage, but cannot honour save()
def __init__(self, items):
self.items = dict(items)
def save(self, name, data):
raise PermissionError("archive is read-only")
def load(self, name):
return self.items[name]
def nightly_backup(store: Storage): # written against the Storage promise
store.save("backup.json", "{}")
return "backup saved"
print(nightly_backup(MemoryStorage()))
try:
print(nightly_backup(ReadOnlyArchive({})))
except PermissionError as err:
print("substitution broke the caller:", err)Output
backup saved substitution broke the caller: archive is read-only
Nothing in the archive class is buggy in isolation; the lie is in the inheritance. The fix is interface segregation: stop making one fat Storage interface and describe the two capabilities separately. Code that only reads asks for Readable, code that writes asks for Writable, and the archive honestly offers only the first.
from typing import Protocol
class Readable(Protocol):
def load(self, name: str) -> str: ...
class Writable(Protocol):
def save(self, name: str, data: str) -> None: ...
class MemoryStorage: # satisfies both Readable and Writable
def __init__(self):
self.items = {}
def save(self, name, data):
self.items[name] = data
def load(self, name):
return self.items[name]
class ReadOnlyArchive: # honestly only Readable
def __init__(self, items):
self.items = dict(items)
def load(self, name):
return self.items[name]
def nightly_backup(store: Writable): # a type checker rejects ReadOnlyArchive here
store.save("backup.json", "{}")
return "backup saved"
def print_report(store: Readable): # needs less, so it asks for less
return "report: " + store.load("2024-03")
print(nightly_backup(MemoryStorage()))
print(print_report(ReadOnlyArchive({"2024-03": "42 invoices"})))Output
backup saved report: 42 invoices
print_report now needs less, so it asks for less, and a type checker such as mypy will flag any attempt to pass a ReadOnlyArchive to nightly_backup. At run time Python itself would still only fail on the call, which is the cost of duck typing noted in the previous lesson.
When SOLID is too much
The after-version is several times longer than the before-version for the same output. For a one-off script that is a bad trade. These principles pay off when code changes often, has several authors, or needs tests, and they cost you indirection. A reasonable rule: leave the first version simple, and refactor when the second change request hits the same method, which is exactly the story in this lesson. Do not create an interface for something with one implementation and no reason to vary.
Common mistakes
- Splitting too early. Four classes for a 30-line function you will never touch again. The symptom is a code reviewer asking "where does the actual work happen?" and needing five files to answer.
- One-method abstractions for everything. An interface for every class, each with a single implementation, adds names but no flexibility. Introduce the abstraction when the second implementation appears.
- "Open for extension" by flag. Replacing the
if plan ==chain with amodeparameter that selects behaviour inside a method just moves the branches. Look for objects with a shared method. - Subclass that raises `NotImplementedError`. This is the usual signature of a Liskov violation. Callers get a surprise exception, as in
lsp_violation.py. - Injecting concrete classes. If the constructor type is
ConsoleMailerrather thanMailer, the test still has to run a console mailer. Depend on the abstraction. - Hidden construction. Writing
self.mailer = ConsoleMailer()inside__init__makes tests send real emails. Pass it in. - Treating SOLID as a checklist. Counting principles instead of asking "what is likely to change here?" produces ceremony without benefit.
How this shows up in real projects and interviews
Most refactors you will do are exactly this shape: a service class that grew a pricing branch, a formatter and a notification call, and now needs a test. Reviewers call it a "god class" or say it "does too much". The practical skill is to name the reasons it changes, pull out one collaborator at a time, and keep the old tests green after each step. Dependency injection frameworks in Java and other ecosystems automate what the BillingService constructor does by hand.
In interviews, SOLID comes up in two ways: recite and explain the five principles, or look at a block of code and say what is wrong with it. The second is much more common, so practise with concrete examples like this one rather than definitions. The same ideas show up again, under their pattern names, in Design Patterns: the pricing rules are the strategy pattern and VolumeDiscount is a decorator.
Try it yourself
Add two features to invoice_after.py without editing BillingService, PerSeat, VolumeDiscount or the existing renderers: (1) a JsonRenderer that outputs the invoice as a JSON string, and (2) a LoyaltyDiscount rule that wraps another rule and takes a flat 5 off for any customer id in a given set. Write assert-based tests using FakeMailer.
Show a hint
LoyaltyDiscount needs to know the customer, but PricingRule.price only receives seats. That tension is the lesson: decide whether to widen the protocol (change every rule) or give the rule its customer set when you construct it, then note which choice respects the open/closed principle better.
Key takeaways
- Group code by reason to change: pricing, formatting and delivery should not share a method.
- Add behaviour by adding new objects, such as a plan entry or a renderer, instead of editing a working
ifchain. - Pass collaborators in through the constructor so tests can substitute fakes for email, files or networks.
- A subtype that has to refuse something its parent promises, usually by raising
NotImplementedError, breaks the Liskov principle; split the interface. - Apply SOLID in response to real change pressure, not in advance, because every abstraction costs indirection.
Test yourself
A trick from this lesson's ride: S: one class, one reason to change. If sales and finance both edit it, split it. Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
What are the SOLID principles in simple words?
S: a class should have one reason to change. O: add behaviour by extending, not editing, working code. L: subtypes must work wherever their parent type is expected. I: do not force clients to depend on methods they do not use. D: depend on abstractions, not concrete implementations.
How do I know when a class violates the single responsibility principle?
Ask what kinds of change request would make you edit it. If pricing changes, format changes and delivery changes all land in the same class or method, it has several responsibilities. Hard-to-write unit tests that require unrelated setup, such as an email server, are another strong signal.
Is SOLID only for Java and C#?
No. The principles describe dependencies and change, not a language. They are often illustrated in Java or C# because of their interfaces, but Python uses duck typing, protocols and plain functions to achieve the same effects, as the refactor in this lesson shows.