Search Tech Journey

Find topics, journeys and posts

back to blog
engineeringintermediate 12m2026-06-09

SOLID Part 1 — SRP, OCP, LSP with Python Examples

Session 4 of the 48-session learning series.

Why this session matters

This is Session 04 of 48 in the OOP & Languages track. It builds on the rhythm of one focused topic, paced so you have time to actually absorb it rather than rush.

Agenda

  • What SOLID is and why it's an 'engineer-grade' principle set
  • SRP — Single Responsibility Principle, the 'reason to change' test
  • OCP — Open/Closed Principle, via strategy + extension points
  • LSP — Liskov Substitution, with the rectangle/square counter-example
  • Where SOLID overreaches — pragmatic Python guidance

Pre-read (skim before the session)

Deep dive

1. What SOLID is (and isn't)

SOLID is five OOP design principles formalised by Robert C. Martin in the early 2000s:

  • S — Single Responsibility Principle
  • O — Open/Closed Principle
  • L — Liskov Substitution Principle
  • I — Interface Segregation Principle (Session 11)
  • D — Dependency Inversion Principle (Session 11)

They're heuristics, not laws. Applied well they make code testable and changeable; applied dogmatically they produce abstraction soup with 47 small files for a 200-line script. The skill is knowing which one is paying its way today.

2. SRP — Single Responsibility Principle

A class should have one reason to change.

The classic violation:

class Report:
    def collect_data(self): ...           # changes when data source changes
    def calculate_metrics(self): ...      # changes when business logic changes
    def render_html(self): ...            # changes when UI/template changes
    def email_to_users(self): ...         # changes when delivery mechanism changes

Four different stakeholders can each demand a change. Two of them changing at the same time → merge conflict + a bug surface that has nothing to do with their actual change.

Split:

class ReportCollector:
    def collect(self) -> dict: ...

class ReportCalculator:
    def compute(self, raw: dict) -> dict: ...

class HtmlReportRenderer:
    def render(self, computed: dict) -> str: ...

class EmailDispatcher:
    def send(self, html: str, recipients: list[str]) -> None: ...

Now each class has one reason to change, and you can unit-test each piece without spinning up SMTP.

Smell test: if a method's name says "and" — validate_and_save_user — you probably violated SRP.

3. OCP — Open/Closed Principle

Software entities should be open for extension, closed for modification.

You shouldn't have to modify a tested class to add a new behaviour — you should extend it. The mechanism is usually a strategy or template method.

Bad — adding a new payment method requires editing PaymentProcessor:

class PaymentProcessor:
    def charge(self, method: str, amount: float):
        if method == "card":   self._charge_card(amount)
        elif method == "upi":  self._charge_upi(amount)
        elif method == "paypal": self._charge_paypal(amount)
        # adding a new method = editing this file, re-running tests, redeploying

Good — strategies:

from typing import Protocol

class PaymentStrategy(Protocol):
    def charge(self, amount: float) -> None: ...

class CardPayment:
    def charge(self, amount: float) -> None: ...

class UPIPayment:
    def charge(self, amount: float) -> None: ...

class PaymentProcessor:
    def __init__(self, strategy: PaymentStrategy):
        self.strategy = strategy
    def charge(self, amount: float) -> None:
        self.strategy.charge(amount)

Adding PayPalPayment is now a new class, no edits to PaymentProcessor.

Python-specific note: Protocol (PEP 544) gives you structural subtyping, no need for inheritance.

4. LSP — Liskov Substitution Principle

Subtypes must be substitutable for their base types without changing program correctness.

The famous counter-example: rectangle and square.

class Rectangle:
    def set_width(self, w): self.w = w
    def set_height(self, h): self.h = h
    def area(self): return self.w * self.h

class Square(Rectangle):
    def set_width(self, w):  self.w = w; self.h = w  # mutates h too!
    def set_height(self, h): self.w = h; self.h = h

A function that correctly assumes set_width doesn't affect height will break when passed a Square. Subtype broke a contract of the base.

Better — model what they share, not the inheritance you expect:

class Shape(Protocol):
    def area(self) -> float: ...

@dataclass(frozen=True)
class Rectangle:
    w: float; h: float
    def area(self): return self.w * self.h

@dataclass(frozen=True)
class Square:
    side: float
    def area(self): return self.side ** 2

Both implement Shape; neither inherits from the other; no surprising state mutation.

LSP failure modes to watch for:

  • Subclass throws an exception the base never threw.
  • Subclass weakens preconditions or strengthens postconditions in a surprising way.
  • Subclass overrides a method to do something semantically different (a Bird.fly() overridden by Penguin to raise NotImplementedError is the textbook fail).

5. Practical Python guidance

  • Prefer composition over inheritance. Python has multiple inheritance, but mixin chains are a debugging nightmare in production. Inject collaborators in __init__.
  • Use Protocol for interfaces. No need for ABC in most cases; duck-typing is structurally enforced at type-check time.
  • @dataclass(frozen=True) for value objects. Immutability eliminates a class of state-mutation bugs and makes LSP failures less likely.
  • Don't pre-abstract. Wait until you have two implementations of the same thing before you extract an interface. "Rule of three" applies.

6. Real example — refactoring a 400-line OrderService

Before:

class OrderService:
    def place_order(self, user_id, items, payment_method):
        # 1. validate user
        # 2. validate items in stock
        # 3. compute totals + tax
        # 4. apply discount codes
        # 5. process payment
        # 6. create order record
        # 7. update inventory
        # 8. send confirmation email
        # 9. publish OrderPlaced event

After (SRP + OCP + DIP — Session 11 covers DIP fully):

class OrderService:
    def __init__(
        self,
        user_repo: UserRepo,
        stock: StockChecker,
        pricing: PricingEngine,
        discounts: DiscountEngine,
        payments: PaymentStrategy,
        orders: OrderRepo,
        events: EventBus,
        notifier: Notifier,
    ): ...

    def place_order(self, user_id, items, payment_method):
        user = self.user_repo.get(user_id)
        self.stock.reserve(items)
        totals = self.pricing.compute(items, user)
        totals = self.discounts.apply(totals, user)
        self.payments.charge(user, totals.total)
        order = self.orders.create(user, items, totals)
        self.events.publish(OrderPlaced(order.id))
        self.notifier.notify(user, order)

Each collaborator is testable in isolation. The order service is now an orchestrator, not a god class.

7. When SOLID overreaches

Three honest cases where applying SOLID makes things worse:

  1. One-off scripts. Don't extract PaymentStrategy from a 30-line cron job.
  2. Internal helpers under 100 lines. YAGNI beats OCP.
  3. Premature abstraction. Two implementations that might diverge → don't abstract yet.

The rule: SOLID earns its keep when a class has two real consumers and a history of changes.

8. What's next (Session 11 — SOLID Part 2)

  • I — Interface Segregation
  • D — Dependency Inversion
  • Design patterns: Strategy (you saw it here), Factory, Observer
  • When patterns help vs hurt

Reading material

Books:

  • Clean Code — Robert C. Martin (ch. 3: Functions, ch. 10: Classes)
  • Clean Architecture — Robert C. Martin (Part III: Design Principles — the SOLID chapters)
  • Agile Software Development, Principles, Patterns, and Practices — Robert C. Martin (the book where SOLID was first laid out)

Papers / essays:

Official docs:

Blog posts:

In-depth research material

Videos

LeetCode — Design Parking System

Post-session checklist

By the end of this session you should be able to:

  • Apply the 'reason to change' test to a real class in your repo.
  • Refactor an if/elif chain over a string type into a strategy with Protocol.
  • Spot a LSP violation in a subclass (or argue none exist).
  • Argue when not to apply SOLID and why YAGNI wins.
  • Solve design-parking-system by modelling levels + slot types cleanly.

Generated from sessions_data.py + content_part*.py. To edit a video / leetcode / title, edit the data file and re-run write_sessions.py.