Skip to main content
PProfileMatch

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

  1. The class that changes for three reasons
  2. Refactor: split by reason, then open for extension
  3. Liskov and interface segregation: contracts you can trust
  4. When SOLID is too much
  5. Common mistakes
  6. How this shows up in real projects and interviews

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

invoice_before.py
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.

LetterPrinciple in one lineSmell in the before-codeFix in the after-code
SA class should have one reason to changeOne method prices, formats and emailsInvoice, PricingRule, Renderer and Mailer each own one job
OAdd behaviour by adding code, not editing working codeif plan == ... chainsPlans live in a dictionary of rule objects; new plan means a new entry
LA 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
IClients should not depend on methods they do not use(see below)Small Readable and Writable protocols
DDepend on abstractions, not concrete detailsThe method calls print as a stand-in for a mail serverBillingService 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.

invoice_after.py
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.

lsp_violation.py
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.

lsp_fixed.py
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 a mode parameter 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 ConsoleMailer rather than Mailer, 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 if chain.
  • 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.

Ride this lesson

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.