Module 04: SOLID Principles
Why this matters
SOLID is the single most commonly asked LLD topic there is — almost every interviewer will, at some point, hand you a class and ask "what's wrong with this design?", and the answer is almost always one of these five letters. More importantly, SOLID isn't trivia to memorize: every design pattern in modules 06–09 exists specifically to satisfy one or more of these principles in a proven, reusable shape. Understanding SOLID first means the patterns will feel like "oh, that's just OCP done properly," rather than five new things to memorize from scratch.
Concepts
S — Single Responsibility Principle (SRP)
A class should have one reason to change. Not "one method" — one reason, meaning one axis along which requirements can shift independently.
# VIOLATION: three unrelated reasons to change, in one class
class Report:
def __init__(self, data):
self.data = data
def calculate_total(self):
return sum(self.data)
def format_as_html(self):
return f"<p>Total: {self.calculate_total()}</p>"
def save_to_file(self, path):
with open(path, "w") as f:
f.write(self.format_as_html())
This class changes if the calculation logic changes, if the display format changes (HTML today, maybe JSON tomorrow), or if where it's saved changes (file today, database tomorrow) — three unrelated teams or requirements could each force an edit to this one class.
# FIXED: one reason to change, per class
class ReportData:
def __init__(self, data):
self.data = data
def total(self):
return sum(self.data)
class HtmlReportFormatter:
def format(self, report: ReportData) -> str:
return f"<p>Total: {report.total()}</p>"
class FileReportSaver:
def save(self, content: str, path: str) -> None:
with open(path, "w") as f:
f.write(content)
Each class can now change for exactly one reason, tested in isolation, without risking breaking the other two concerns.
O — Open/Closed Principle (OCP)
Open for extension, closed for modification: adding new behavior should mean adding new code, not editing code that already works (and is already tested).
# VIOLATION: adding a new customer type means editing this method again
class DiscountCalculator:
def discount_for(self, customer_type: str, amount: float) -> float:
if customer_type == "regular":
return amount * 0.0
elif customer_type == "premium":
return amount * 0.1
elif customer_type == "vip":
return amount * 0.2
# every new tier means another elif here, risking the existing ones
# FIXED: new tiers are new classes; DiscountCalculator never changes again
from abc import ABC, abstractmethod
class DiscountPolicy(ABC):
@abstractmethod
def discount(self, amount: float) -> float: ...
class RegularDiscount(DiscountPolicy):
def discount(self, amount): return amount * 0.0
class PremiumDiscount(DiscountPolicy):
def discount(self, amount): return amount * 0.1
class VipDiscount(DiscountPolicy):
def discount(self, amount): return amount * 0.2
def apply_discount(policy: DiscountPolicy, amount: float) -> float:
return policy.discount(amount) # never touched again, for any future tier
# adding "PlatinumDiscount" later = one new class, zero edits to existing code
This is exactly the Strategy pattern, arriving formally in module 08 — OCP is the principle; Strategy is one proven shape that satisfies it.
L — Liskov Substitution Principle (LSP)
A subtype must be usable anywhere its base type is expected, without breaking the caller's expectations. The textbook example is the Square/Rectangle trap, and it's worth seeing exactly why it breaks, not just that it does:
# VIOLATION: Square "is-a" Rectangle in the geometric sense, but NOT behaviorally
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = 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 = w
self.height = w # a square must keep both sides equal...
def set_height(self, h):
self.width = h # ...so setting one secretly changes the other
self.height = h
def resize_and_check(rect: Rectangle):
rect.set_width(5)
rect.set_height(4)
assert rect.area() == 20, f"expected 20, got {rect.area()}" # written trusting Rectangle's contract
resize_and_check(Rectangle(0, 0)) # passes: area is 20
resize_and_check(Square(0, 0)) # FAILS: Square silently forced both sides to 4, area is 16
resize_and_check was written correctly against Rectangle's
contract — "setting width and height independently controls the area
independently." Square cannot honor that contract without breaking its
own invariant (width == height), so substituting a Square wherever a
Rectangle is expected silently breaks the caller. This is LSP violated,
even though "a square is a rectangle" is true in geometry.
The fix isn't a clever workaround — it's recognizing the inheritance
itself is wrong. Square should not inherit from Rectangle at all.
Both should instead implement a common Shape abstraction (module 02)
with only an area() method and no shared set_width/set_height
contract that only one of them can honor:
class Shape(ABC):
@abstractmethod
def area(self) -> float: ...
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self): return self.width * self.height
class Square(Shape):
def __init__(self, side):
self.side = side # no shared mutable width/height contract to violate
def area(self): return self.side ** 2
I — Interface Segregation Principle (ISP)
No client should be forced to depend on methods it doesn't use. A "fat" interface with many unrelated methods forces every implementer to provide (or stub out) methods that make no sense for it.
# VIOLATION: a fat interface forces RobotWorker to implement something meaningless
from abc import ABC, abstractmethod
class Worker(ABC):
@abstractmethod
def work(self): ...
@abstractmethod
def eat(self): ...
class HumanWorker(Worker):
def work(self): print("coding")
def eat(self): print("eating lunch")
class RobotWorker(Worker):
def work(self): print("welding")
def eat(self):
raise NotImplementedError("robots don't eat") # forced to implement something nonsensical
# FIXED: split into focused interfaces; implement only what applies
class Workable(ABC):
@abstractmethod
def work(self): ...
class Feedable(ABC):
@abstractmethod
def eat(self): ...
class HumanWorker(Workable, Feedable):
def work(self): print("coding")
def eat(self): print("eating lunch")
class RobotWorker(Workable): # simply doesn't implement Feedable — no method to fake
def work(self): print("welding")
D — Dependency Inversion Principle (DIP)
Depend on abstractions, not concrete implementations — especially across a "layer boundary" (a high-level policy shouldn't hard-wire itself to one specific low-level detail).
# VIOLATION: NotificationService is welded to one concrete sender
class EmailSender:
def send(self, message): print(f"Emailing: {message}")
class NotificationService:
def __init__(self):
self.sender = EmailSender() # hard-wired — can never be swapped or faked in a test
def notify(self, message):
self.sender.send(message)
# FIXED: NotificationService depends on an abstraction, not a concrete class
from abc import ABC, abstractmethod
class MessageSender(ABC):
@abstractmethod
def send(self, message: str) -> None: ...
class EmailSender(MessageSender):
def send(self, message): print(f"Emailing: {message}")
class SmsSender(MessageSender):
def send(self, message): print(f"Texting: {message}")
class NotificationService:
def __init__(self, sender: MessageSender): # the abstraction is INJECTED, not created inside
self.sender = sender
def notify(self, message):
self.sender.send(message)
NotificationService(EmailSender()).notify("hello") # swap in SmsSender() with zero changes here
This is called dependency inversion because the normal direction —
NotificationService deciding on and depending directly on EmailSender
— gets inverted: both NotificationService and EmailSender now depend
on the shared MessageSender/IMessageSender abstraction instead of on
each other. This is also the seed of dependency injection, treated in
full in module 19.
Hands-on exercises
Do each in both languages.
1. Fix an SRP violation
Take a UserAccount class that stores user data, validates an email
format, and sends a welcome email, all in one class. Split it into
three single-responsibility classes.
2. Fix an OCP violation
Take a ShippingCostCalculator with an if/elif chain over
"standard", "express", "overnight" shipping methods. Refactor it to
the polymorphic shape from the OCP example so a new shipping method
requires zero edits to existing classes.
3. Spot and fix an LSP violation
Given a Bird base class with a fly() method, and a Penguin
subclass that overrides fly() to throw/raise "penguins can't fly" —
explain in writing why this violates LSP (an interviewer will ask you to
articulate this, not just fix it), then redesign so Penguin is not
forced into a contract it can't honor (hint: not every Bird can fly —
model Flyable separately, same idea as the ISP fix).
4. Fix an ISP violation
Take a fat IPrinterMachine/PrinterMachine interface with print(),
scan(), and fax(). Split it so a SimplePrinter class that only
prints isn't forced to implement scan()/fax().
5. Fix a DIP violation
Take an OrderProcessor that directly instantiates a concrete
MySqlOrderRepository inside its constructor. Refactor it to depend on
an OrderRepository/IOrderRepository abstraction injected from
outside, then show you can pass in a fake/in-memory implementation
without changing OrderProcessor at all — this is exactly what makes a
class testable, previewed here and formalized in module 19.
Independent challenge
No code given.
Task: You're given (mentally construct, or find/write) a single
OrderManager "God class" that: validates an order, calculates tax
using an if/elif chain over hardcoded country codes, calculates a
discount using another if/elif chain over hardcoded customer tiers,
sends a confirmation email by directly instantiating a concrete
SmtpEmailClient, and saves the order by directly instantiating a
concrete SqlOrderRepository. Refactor this single class into a design
that satisfies all five SOLID principles simultaneously — identify
which refactor addresses which letter, explicitly, in a short comment or
note above each new class.
Hint
You should end up with roughly: one class per responsibility (SRP) — an
Order/validation piece, a tax calculator, a discount calculator; the
tax and discount calculators built as swappable strategies keyed by
country/tier so adding a new one needs no edits (OCP); every strategy
implementation actually honoring its interface's contract with no
surprise exceptions (LSP); focused interfaces rather than one fat one for
"anything order-related" (ISP); and OrderManager itself depending on
injected abstractions for both email sending and persistence, never
constructing SmtpEmailClient/SqlOrderRepository directly (DIP).
Common mistakes & troubleshooting
- Over-applying SRP into dozens of tiny classes. SRP means "one reason to change," not "one method per class." A class with five tightly related methods that all change together for the same reason is fine — don't fragment cohesive code just to look SOLID.
- Misreading "closed for modification" as "never edit this file again." OCP is about not needing to modify existing, working, tested code to add new behavior — it doesn't mean the file is literally frozen forever; genuine bug fixes still belong there.
- Treating Square-extends-Rectangle as "just a fun puzzle." It's the canonical LSP violation because it looks so obviously correct geometrically — the lesson is that "is-a" in plain English is not the same test as "is-a" behaviorally substitutable. Always ask "can every caller of the base type use this subtype with zero surprises?"
- Defending a fat interface as "convenient — one place for
everything." Convenient for the interface's author, expensive for
every implementer forced to fake methods that don't apply to them
(as
RobotWorker.Eat()had to). Split by what different clients actually need, not by what's easy to declare in one place. - Constructing dependencies with
new/direct instantiation deep inside a class, then wondering why it can't be unit tested. If a class builds its ownEmailSender/SqlOrderRepositoryinternally, no test can substitute a fake one — inject the abstraction through the constructor instead (DIP), exactly as shown above.
Checkpoint quiz
- What does "one reason to change" mean in SRP — why not just "one method"?
- In the OCP example, what specifically has to change in
DiscountCalculator/ApplyDiscountitself when a new "Platinum" tier is added, after the fix? - Why does
Square extends Rectangleviolate LSP even though a square genuinely is a rectangle geometrically? - What's wrong with
RobotWorkerbeing forced to implementeat()? - In the DIP fix, which two things does
NotificationServicenow depend on, and which one did it depend on before the fix? - Which SOLID letter is most directly satisfied by the Strategy pattern (coming in module 08)?
Answers
- It means one axis of change — one category of requirement that could force an edit. A class can have several methods and still have a single responsibility, as long as all its methods change together for the same underlying reason.
- Nothing — you add one new class (
PlatinumDiscount) implementing the existingDiscountPolicy/IDiscountPolicyabstraction; the calculator/apply function that consumes the abstraction is untouched. - Because
Rectangle's contract (set width and height independently, each controls area independently) cannot be honored bySquare(which must keep both sides equal) — code written correctly againstRectangle's contract breaks when aSquareis substituted in. - It's forced to depend on and "implement" a method (
eat()) that has no meaningful behavior for it — violating ISP, the interface promised more than every implementer can actually deliver. - After the fix, it depends only on the
MessageSender/IMessageSenderabstraction (injected via constructor). Before the fix, it depended directly on the concreteEmailSenderclass, constructed internally. - Open/Closed Principle (OCP) — Strategy lets you add new behavior (new strategy classes) without modifying the code that selects and uses a strategy.
Interview questions
- "Walk me through all five SOLID principles with a one-sentence definition each." SRP: a class should have one reason to change. OCP: open for extension, closed for modification. LSP: subtypes must be substitutable for their base type without breaking correctness. ISP: no client should depend on methods it doesn't use. DIP: depend on abstractions, not concrete implementations.
- "Why is
Square extends Rectanglethe textbook LSP violation, and what would you do instead?" BecauseSquarecan't honorRectangle's implied contract of independently settable width/height without breaking its ownwidth == heightinvariant — substituting it silently changes caller behavior. Instead, don't model the inheritance at all: give both a shared minimal abstraction (like aShapewith justarea()) and no shared mutable-dimension contract that only one can honor. - "How does the Strategy pattern relate to OCP?"
Strategy is a concrete, reusable shape for satisfying OCP: instead of
a branching
if/elifchain that must be edited for every new case, each case becomes its own class implementing a shared interface, and new cases are added purely by writing new classes — the code that uses a strategy never needs to change. - "What's the relationship between DIP and dependency injection?" DIP is the principle (depend on abstractions, not concretions). Dependency injection is the mechanism — passing the concrete implementation in from outside (typically via a constructor) rather than having a class construct its own dependency — that makes DIP achievable in practice, and is what makes a class unit-testable in isolation.
- "Give a real example of an ISP violation you've seen or could
imagine, beyond the textbook worker/robot example."
A common real one: a single
IRepositoryinterface withCreate/Read/Update/Delete/Search/Export/Importforces every simple read-only repository to stub out five methods it will never use — split into smaller interfaces (IReadableRepository,IWritableRepository) so a read-only implementer only implements what it actually does.
Further reading & sources
- Robert C. Martin: The Principles of OOD (the original SOLID essays) - the source material SOLID is drawn from.
- Microsoft Learn: Dependency inversion principle - DIP framed in a real application-architecture context.
- Python:
abcmodule - the abstraction mechanism used throughout this module's OCP/ISP/DIP examples.
Next
05-core-design-principles — DRY, KISS, YAGNI, the Law of Demeter, composition-over-inheritance, and coupling/cohesion: the other half of "principles," rounding out everything you need before the patterns themselves begin in module 06.