SOLID Principles OOP | Complete Guide | InventiveHQ
SOLID is a set of five object-oriented design principles — Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion — that together reduce the cost of changing software by keeping classes small, contracts narrow, and dependencies pointed at abstractions instead of concrete implementations. The principles were articulated by Robert C. Martin (Uncle Bob) in the late 1990s and early 2000s, with the memorable acronym ordering suggested by Michael Feathers. Applied well, they turn "every change breaks three other things" into "new features are new files."
That is the summary an AI Overview gives you. Here is what it can't show you: which principle actually solves the mess in front of you, what each one looks like as before-and-after code rather than a slogan, and how to tell a principle that's earning its keep from an abstraction you added out of habit. Below is a one-screen decision table, an animated map of how the five principles connect, working Python examples for all five (not just SRP), and a pre-commit checklist you can actually run.
The five principles at a glance
Most guides list SOLID as five definitions and stop. The definitions are the easy part — the hard part is knowing which principle a given smell is telling you to reach for. Use the symptom column to jump to the right letter.
| Principle | One-line rule | Symptom it fixes | Reach for it when... |
|---|---|---|---|
| S — Single Responsibility | A class should have one reason to change | A class edited for two unrelated reasons (DB schema and report layout) | Bug fixes in one feature keep breaking an unrelated feature in the same class |
| O — Open/Closed | Open for extension, closed for modification | A growing if/elif chain edited for every new type | Adding a case means touching stable, already-tested code |
| L — Liskov Substitution | Subtypes must be swappable for their base type | A subclass that throws or no-ops on inherited methods | Callers need isinstance checks to avoid a subclass blowing up |
| I — Interface Segregation | No client forced to depend on unused methods | Implementers raising NotImplementedError on fat interfaces | A "simple" implementer must stub out half the interface |
| D — Dependency Inversion | Depend on abstractions, not concretions | High-level logic hard-wired to a specific DB/API/vendor | You can't unit-test business logic without a real database |
Which do I learn first? SRP and DIP give the biggest payoff for the least ceremony — get those two into muscle memory before worrying about the subtler L and I. The other three fall out naturally once you're separating responsibilities and depending on abstractions.
How the five principles connect
SOLID isn't five unrelated rules — they reinforce each other. Splitting responsibilities (S) creates the seams where you introduce abstractions (D), and those abstractions are what let you extend without modifying (O). The animation below traces that flow.
Understanding SOLID Principles
SOLID is an acronym representing five key principles that address common design challenges in object-oriented programming:
- Single Responsibility Principle (SRP): A class should have only one reason to change
- Open/Closed Principle (OCP): Software entities should be open for extension but closed for modification
- Liskov Substitution Principle (LSP): Subtypes should be replaceable for their base types without altering behavior
- Interface Segregation Principle (ISP): Clients should not be forced to depend on interfaces they do not use
- Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules; both should depend on abstractions
The examples below use Python, but every pattern maps directly to Java, C#, TypeScript, or any language with interfaces and classes.
S — Single Responsibility Principle
A class should have only one reason to change. When a class mixes concerns — say, holding user data and saving it to a database and formatting a report — a change to the report layout forces you to reopen (and risk breaking) database code. Split the responsibilities.
# Violation: three unrelated reasons to change one class
class User:
def __init__(self, name, email):
self.name = name
self.email = email
def save_to_database(self): # changes when the DB schema changes
print(f"Saving {self.name} to database")
def generate_report(self): # changes when the report layout changes
print(f"Generating report for {self.name}")
# Better: one reason to change per class
class User:
def __init__(self, name, email):
self.name = name
self.email = email
class UserRepository:
def save(self, user: User):
print(f"Saving {user.name} to database")
class UserReportGenerator:
def generate_report(self, user: User):
print(f"Generating report for {user.name}")
O — Open/Closed Principle
Software should be open for extension but closed for modification. The tell-tale violation is an if/elif chain that grows every time you add a type — each edit risks the branches that already work. Replace the chain with polymorphism so new behavior arrives as a new class, not an edit.
# Violation: every new shape edits area()
def area(shape):
if shape.kind == "circle":
return 3.14159 * shape.radius ** 2
elif shape.kind == "rectangle":
return shape.width * shape.height
# add a triangle? edit this function and re-test everything
# Better: add a triangle by writing a new class, touching nothing above
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float: ...
class Circle(Shape):
def __init__(self, radius): self.radius = radius
def area(self): return 3.14159 * self.radius ** 2
class Rectangle(Shape):
def __init__(self, w, h): self.w, self.h = w, h
def area(self): return self.w * self.h
L — Liskov Substitution Principle
Any code that works with a base type must keep working when handed a subtype — without knowing which subtype it got. A subclass that breaks its parent's guarantees (throwing where the base never would, or violating an invariant) forces callers into defensive isinstance checks.
# Violation: a Square breaks Rectangle's contract
class Rectangle:
def __init__(self, w, h): self._w, self._h = w, h
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): # a Square *is not* substitutable
def set_width(self, w): self._w = self._h = w # surprises callers
def set_height(self, h): self._w = self._h = h
# Any function expecting to set width and height independently now breaks.
# Fix: don't inherit. Model Square and Rectangle as separate Shapes (see OCP).
I — Interface Segregation Principle
Clients should not be forced to depend on methods they never use. A "fat" interface makes simple implementers stub out irrelevant methods with NotImplementedError. Split it into role-focused interfaces so each client depends only on what it actually calls.
# Violation: a fat interface forces a robot to "eat"
class Worker(ABC):
@abstractmethod
def work(self): ...
@abstractmethod
def eat(self): ...
class Robot(Worker):
def work(self): print("working")
def eat(self): raise NotImplementedError # robots don't eat
# Better: split roles so nobody stubs unused methods
class Workable(ABC):
@abstractmethod
def work(self): ...
class Eatable(ABC):
@abstractmethod
def eat(self): ...
class Human(Workable, Eatable):
def work(self): print("working")
def eat(self): print("eating")
class Robot(Workable): # depends only on what it uses
def work(self): print("working")
D — Dependency Inversion Principle
High-level modules (your business logic) should not depend on low-level modules (a specific database or vendor SDK); both should depend on an abstraction. Invert the arrow: the policy defines the interface it needs, and the detail implements it. This is what makes business logic unit-testable without a live database.
# Violation: NotificationService is welded to email
class EmailClient:
def send(self, msg): print(f"email: {msg}")
class NotificationService:
def __init__(self):
self.client = EmailClient() # can't swap, can't fake in tests
def notify(self, msg): self.client.send(msg)
# Better: depend on an abstraction, inject the concrete detail
class MessageSender(ABC):
@abstractmethod
def send(self, msg): ...
class EmailClient(MessageSender):
def send(self, msg): print(f"email: {msg}")
class SmsClient(MessageSender):
def send(self, msg): print(f"sms: {msg}")
class NotificationService:
def __init__(self, sender: MessageSender): # inject the dependency
self.sender = sender
def notify(self, msg): self.sender.send(msg)
# Production wiring and a test fake are now a one-line swap.
NotificationService(EmailClient()).notify("deploy done")
A pre-commit SOLID checklist
Run this against a class before you open the pull request. Any "yes" is a prompt to refactor — not a mandate, but a question worth answering.
- SRP: Does this class change for more than one reason? Could I name two different stakeholders who would each request edits here?
- OCP: To add the next obvious variant, would I edit this file — or add a new one? A growing
if/elifon atype/kindfield is the red flag. - LSP: Does any subclass throw, no-op, or
passon a method its base type promises? Do callers useisinstanceto dodge a subclass? - ISP: Does any implementer raise
NotImplementedErroror leave methods empty? Split the interface along those lines. - DIP: Can I unit-test this class's logic without a real database, network, or vendor SDK? If not, the dependency is concrete where it should be abstract.
- The over-engineering check: Does each abstraction I added have (or credibly foresee) a second implementation? A one-implementation interface is usually premature.
Benefits of Applying SOLID Principles
Implementing SOLID principles leads to significant improvements in code quality and maintainability:
- Improved maintainability: Easier to modify and debug individual components
- Enhanced reusability: Modular classes can be reused across different projects
- Better testability: Smaller, focused components (and injected dependencies) make unit testing straightforward
- Reduced technical debt: Clean, organized code prevents future refactoring crunches
- Improved scalability: Extensible design supports growing requirements without rewrites
Key takeaway: SOLID principles are not theoretical decoration — they solve real change-management problems and keep systems flexible as requirements evolve.
Common Mistakes and Best Practices
While SOLID provides excellent guidance, it can be misapplied. The failures to avoid:
- Overcomplicating code: Breaking everything into tiny classes and interfaces before any real variation exists
- Modifying instead of extending: Violating OCP by editing tested classes for every new case
- Improper inheritance: Creating subclasses that break base-class expectations (the LSP trap)
- Fat interfaces: Forcing implementers to stub methods they never call
- Tight coupling: High-level modules depending directly on low-level implementations
Remember: SOLID should enhance code quality, not manufacture complexity. Apply each principle in response to actual pain — duplication, churn, untestable logic — rather than preemptively on code that isn't hurting yet.
Elevate Your IT Efficiency with Expert Solutions
Transform Your Technology, Propel Your Business
Master advanced software architecture and design patterns with professional guidance. At InventiveHQ, we combine programming expertise with innovative cybersecurity practices to enhance your development skills, streamline your IT operations, and leverage cloud technologies for optimal efficiency and growth.