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.

Intermediate

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.05

A 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 = h
def 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

PrincipleProblem it preventsKey signal
Single ResponsibilityOne change forces risky edits across unrelated concernsClass has more than one reason to change
Open/ClosedAdding a new case requires editing already-working codeif/elif chains that grow with every new type
Liskov SubstitutionSubtype silently breaks behaviour the parent guaranteesSubclass raises NotImplementedError or violates parent assumptions
Interface SegregationImplementers must stub out methods that don't apply to themFat interface with unrelated methods lumped together
Dependency InversionBusiness logic welded to a concrete implementation, unswappable & untestable__init__ that hard-constructs its own dependencies

Further reading

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?