Guidelines, not rules. Here's the difference — and why it matters.
What is SOLID?
SOLID is a set of software design guidelines — not hard rules, but principles that guide how we organize our code.
The goal is simple: as your codebase grows and your team scales, things should get easier to change, not harder. SOLID is what makes that possible.
Five principles. One goal. Let's walk through each one with real code.
S — Single Responsibility Principle
A class, function, or method should have one and only one reason to change.
The Violation
class Bird:
def __init__(self, name: str, bird_type: str):
self.name = name
self.bird_type = bird_type
def make_sound(self):
# two jobs — deciding the type AND making the sound
if self.bird_type == "parrot":
print("Squawk!")
elif self.bird_type == "eagle":
print("Screech!")
elif self.bird_type == "owl":
print("Hoot!")
else:
print("...")
make_sound() has two responsibilities — deciding which bird type it is AND making the sound. That's two reasons to change. Add a new bird? Touch make_sound(). Change how sounds work? Touch make_sound() again. Two different reasons, one method. SRP violated.
The Fix
from abc import ABC, abstractmethod
class Bird(ABC):
def __init__(self, name: str):
self.name = name
@abstractmethod
def make_sound(self):
pass
class Parrot(Bird):
def make_sound(self):
print("Squawk!")
class Eagle(Bird):
def make_sound(self):
print("Screech!")
class Owl(Bird):
def make_sound(self):
print("Hoot!")
# Usage
birds = [Parrot("Polly"), Eagle("Sam"), Owl("Oliver")]
for bird in birds:
bird.make_sound()
Now each class has one responsibility. Parrot.make_sound() only changes if parrots change how they sound. Nothing else touches it.
O — Open/Closed Principle
A class should be open for extension but closed for modification.
SRP and OCP go hand in hand. When you fixed SRP in the Bird example above — you also fixed OCP.
The Violation
class BirdSoundService:
def make_sound(self, bird_type: str):
if bird_type == "parrot":
print("Squawk!")
elif bird_type == "eagle":
print("Screech!")
# adding a new bird = modifying this existing class
Every time a new bird arrives, you open this class and add another elif. You're modifying existing, working code. That's an OCP violation — and a regression risk.
The Fix
from abc import ABC, abstractmethod
class Bird(ABC):
@abstractmethod
def make_sound(self):
pass
class Parrot(Bird):
def make_sound(self):
print("Squawk!")
class Eagle(Bird):
def make_sound(self):
print("Screech!")
# Adding a new bird — zero changes to existing code
class Flamingo(Bird):
def make_sound(self):
print("Honk!")
class BirdSoundService:
def make_sound(self, bird: Bird):
bird.make_sound() # polymorphism handles it
New bird? New class. Existing code untouched. That's OCP.
Inheritance vs Composition
OCP can be achieved two ways — inheritance and composition. For larger codebases and larger teams, composition is safer.
Inheritance creates tight coupling. Change the parent and every subclass is potentially affected. When multiple developers are pushing code simultaneously, one change in the parent ripples through the entire hierarchy.
Composition injects behaviour instead:
class Bird:
def __init__(self, sound_behaviour):
self.sound = sound_behaviour
def make_sound(self):
self.sound.execute()
class ScreechSound:
def execute(self):
print("Screech!")
class SilentSound:
def execute(self):
print("...")
eagle = Bird(sound_behaviour=ScreechSound())
rubber_duck = Bird(sound_behaviour=SilentSound())
Swap the behaviour by swapping the object. Nothing else changes.
Inheritance defines what you are. Composition defines what you have. Prefer what you have — it's easier to change.
L — Liskov Substitution Principle
A child class should be fully substitutable for its parent. Wherever you use the parent, the child must work correctly — no surprises, no broken contracts.
The Violation
class Bird(ABC):
@abstractmethod
def fly(self):
pass
@abstractmethod
def make_sound(self):
pass
class Eagle(Bird):
def fly(self):
print("Eagle soaring high!")
def make_sound(self):
print("Screech!")
class Penguin(Bird):
def fly(self):
raise NotImplementedError("Penguins can't fly!") # ← LSP violated
def make_sound(self):
print("Squawk!")
# This breaks at runtime
def make_bird_fly(bird: Bird):
bird.fly() # works for Eagle, crashes for Penguin
Penguin extends Bird but can't honour the fly() contract. It's like a button that does nothing when you click it — the interface promises behaviour, the implementation delivers nothing. Users and callers hate this.
The Fix
from abc import ABC, abstractmethod
class Bird(ABC):
@abstractmethod
def make_sound(self):
pass
class Flyable(ABC):
@abstractmethod
def fly(self):
pass
class Eagle(Bird, Flyable):
def make_sound(self):
print("Screech!")
def fly(self):
print("Eagle soaring high!")
class Penguin(Bird):
def make_sound(self):
print("Squawk!")
# no fly() — penguins don't fly, so they don't implement Flyable
class Flamingo(Bird, Flyable):
def make_sound(self):
print("Honk!")
def fly(self):
print("Flamingo taking off!")
# Clean — only birds that can fly implement Flyable
def make_bird_fly(bird: Flyable):
bird.fly()
make_bird_fly(Eagle()) # ✅
make_bird_fly(Flamingo()) # ✅
# make_bird_fly(Penguin()) — type checker catches this at compile time
Now the contract is honest. If a class says it can fly, it actually can.
I — Interface Segregation Principle
An interface should only contain methods that are genuinely related to each other.
Notice the fix for LSP above — we moved fly() into a separate Flyable interface. That's ISP in action.
The Violation
class BirdInterface(ABC):
@abstractmethod
def make_sound(self): pass
@abstractmethod
def fly(self): pass
@abstractmethod
def dance(self): pass # ← what does dancing have to do with flying?
@abstractmethod
def swim(self): pass
Flying, dancing, and swimming are unrelated behaviours. Any class implementing BirdInterface is forced to implement all of them — even if dancing makes no sense for an eagle.
The rule: if you look at a method in an interface and think "why is this even here?" — that's ISP telling you to split.
The Fix
from abc import ABC, abstractmethod
class Bird(ABC):
@abstractmethod
def make_sound(self): pass
class Flyable(ABC):
@abstractmethod
def fly(self): pass
class Swimmable(ABC):
@abstractmethod
def swim(self): pass
class Danceable(ABC):
@abstractmethod
def dance(self): pass
# Each bird implements only what makes sense
class Eagle(Bird, Flyable):
def make_sound(self): print("Screech!")
def fly(self): print("Eagle soaring!")
class Penguin(Bird, Swimmable):
def make_sound(self): print("Squawk!")
def swim(self): print("Penguin swimming!")
class Peacock(Bird, Danceable):
def make_sound(self): print("Screech!")
def dance(self): print("Peacock dancing!")
Focused, cohesive, justified. Each interface does exactly one thing.
LSP vs ISP — Same Smell, Different Diagnosis
Both surface as a class implementing something it has no business implementing:
-
Penguinforced to implementfly()→ LSP says fix the hierarchy -
BirdInterfaceforced to includedance()→ ISP says split the interface
Two sides of the same coin. Knowing which applies is what separates a good diagnosis from a guess.
D — Dependency Inversion Principle
Classes should not depend directly on each other — they should both depend on an abstraction.
The Violation
class LowFly:
def low_fly(self):
print("Flying low...")
class HighFly:
def high_fly(self):
print("Flying high!")
class Pigeon:
def __init__(self):
self.flying = LowFly() # hardcoded dependency
def fly(self):
self.flying.low_fly()
pigeon = Pigeon()
pigeon.fly() # Flying low...
Pigeon is hardcoded to LowFly. The pigeon has a RedBull and decides to fly high like an eagle. Now you're deep in the codebase changing every place LowFly was hardcoded. One decision ripples everywhere.
The Fix
from abc import ABC, abstractmethod
class DoFlying(ABC):
@abstractmethod
def fly(self): pass
class LowFly(DoFlying):
def fly(self):
print("Flying low...")
class HighFly(DoFlying):
def fly(self):
print("Flying high!")
class Pigeon:
def __init__(self, flying: DoFlying):
self.flying = flying # injected, not hardcoded
def fly(self):
self.flying.fly()
# Flying low
pigeon = Pigeon(flying=LowFly())
pigeon.fly() # Flying low...
# Pigeon had a RedBull — swap the behaviour, zero code changes
pigeon = Pigeon(flying=HighFly())
pigeon.fly() # Flying high!
Pigeon depends on the DoFlying interface — not on LowFly or HighFly directly. Both concrete classes implement the same fly() method through DoFlying. Swap the behaviour by changing one line at the call site. The Pigeon class never changes.
How They Connect
This is the question that often comes up in interviews: "How do SOLID principles relate to each other?"
Here's the connection:
SRP gives you small, focused classes. When a class does one thing, it becomes naturally easy to extend without modification — which is exactly what OCP asks for.
OCP tells you to extend via abstractions. But once you have inheritance hierarchies, LSP steps in and says: make sure your subclasses honour the parent's contract.
LSP and ISP surface the same smell — a class implementing something it has no business implementing. LSP says fix your hierarchy. ISP says fix your interface. Two sides of the same coin.
DIP is the glue that ties everything together. SRP, OCP, LSP, and ISP all tell you to create abstractions. DIP says: now depend on those abstractions, not the concretions. This is what makes everything testable, swappable, and decoupled.
SRP creates focused classes → OCP makes them extendable → LSP keeps hierarchies honest → ISP keeps interfaces clean → DIP wires it all together.
The Tradeoffs — What SOLID Costs You
SOLID is a compass, not a religion. Over-applying it leads to premature abstraction — which is its own anti-pattern.
| Principle | Benefit | Cost |
|---|---|---|
| SRP | Focused, easy to change in isolation | More classes, more files, more navigation |
| OCP | Add features without breaking existing code | More abstractions upfront — overkill if variants never appear |
| LSP | Subclasses are truly interchangeable | Sometimes forces flat hierarchies or abandoning inheritance |
| ISP | Classes only know what they need | Interface explosion — dozens of tiny interfaces |
| DIP | Testable, swappable, decoupled | Wiring complexity — need a composition root |
Signs you've over-engineered:
- You need to open 5 files to understand one feature
- Interfaces all have exactly one implementation
- New engineers can't find where the actual work happens
The rule of three: Once is a one-off. Twice is a coincidence. Three times — abstract it.
Understanding SOLID isn't about reciting five definitions. It's about knowing where each principle applies, how they connect to each other, and honestly explaining what each one costs. That's what makes the difference.
Top comments (0)