SOLID Principles Explained with Examples
Single responsibility, open/closed, Liskov substitution, interface segregation and dependency inversion — with before/after code.
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.
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.
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:
<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.
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.
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:
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.
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:
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.
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.
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:
['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):
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:
[('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.