Search Tech Journey

Find topics, journeys and posts

back to blog
pythonintermediate 15m2026-07-06

SOLID Principles & Design Patterns — SRP, OCP, LSP, ISP, DIP

A deep-dive on SRP, OCP, LSP, ISP, DIP — part of a 24-topic evergreen learning series.

Why this session matters

Part of a 24-topic learning series on engineering, ML, and LLM systems. Each session is a 90-minute deep-dive on one topic — designed so anyone can pick it up cold. Every two topics are followed by a revision session with recall prompts and hands-on drills.


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

Why this session matters

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
  • D — Dependency Inversion Principle

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 — the next session 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 (the next session — 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

Assignment / Deliverables

Give yourself a clean 2-hour window and complete all of these before moving on:

  1. Read the deep-dive above end-to-end — no skimming. Take notes in your own words.
  2. Solve the LeetCode problem below without help first. Only look at the hint after 15 focused minutes; only look at editorial after 30. Log your time.
  3. Reproduce one code snippet locally. Pick the snippet that felt least obvious and get it running in a scratch file / notebook.
  4. Draw the core diagram from memory. Paper, whiteboard, or tldraw — doesn't matter. If you can't, re-read section 2 and try again.
  5. Write a 3-line takeaway in your prep journal: what surprised you, what you still don't understand, what you'd read next.
  6. Skim one item from the Reading material section. Bookmark the rest for the weekend.
  7. Commit any code + notes to your prep repo with message session-NN: <one-line summary>.

Stretch (optional, +30 min): explain today's topic to a rubber duck / a friend / a voice note. If you can't teach it in 5 minutes, you don't own it yet — flag it and revisit next weekend.

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.


Part 2: SOLID Part 2 — ISP, DIP, and Design Patterns (Strategy, Factory, Observer)

Why this session matters

It builds on the rhythm of one focused topic, paced so you have time to actually absorb it rather than rush.

Agenda

  • ISP — don’t force consumers to depend on interfaces they don’t use
  • DIP — depend on abstractions, not concretions; the inversion shape
  • Strategy / Factory / Observer — the three patterns you’ll actually use
  • Patterns in Python — functions and Protocols beat heavy class hierarchies
  • When patterns help vs hurt — a short anti-pattern guide

Pre-read (skim before the session)

Deep dive

1. ISP — Interface Segregation Principle

No client should be forced to depend on methods it does not use.

The smell: a 'fat' interface where most implementers only care about half the methods, raising NotImplementedError for the rest.

Bad — one giant PrinterDevice interface:

class PrinterDevice(Protocol):
    def print(self, doc): ...
    def scan(self, doc): ...
    def fax(self, doc): ...
    def staple(self, doc): ...

A basic printer doesn't fax or staple. It either fakes the methods or breaks LSP.

Good — small, role-based interfaces:

class Printer(Protocol):
    def print(self, doc): ...

class Scanner(Protocol):
    def scan(self, doc): ...

class Fax(Protocol):
    def fax(self, doc): ...

class Stapler(Protocol):
    def staple(self, doc): ...

class MultiFunctionDevice(Printer, Scanner, Fax): ...    # opts in by composition

Consumers depend only on what they need: def archive(scanner: Scanner) doesn't drag in print/fax.

2. DIP — Dependency Inversion Principle

High-level modules should not depend on low-level modules. Both should depend on abstractions.

The shape: when a high-level policy module needs to talk to a low-level infrastructure module, invert the relationship by introducing an abstraction the high-level module owns and the low-level module implements.

Before:

# high-level
class OrderService:
    def __init__(self):
        self.db = PostgresOrderRepo()   # direct dep on postgres

After:

class OrderRepo(Protocol):
    def save(self, order: Order) -> None: ...
    def get(self, id: str) -> Order: ...

class OrderService:
    def __init__(self, repo: OrderRepo):
        self.repo = repo

# in adapter layer (lives near infra)
class PostgresOrderRepo:
    def save(self, order): ...
    def get(self, id): ...

Now OrderService knows about OrderRepo (its own abstraction) and Postgres knows about OrderRepo (implements it). The arrow of dependency inverted — high-level no longer imports the low-level.

This is the backbone of clean / hexagonal / onion / ports-and-adapters architecture. The pattern is everywhere because it makes substitution (and testing) trivial — swap in an InMemoryOrderRepo in tests, no patches.

3. Strategy pattern (you saw it in S04)

Encapsulate an algorithm in an object; swap implementations at runtime.

class Sort(Protocol):
    def sort(self, items: list) -> list: ...

class Quicksort:
    def sort(self, items): ...

class Mergesort:
    def sort(self, items): ...

class Pipeline:
    def __init__(self, sorter: Sort):
        self.sorter = sorter
    def run(self, items):
        return self.sorter.sort(items)

In Python, Strategy is often just a callable:

class Pipeline:
    def __init__(self, sort_fn):
        self.sort_fn = sort_fn   # any callable list→list
    def run(self, items):
        return self.sort_fn(items)

pipeline = Pipeline(sort_fn=sorted)

Don’t build a class hierarchy when a function works.

4. Factory pattern

Encapsulate construction so consumers ask 'give me an X for this input' instead of if/elif/elif over types.

def payment_for(method: str) -> PaymentStrategy:
    return {
        "card":   CardPayment(),
        "upi":    UPIPayment(),
        "paypal": PayPalPayment(),
    }[method]

In Python it’s usually just a function returning the right object. The full GoF AbstractFactory is overkill 90% of the time.

Use a factory when:

  • Construction is non-trivial (config, secrets, connection pools).
  • You’re hiding which concrete type from the caller.
  • You want a single registration point (register("card", CardPayment)) for plugins.

5. Observer pattern (a.k.a. pub/sub)

Subjects publish events; observers subscribe; subject doesn’t know who’s listening.

from collections import defaultdict

class EventBus:
    def __init__(self):
        self._subs: dict[type, list] = defaultdict(list)
    def subscribe(self, event_type, handler):
        self._subs[event_type].append(handler)
    def publish(self, event):
        for h in self._subs[type(event)]:
            h(event)

bus = EventBus()
bus.subscribe(OrderPlaced, send_confirmation_email)
bus.subscribe(OrderPlaced, update_inventory)
bus.publish(OrderPlaced(order_id="..."))

In-process Observer is a stepping-stone to real pub/sub (Kafka, NATS, Pub/Sub) across services. The shape is the same; the bus is just remote.

6. Patterns in Python — don't over-Java-ify

A lot of GoF patterns dissolve in Python because:

  • First-class functions → Strategy / Command often become functions.
  • Decorators → the Decorator pattern is literally syntax.
  • Protocol → no need for abstract base classes for most interfaces.
  • Module-level state → Singleton is instance = ClassName() at module load.

Reach for a class hierarchy only when:

  • Multiple methods share state (__init__ collaborators).
  • The pattern truly has multiple methods (Strategy with sort + validate + report).
  • Polymorphism is genuinely needed across many call sites.

7. Anti-patterns to call out in code review

  • God classes — OrderService with 27 methods. Split by SRP.
  • Anaemic models — dataclasses with no behaviour + a parallel OrderManager. Either grow the model or accept it's a DTO.
  • Premature factories — a factory for one concrete type.
  • Singletons hidden behind globals — untestable. Make them injectable.
  • isinstance ladders — you wanted polymorphism; refactor with strategies or pattern matching.
  • Layer cake with no behaviour — 5 layers, each forwards to the next. Cut layers.

8. A small worked refactor

Before — a notification function with 4 if-branches and infrastructure baked in:

def notify(user, channel, msg):
    if channel == "email":
        smtp = smtplib.SMTP("mail.local"); smtp.sendmail(...)
    elif channel == "sms":
        twilio.send(user.phone, msg)
    elif channel == "push":
        fcm.send(user.device_token, msg)
    elif channel == "slack":
        slack.post_message(user.slack_id, msg)

After — strategy + DIP:

class Notifier(Protocol):
    def notify(self, user, msg): ...

class EmailNotifier:    ...
class SmsNotifier:      ...
class PushNotifier:     ...
class SlackNotifier:    ...

def get_notifier(channel: str) -> Notifier:
    return REGISTRY[channel]      # factory

# usage in service
class NotificationService:
    def __init__(self, notifier: Notifier):
        self.notifier = notifier
    def notify(self, user, msg):
        self.notifier.notify(user, msg)

Wiring (e.g. in your app composition root) is the only place that knows about all four implementations.

9. When patterns hurt

  • You added a Strategy + Factory + Observer for a CLI tool that runs once a week.
  • You wrapped every dataclass in an interface 'in case we add a second one'.
  • You have 5 layers of OrderHandlerFactoryProvider because the framework demanded it.

Reach for the simplest thing that still passes the real test: can two engineers safely change two parts of the system at the same time?

10. What's next (the next session — Concurrency)

We shift from class-shape questions to runtime-shape questions: threads, asyncio, the GIL, actor models, and where each one is the right answer.

Reading material

Books:

  • Design Patterns: Elements of Reusable Object-Oriented Software — Gamma, Helm, Johnson, Vlissides ("Gang of Four")
  • Head First Design Patterns, 2nd ed. — Freeman, Robson (easier on-ramp than GoF; Strategy / Factory / Observer covered)
  • Clean Architecture — Robert C. Martin (the DIP chapter is gold; "The Dependency Rule")

Papers / essays:

Official docs:

Blog posts:

In-depth research material

Videos

LeetCode — Design Twitter

  • Link: https://leetcode.com/problems/design-twitter/
  • Difficulty: Medium
  • Why this problem: User→tweets list + followee set; getNewsFeed merges via min-heap of top-k by timestamp.
  • Time-box: 30 minutes. Look up the editorial only after.

Assignment / Deliverables

Give yourself a clean 2-hour window and complete all of these before moving on:

  1. Read the deep-dive above end-to-end — no skimming. Take notes in your own words.
  2. Solve the LeetCode problem below without help first. Only look at the hint after 15 focused minutes; only look at editorial after 30. Log your time.
  3. Reproduce one code snippet locally. Pick the snippet that felt least obvious and get it running in a scratch file / notebook.
  4. Draw the core diagram from memory. Paper, whiteboard, or tldraw — doesn't matter. If you can't, re-read section 2 and try again.
  5. Write a 3-line takeaway in your prep journal: what surprised you, what you still don't understand, what you'd read next.
  6. Skim one item from the Reading material section. Bookmark the rest for the weekend.
  7. Commit any code + notes to your prep repo with message session-NN: <one-line summary>.

Stretch (optional, +30 min): explain today's topic to a rubber duck / a friend / a voice note. If you can't teach it in 5 minutes, you don't own it yet — flag it and revisit next weekend.

Post-session checklist

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

  • Spot an ISP violation (a fat interface) and split it into role-based interfaces.
  • Apply DIP to invert a direct DB dependency.
  • Implement Strategy, Factory, Observer in idiomatic Python (functions where possible).
  • Call out 3 anti-patterns and propose refactors.
  • Solve design-twitter using composition + min-heap merge.

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