Design Patterns Lesson 2 of 6 ⏱️ 6 min read

SOLID Principles Explained with Examples

Single responsibility, open/closed, Liskov substitution, interface segregation and dependency inversion — with before/after code.

Updated Oct 30, 2026

What is SOLID?

SOLID is an acronym for five object-oriented design principles popularised by Robert C. Martin ("Uncle Bob"). They help you write code that is easy to change, test and understand:

Letter Principle One-line summary
S Single Responsibility A class should have one reason to change
O Open/Closed Open for extension, closed for modification
L Liskov Substitution Subtypes must be usable wherever the base type is expected
I Interface Segregation Don't force clients to depend on methods they don't use
D Dependency Inversion Depend on abstractions, not concrete implementations

S — Single Responsibility Principle

A class should have only one reason to change.

Before: one class calculates a report, formats it and emails it. A change to the email provider, the layout or the calculation all touch the same class.

Python
class ReportService:
    def generate(self, results): ...      # business logic
    def to_html(self, report): ...        # presentation
    def email(self, html, to): ...        # infrastructure

After: each responsibility lives in its own class, so changes are isolated and each part is easy to test.

Python
class GradeReport:
    def __init__(self, results: dict[str, int]):
        self.results = results

    def average(self) -> float:
        return sum(self.results.values()) / len(self.results)

class HtmlReportFormatter:
    def format(self, report: GradeReport) -> str:
        return f"<p>Average: {report.average():.1f}</p>"

report = GradeReport({"ayesha": 92, "bilal": 78, "sara": 85})
print(HtmlReportFormatter().format(report))

Output:

Plaintext
<p>Average: 85.0</p>

O — Open/Closed Principle

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

Before: every new discount type means editing a growing if/elif chain — and risking breaking existing ones.

Python
def discount(kind, price):
    if kind == "student":
        return price * 0.8
    elif kind == "early_bird":
        return price * 0.9
    # every new kind = edit this function

After: new discounts are new classes; existing code doesn't change.

Python
from abc import ABC, abstractmethod

class Discount(ABC):
    @abstractmethod
    def apply(self, price: float) -> float: ...

class StudentDiscount(Discount):
    def apply(self, price): return price * 0.8

class EarlyBirdDiscount(Discount):
    def apply(self, price): return price * 0.9

class ScholarshipDiscount(Discount):          # added later — nothing else changed
    def apply(self, price): return 0.0

def checkout(price: float, discount: Discount) -> float:
    return discount.apply(price)

for d in (StudentDiscount(), EarlyBirdDiscount(), ScholarshipDiscount()):
    print(type(d).__name__, checkout(10_000, d))

Output:

Plaintext
StudentDiscount 8000.0
EarlyBirdDiscount 9000.0
ScholarshipDiscount 0.0

This is the Strategy pattern in action.

L — Liskov Substitution Principle

Objects of a subclass must be replaceable for objects of the base class without breaking the program.

The classic violation: a Square that inherits from Rectangle.

Python
class Rectangle:
    def __init__(self, width, height):
        self.width, self.height = width, height

    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     # keeps it square…
    def set_height(self, h): self.width = self.height = h

def stretch(rect: Rectangle) -> int:
    rect.set_width(5)
    rect.set_height(4)
    return rect.area()                     # caller expects 5 × 4 = 20

print("Rectangle:", stretch(Rectangle(1, 1)))
print("Square:   ", stretch(Square(1, 1)))

Output:

Plaintext
Rectangle: 20
Square:    16

Square breaks the expectations of code written for Rectangle. Mathematically a square is a rectangle, but behaviourally it isn't substitutable. Fixes: don't inherit — make both implement a common Shape interface with area(), and make them immutable.

Other LSP smells: a subclass that throws NotImplementedError for an inherited method, or one that requires stronger inputs or returns weaker guarantees than its parent.

I — Interface Segregation Principle

Clients should not be forced to depend on methods they do not use.

Before: one fat interface forces every implementation to stub out methods.

Python
from abc import ABC, abstractmethod

class Worker(ABC):
    @abstractmethod
    def code(self): ...
    @abstractmethod
    def attend_meeting(self): ...
    @abstractmethod
    def charge_battery(self): ...     # only makes sense for robots!

After: small, focused interfaces that classes combine as needed.

Python
from typing import Protocol

class Coder(Protocol):
    def code(self) -> str: ...

class Rechargeable(Protocol):
    def charge_battery(self) -> str: ...

class Developer:
    def code(self): return "writing Python"

class CodeBot:
    def code(self): return "generating tests"
    def charge_battery(self): return "charging"

def sprint(team: list[Coder]) -> list[str]:
    return [member.code() for member in team]

print(sprint([Developer(), CodeBot()]))

Output:

Plaintext
['writing Python', 'generating tests']

D — Dependency Inversion Principle

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

Before: the business logic creates its own MySQL repository and SMTP mailer. You can't test it without a database and mail server, or switch providers without editing it.

After: the service depends on interfaces, and concrete implementations are injected from outside (dependency injection):

Python
from typing import Protocol

class Notifier(Protocol):
    def send(self, to: str, message: str) -> None: ...

class EnrollmentService:
    def __init__(self, notifier: Notifier):          # injected, not created inside
        self.notifier = notifier

    def enroll(self, student: str, course: str) -> None:
        # ... save enrollment ...
        self.notifier.send(student, f"Welcome to {course}!")

class FakeNotifier:                                   # used in tests
    def __init__(self):
        self.sent = []
    def send(self, to, message):
        self.sent.append((to, message))

fake = FakeNotifier()
EnrollmentService(fake).enroll("ayesha", "System Design")
print(fake.sent)

Output:

Plaintext
[('ayesha', 'Welcome to System Design!')]

In production you inject an EmailNotifier or SmsNotifier. Frameworks provide DI containers to wire this up — Laravel's service container, Spring's @Autowired, NestJS providers.

SOLID without overdoing it

SOLID principles are guidelines, not laws. Applying them to a 50-line script creates needless abstraction. Apply them where code changes often or is hard to test — and refactor towards them as the codebase grows.

Common mistakes

  • Interpreting SRP as "one method per class" — it's about one reason to change.
  • Creating interfaces for everything when there's only one implementation and no testing need.
  • Using inheritance for code reuse and breaking LSP — prefer composition.

Key takeaways

  • S: one reason to change. O: extend with new code, don't modify old code.
  • L: subclasses must honour the parent's contract.
  • I: small, focused interfaces. D: depend on abstractions and inject dependencies.
  • SOLID makes code easier to test and change — apply it where change is likely.
Finished this lesson?
Tick it off to track your Design Patterns progress.
Next lesson