Python
28 / 29
SOLID Principles
Five rules that keep object-oriented code easy to change.
The point of SOLID
Software changes constantly. SOLID is about minimizing the blast radius of every change — so adding a feature touches one class, not twenty.
S — Single Responsibility
A class should have one, and only one, reason to change.
# BAD: report generation + email sending in one class
class Report:
def build(self): ...
def send_email(self): ...
# GOOD: split responsibilities
class Report: def build(self): ...
class ReportMailer: def send(self, report): ...O — Open/Closed
Open for extension, closed for modification. Add behavior without editing existing code.
# BAD: every new shape forces editing this function
def area(shape):
if shape.kind == "circle": ...
elif shape.kind == "square": ...
# GOOD: each shape owns its area
class Shape(Protocol):
def area(self) -> float: ...
class Circle: def area(self): return 3.14*self.r**2
class Square: def area(self): return self.side**2
def total_area(shapes: list[Shape]) -> float:
return sum(s.area() for s in shapes)L — Liskov Substitution
Subtypes must be usable wherever their base type is — without surprises.
# BAD: violates LSP — callers of Bird.fly() break for Penguin
class Bird: def fly(self): ...
class Penguin(Bird):
def fly(self): raise NotImplementedError
# GOOD: model the actual hierarchy
class Bird: ...
class FlyingBird(Bird): def fly(self): ...
class Penguin(Bird): ...I — Interface Segregation
Don't force clients to depend on methods they don't use. Prefer many small Protocols.
# BAD: one fat interface
class Worker(Protocol):
def work(self) -> None: ...
def eat(self) -> None: ...
class Robot:
def work(self): ...
def eat(self): raise NotImplementedError # robots don't eat
# GOOD: split
class Workable(Protocol): def work(self) -> None: ...
class Eatable(Protocol): def eat(self) -> None: ...D — Dependency Inversion
Depend on abstractions, not concretions. High-level code shouldn't know if storage is Postgres or a file.
# BAD: hard-wired to Postgres
class OrderService:
def __init__(self):
self.db = PostgresClient("prod-db:5432")
# GOOD: inject the abstraction
class Repository(Protocol):
def save(self, order: Order) -> None: ...
class OrderService:
def __init__(self, repo: Repository):
self.repo = repo
# Swap implementations in tests / staging / prod
OrderService(PostgresRepo())
OrderService(InMemoryRepo())Pythonic SOLID
- Use
Protocolinstead of abstract base classes for interfaces. - Use plain functions when a class would have one method.
- Inject dependencies in
__init__, never callnewinside business logic. - Test the principle, not the dogma — readability beats perfect adherence.