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)
- ArjanCodes — SOLID Principles in Python (video)
- Robert C. Martin — original SOLID essay collection
- Python's data model (dunder methods)
- Refactoring (Fowler) — table of contents
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 byPenguintoraise NotImplementedErroris the textbook fail).
5. Practical Python guidance
- Prefer composition over inheritance. Python has multiple inheritance, but
mixinchains are a debugging nightmare in production. Inject collaborators in__init__. - Use
Protocolfor interfaces. No need forABCin 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:
- One-off scripts. Don't extract
PaymentStrategyfrom a 30-line cron job. - Internal helpers under 100 lines. YAGNI beats OCP.
- 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:
- The Principles of OOD (Uncle Bob, 2005) — the original SOLID writeup.
- Design Principles and Design Patterns (Robert C. Martin, 2000) — pre-SOLID summary essay.
Official docs:
- Python
abc— Abstract Base Classes — needed for OCP/DIP in Python. - Python
typing.Protocol(PEP 544) — structural subtyping, the Pythonic LSP.
Blog posts:
- SOLID Principles in Python — Real Python — best modern Python walkthrough.
- The SOLID Principles — Khalil Stemmler — clearest TS examples; concepts transfer cleanly to Python.
- SOLID Principles: When to use them — Codecademy — short overview with code.
In-depth research material
- python-patterns — github.com/faif/python-patterns — ~41k ★, every Gang-of-Four pattern in idiomatic Python.
- refactoring.guru — Design Patterns — interactive UML + code samples in many languages.
- Refactoring (Martin Fowler, 2nd ed.) — book site — the SOLID complement: how to get to clean designs.
- Stack Overflow — SOLID criticism threads — read the contrary takes (e.g. Dan North on "why every elegant SRP becomes a god-class facade").
- Dan North — Why every element of SOLID is wrong — the famous contrarian deck; sharpens your own view.
- Programming Throwdown #102 — Clean Code — episode-length discussion of how SOLID lands in real codebases.
Videos
- The S.O.L.I.D. Principles of OO & Agile Design — Uncle Bob Martin — TDD TV · 1 h 12 min — Robert C. Martin's own conference talk; canonical source.
- Single Responsibility Principle (SOLID) — Code Walks 006 — Christopher Okhravi · 6 min — bite-size; first in a great per-principle series.
- Liskov Substitution Principle (SOLID) — Christopher Okhravi · 16 min — the trickiest principle, explained with the bird/penguin example everyone needs.
- Depend on Abstractions not Concretions (DIP) — Christopher Okhravi · 12 min — the DIP refresher; how to wire a real app's dependencies.
- SOLID Principles: Do You Really Understand Them? — Alex Hyett · 7 min — modern, concise re-cap with concrete code; great as a closer.
LeetCode — Design Parking System
- Link: https://leetcode.com/problems/design-parking-system/
- Difficulty: Easy
- Why this problem: Classic OOP modelling — counters per slot type, decrement on park.
- 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:
- Read the deep-dive above end-to-end — no skimming. Take notes in your own words.
- 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.
- Reproduce one code snippet locally. Pick the snippet that felt least obvious and get it running in a scratch file / notebook.
- Draw the core diagram from memory. Paper, whiteboard, or tldraw — doesn't matter. If you can't, re-read section 2 and try again.
- Write a 3-line takeaway in your prep journal: what surprised you, what you still don't understand, what you'd read next.
- Skim one item from the Reading material section. Bookmark the rest for the weekend.
- 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-systemby 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)
- ArjanCodes — Design Patterns Tour (video)
- Gang of Four — Design Patterns (book index)
- Cosmic Python — Architecture Patterns with Python
- PEP 544 — Protocols
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/elifover 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.
isinstanceladders — 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
OrderHandlerFactoryProviderbecause 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:
- The Dependency Inversion Principle (Robert C. Martin, 1996) — the original DIP essay.
- The Interface Segregation Principle (Robert C. Martin) — original ISP essay.
Official docs:
- Python
typing.Protocol(PEP 544) — duck typing meets static checking. - Python
abc— Abstract Base Classes - Java
java.util.function— functional interfaces for Strategy
Blog posts:
- Refactoring Guru — Design Patterns catalog — code in Python/Java/TS/C#, UML diagrams, when-to-use.
- Strategy vs Template Method — Martin Fowler — the distinction matters in real codebases.
- Composition over inheritance — Real Python
In-depth research material
- python-patterns — github.com/faif/python-patterns — ~41k ★ idiomatic Python implementations of every GoF pattern.
- Java Design Patterns — github.com/iluwatar/java-design-patterns — ~89k ★, the most-starred patterns repo on GitHub.
- Plugin pattern in modern Python — testing.googleblog.com
- The Pragmatic Engineer — "On Patterns" deep dive — when patterns become anti-patterns.
- Programming Throwdown — Design Patterns episode — extended discussion of pattern overuse.
- Sandi Metz — Nothing is Something (talk + companion blog) — the polymorphism mental model.
Videos
- Strategy Pattern — Design Patterns (ep 1) — Christopher Okhravi · 35 min — the most popular design-pattern video on YouTube (1.6M views). Mandatory.
- Observer Pattern — Design Patterns (ep 2) — Christopher Okhravi · 50 min — pub/sub mechanics with crisp code walkthrough.
- Factory Method Pattern — Design Patterns (ep 4) — Christopher Okhravi · 27 min — the canonical creational pattern explainer.
- Depend on Abstractions not Concretions (DIP) — Christopher Okhravi · 12 min — exactly what this session needs on DIP.
- Interface Segregation Principle (SOLID) — Christopher Okhravi · 5 min — quick ISP refresher; pairs with the longer DIP video.
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:
- Read the deep-dive above end-to-end — no skimming. Take notes in your own words.
- 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.
- Reproduce one code snippet locally. Pick the snippet that felt least obvious and get it running in a scratch file / notebook.
- Draw the core diagram from memory. Paper, whiteboard, or tldraw — doesn't matter. If you can't, re-read section 2 and try again.
- Write a 3-line takeaway in your prep journal: what surprised you, what you still don't understand, what you'd read next.
- Skim one item from the Reading material section. Bookmark the rest for the weekend.
- 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-twitterusing 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.