SOLID Principles: Five Rules for Code That Survives Change
A practical guide to the SOLID principles of object-oriented design — what each one means, why it matters, and how to apply it with clear before-and-after code examples.

SOLID Principles: Five Rules for Code That Survives Change
Most code is easy to write and hard to change. The first version of a class works fine; the trouble starts six months later when a new requirement arrives and touching one method breaks three others. SOLID is a set of five design principles, popularized by Robert C. Martin, that aim at exactly that problem: keeping code flexible, understandable, and safe to modify as it grows.
The acronym stands for:
| 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 their base type is |
| I | Interface Segregation | Don't force clients to depend on methods they don't use |
| D | Dependency Inversion | Depend on abstractions, not on concrete implementations |
Let's look at each one with examples. The code is in Python for readability, but the ideas apply to any language with classes and interfaces.
S — Single Responsibility Principle
A class should have only one reason to change.
"Responsibility" here means a reason to change, not "does one thing." A class that handles both business rules and database persistence has two masters: the domain expert and the DBA. When either changes their mind, the class changes.
Before
class Report:
def __init__(self, data):
self.data = data
def calculate_totals(self):
return sum(item.amount for item in self.data)
def format_as_html(self):
return f"<h1>Total: {self.calculate_totals()}</h1>"
def save_to_database(self, connection):
connection.execute("INSERT INTO reports ...", self.data)
This class changes when the calculation logic changes, when the HTML layout changes, and when the database schema changes.
After
class Report:
def __init__(self, data):
self.data = data
def calculate_totals(self):
return sum(item.amount for item in self.data)
class HtmlReportFormatter:
def format(self, report: Report) -> str:
return f"<h1>Total: {report.calculate_totals()}</h1>"
class ReportRepository:
def __init__(self, connection):
self.connection = connection
def save(self, report: Report):
self.connection.execute("INSERT INTO reports ...", report.data)
Each class now has a single axis of change. A side benefit: each piece is far easier to test in isolation.
Smell to watch for: class names containing "And" or "Manager", or methods that need wildly different imports.
O — Open/Closed Principle
Software entities should be open for extension but closed for modification.
You should be able to add new behavior without editing existing, tested code. In practice this usually means replacing if/elif chains on a type with polymorphism.
Before
class DiscountCalculator:
def calculate(self, customer_type, amount):
if customer_type == "regular":
return amount * 0.05
elif customer_type == "premium":
return amount * 0.10
elif customer_type == "vip":
return amount * 0.20
return 0
Every new customer tier means reopening this class and adding another branch — and re-testing all the old ones.
After
from abc import ABC, abstractmethod
class DiscountPolicy(ABC):
@abstractmethod
def apply(self, amount: float) -> float: ...
class RegularDiscount(DiscountPolicy):
def apply(self, amount): return amount * 0.05
class PremiumDiscount(DiscountPolicy):
def apply(self, amount): return amount * 0.10
class VipDiscount(DiscountPolicy):
def apply(self, amount): return amount * 0.20
class DiscountCalculator:
def calculate(self, policy: DiscountPolicy, amount: float) -> float:
return policy.apply(amount)
Adding a "student" tier is now a new class, not an edit to DiscountCalculator.
Caveat: don't abstract prematurely. Two branches in an if are fine. Reach for this pattern when you see the third or fourth variation appearing.
L — Liskov Substitution Principle
If
Sis a subtype ofT, objects of typeTmay be replaced with objects of typeSwithout breaking the program.
Named after Barbara Liskov, this principle is about behavioral compatibility. A subclass must honor the contract of its parent — not just the method signatures, but the expectations callers have about what those methods do.
Before — the classic violation
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
def set_height(self, h):
self._width = self._height = h
Mathematically a square is a rectangle. But in code:
def stretch(rect: Rectangle):
rect.set_width(5)
rect.set_height(10)
assert rect.area() == 50 # fails for Square — area is 100
Square silently changes the meaning of set_width, so any code written against Rectangle can break.
After
Don't force an inheritance relationship that doesn't hold behaviorally. Model them as siblings under a shared abstraction:
class Shape(ABC):
@abstractmethod
def area(self) -> float: ...
class Rectangle(Shape):
def __init__(self, width, height):
self.width, self.height = width, height
def area(self): return self.width * self.height
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self): return self.side ** 2
Warning signs of an LSP violation: a subclass that throws NotImplementedError, overrides a method to do nothing, or requires callers to check isinstance before using it.
I — Interface Segregation Principle
Clients should not be forced to depend on interfaces they do not use.
Large, "do-everything" interfaces force implementers to write stub methods and force clients to know about capabilities they'll never call. Prefer several small, focused interfaces over one fat one.
Before
class Worker(ABC):
@abstractmethod
def work(self): ...
@abstractmethod
def eat(self): ...
@abstractmethod
def sleep(self): ...
class Robot(Worker):
def work(self): print("Assembling parts")
def eat(self): raise NotImplementedError # robots don't eat
def sleep(self): raise NotImplementedError # or sleep
Robot is forced to implement methods that make no sense for it — which is also an LSP violation waiting to happen.
After
class Workable(ABC):
@abstractmethod
def work(self): ...
class Eatable(ABC):
@abstractmethod
def eat(self): ...
class Sleepable(ABC):
@abstractmethod
def sleep(self): ...
class Human(Workable, Eatable, Sleepable):
def work(self): print("Writing code")
def eat(self): print("Lunch break")
def sleep(self): print("Zzz")
class Robot(Workable):
def work(self): print("Assembling parts")
Now a scheduler that only cares about Workable doesn't need to know or care whether the worker eats.
Rule of thumb: design interfaces from the client's point of view, not the implementer's.
D — Dependency Inversion Principle
High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.
Business logic (high-level) shouldn't be welded to infrastructure (low-level) like a specific database, email provider, or file system. Introduce an abstraction that the high-level code owns, and let the low-level code implement it.
Before
class MySqlDatabase:
def save(self, order): ...
class OrderService:
def __init__(self):
self.db = MySqlDatabase() # hard-wired dependency
def place_order(self, order):
# ...validation, pricing...
self.db.save(order)
OrderService cannot be tested without a running MySQL instance, and switching to Postgres means editing the service.
After
class OrderRepository(ABC):
@abstractmethod
def save(self, order): ...
class MySqlOrderRepository(OrderRepository):
def save(self, order): ...
class InMemoryOrderRepository(OrderRepository):
def __init__(self): self.orders = []
def save(self, order): self.orders.append(order)
class OrderService:
def __init__(self, repository: OrderRepository):
self.repository = repository # injected abstraction
def place_order(self, order):
# ...validation, pricing...
self.repository.save(order)
The dependency arrow now points toward the abstraction. Production uses MySqlOrderRepository; tests use InMemoryOrderRepository; OrderService never changes.
Note: Dependency Inversion is the principle; Dependency Injection is one common technique for achieving it.
How the Principles Work Together
The five principles reinforce each other:
- SRP produces small, focused classes.
- ISP produces small, focused interfaces for those classes to implement.
- LSP ensures implementations of those interfaces are genuinely interchangeable.
- DIP makes high-level code depend on those interfaces instead of concrete classes.
- OCP is the payoff: because everything depends on stable abstractions, you extend the system by adding new implementations rather than editing old ones.
When Not to Apply SOLID
SOLID is a tool for managing change, and it has a cost: more classes, more indirection, more files to open. In a 200-line script, a throwaway prototype, or code that will never be modified again, that cost buys nothing.
Apply the principles when:
- You've seen the same code change for two or more unrelated reasons.
- You're adding the third variation of something.
- Testing requires standing up real infrastructure.
- A new team member can't understand a class without reading the whole codebase.
Skip them (for now) when the code is small, stable, and understood by everyone who touches it. You can always refactor toward SOLID later — that's the whole point of the principles.
Summary
| Principle | Ask yourself |
|---|---|
| Single Responsibility | How many different people or teams would ask me to change this class? |
| Open/Closed | Can I add a new case without editing existing code? |
| Liskov Substitution | Can I swap in any subclass and have callers still work correctly? |
| Interface Segregation | Does every implementer actually need every method here? |
| Dependency Inversion | Is my business logic importing infrastructure? |
SOLID won't make your code perfect, but it gives you a shared vocabulary for spotting brittle design and a set of proven moves for fixing it.
Further reading: Robert C. Martin, "Agile Software Development: Principles, Patterns, and Practices" (2002), and Barbara Liskov's 1987 keynote "Data Abstraction and Hierarchy."