SOLID Principles
The five principles that are the backbone of maintainable object-oriented design. Each one is really an answer to the same question: when requirements change — and they will — how do I stop that change from rippling through the whole codebase?
Single Responsibility (SRP)
What it is: a class should have one, and only one, reason to change.
Why it matters: if a class handles two responsibilities (say, calculating an invoice and formatting it for print), a change to the print format can break invoice calculation, and vice versa — two unrelated teams or features end up fighting over the same file. Splitting them means each piece changes only when its own reason to change shows up.
# Violates SRP — changes for pricing rules AND for print formatting both land here
class Invoice:
def calculate_total(self): ...
def print_receipt(self): ...
# Follows SRP — each class has exactly one reason to change
class InvoiceCalculator:
def calculate_total(self): ...
class InvoicePrinter:
def print_receipt(self, invoice): ...Open/Closed (OCP)
What it is: software entities should be open for extension but closed for modification — you should be able to add new behavior without editing existing, working code.
Why it matters: every time you edit a class that’s already tested and deployed, you risk breaking it. If new behavior can instead be added by writing a new class (e.g. a new subclass or a new implementation of an interface), the old code — and its test coverage — stays untouched and trustworthy.
# Violates OCP — adding a new discount type means editing this function again
def discount(customer_type, price):
if customer_type == "regular":
return price * 0.95
elif customer_type == "vip":
return price * 0.80
# every new type = another elif here
# Follows OCP — a new discount is a new class; this code never changes
class Discount:
def apply(self, price): raise NotImplementedError
class VipDiscount(Discount):
def apply(self, price): return price * 0.80Liskov Substitution (LSP)
What it is: subtypes must be substitutable for their base type without breaking correctness — any code written against the base type should work correctly no matter which subtype is actually passed in.
Why it matters: it keeps polymorphism honest. The classic violation is a Square extending Rectangle that overrides setWidth to also change height — technically an “is-a” in the real world, but it breaks any code that assumes setting a rectangle’s width leaves its height alone. LSP is what makes OCP safe: without it, “extension” can silently change behavior instead of adding to it.
# Violates LSP — a Square silently changes behavior a Rectangle-user relies on
class Rectangle:
def set_width(self, w): self.width = w
def set_height(self, h): self.height = h
class Square(Rectangle):
def set_width(self, w):
self.width = self.height = w # surprise: height changes too
def resize(rect: Rectangle):
rect.set_width(5)
rect.set_height(10)
assert rect.width == 5 # fails if rect is actually a SquareInterface Segregation (ISP)
What it is: many small, client-specific interfaces beat one large general-purpose one.
Why it matters: a fat interface forces every implementer to provide methods it doesn’t need (often as stubs that throw NotImplemented), and forces every client to depend on methods it never calls — so an unrelated change to a method they don’t even use can still force a recompile or break their code. Splitting the interface means a class only depends on what it actually uses.
# Violates ISP — a BasicPrinter is forced to implement fax() and scan() it doesn't support
class Machine:
def print(self, doc): raise NotImplementedError
def fax(self, doc): raise NotImplementedError
def scan(self, doc): raise NotImplementedError
# Follows ISP — implement only the interface you actually need
class Printer:
def print(self, doc): ...
class Fax:
def fax(self, doc): ...Dependency Inversion (DIP)
What it is: depend on abstractions, not concretions — high-level modules shouldn’t depend on low-level modules; both should depend on interfaces.
Why it matters: without it, high-level business logic ends up directly wired to low-level detail (a specific database driver, a specific email provider), so a low-level change forces a high-level rewrite, and the business logic can’t be tested without the real dependency. Inverting the dependency — the low-level module implements an interface the high-level module defines — means the detail can be swapped or mocked freely.
# Violates DIP — ReportGenerator is wired directly to MySQL
class ReportGenerator:
def __init__(self):
self.db = MySQLDatabase() # concrete, low-level dependency
# Follows DIP — ReportGenerator depends on an abstraction, not a concrete DB
class ReportGenerator:
def __init__(self, db: Database): # any Database implementation works
self.db = dbCohesion and Isolation from Change
These five principles all serve one underlying goal: high cohesion (things that belong together are grouped together) and isolation from change (a change in one place shouldn’t force changes elsewhere). SRP and ISP shrink the surface area of any one class or interface; OCP and LSP let that surface area grow through extension instead of edits; DIP controls which direction the dependencies actually point. The combined effect is a codebase where the blast radius of any single change is small and predictable.