Mahdi Shamlou here.
Across this whole series — OOP, Design Patterns, Architecture — one set of ideas kept showing up without ever getting its own spotlight: SOLID.
Every "bad code" example in my Design Patterns articles violated one of these five principles. Every pattern I showed you was a solution to one of these five principles. SOLID isn't a sixth thing to learn — it's the reasoning underneath everything you've already learned.
I've seen:
- A
UserServiceclass that handles validation, database access, email sending, and logging — all in one class - A
reportsmodule where adding a new report format means editing five existing functions - A subclass that throws
NotImplementedErroron a method its parent promises to support - Interfaces so bloated that classes implement six methods just to use one
- Business logic directly importing and calling a specific database driver
Each of these has a name. Let's go through them.
What Is SOLID?
SOLID is five principles for writing object-oriented code that's easy to maintain and extend, coined by Robert C. Martin ("Uncle Bob"):
- S — Single Responsibility Principle
- O — Open/Closed Principle
- L — Liskov Substitution Principle
- I — Interface Segregation Principle
- D — Dependency Inversion Principle
None of these are Python-specific — they came from the Java/C++ world — but they apply just as much here, sometimes in a more relaxed form thanks to duck typing.
1. Single Responsibility Principle (SRP)
A class should have one, and only one, reason to change.
"Responsibility" here means "reason to change" — not "does one thing" in a trivial sense.
Bad code (multiple responsibilities crammed into one class)
class UserService:
def __init__(self, db_connection):
self.db = db_connection
def create_user(self, name, email):
# Validation
if "@" not in email:
raise ValueError("Invalid email")
# Persistence
self.db.execute("INSERT INTO users (name, email) VALUES (?, ?)", (name, email))
# Notification
print(f"Sending welcome email to {email}")
# Logging
with open("app.log", "a") as f:
f.write(f"User created: {name}\n")
# This class changes if validation rules change, if the database changes,
# if the email provider changes, or if the logging format changes.
# Four unrelated reasons to touch the same class.
Good code (one responsibility per class)
class EmailValidator:
def validate(self, email):
if "@" not in email:
raise ValueError("Invalid email")
class UserRepository:
def __init__(self, db_connection):
self.db = db_connection
def save(self, name, email):
self.db.execute("INSERT INTO users (name, email) VALUES (?, ?)", (name, email))
class WelcomeEmailSender:
def send(self, email):
print(f"Sending welcome email to {email}")
class Logger:
def log(self, message):
with open("app.log", "a") as f:
f.write(f"{message}\n")
class UserService:
def __init__(self, repository, validator, mailer, logger):
self.repository = repository
self.validator = validator
self.mailer = mailer
self.logger = logger
def create_user(self, name, email):
self.validator.validate(email)
self.repository.save(name, email)
self.mailer.send(email)
self.logger.log(f"User created: {name}")
# UserService now orchestrates — it doesn't implement everything itself.
# Change the email provider? Only WelcomeEmailSender changes.
Pros:
- Each class is small, testable, and easy to understand in isolation
- Changes are localized — you know exactly which class to touch
- Classes can be reused independently (the
Loggerworks for anything, not just users)
Cons:
- More classes and files to navigate for simple features
- Can be over-applied — splitting a genuinely cohesive two-line method into three classes is not SRP, it's noise
My Take:
SRP doesn't mean "one method per class." It means "one reason to change." If validation rules and database schema change independently of each other, they're two responsibilities, even if today the code for both fits in five lines.
The key insight: If you can't name the single responsibility of a class in one sentence without using the word "and," it has more than one.
One more thing worth knowing: a "reason to change" usually traces back to a different person or team who'd request it — the marketing team wants the welcome email reworded, the DBA wants the schema changed, the compliance team wants the log format changed. If two changes always come from the same stakeholder, they might genuinely be one responsibility. If they come from different stakeholders, split them — even if today the code for both fits in five lines.
2. Open/Closed Principle (OCP)
Software entities should be open for extension, but closed for modification. In other words, new requirements should usually mean writing new code—not rewriting old, working code.
You should be able to add new behavior without editing existing, working code.
Bad code (must modify existing code for every new type)
class DiscountCalculator:
def calculate(self, customer_type, amount):
if customer_type == "regular":
return amount
elif customer_type == "premium":
return amount * 0.9
elif customer_type == "vip":
return amount * 0.8
# Adding a new customer type means editing this method again
# and risking breaking the existing branches.
Good code (extend without touching existing code)
from abc import ABC, abstractmethod
class DiscountStrategy(ABC):
@abstractmethod
def apply(self, amount):
pass
class RegularDiscount(DiscountStrategy):
def apply(self, amount):
return amount
class PremiumDiscount(DiscountStrategy):
def apply(self, amount):
return amount * 0.9
class VipDiscount(DiscountStrategy):
def apply(self, amount):
return amount * 0.8
class DiscountCalculator:
def calculate(self, strategy: DiscountStrategy, amount):
return strategy.apply(amount)
# New customer type? Add a new class. DiscountCalculator never changes.
class StudentDiscount(DiscountStrategy):
def apply(self, amount):
return amount * 0.85
calc = DiscountCalculator()
print(calc.calculate(StudentDiscount(), 100)) # 85.0
This is the Strategy pattern in action — OCP is why Strategy exists.
Pros:
- New functionality doesn't risk breaking existing, tested code
- Encourages composition and polymorphism over long conditionals
- Makes code more predictable to extend over time
Cons:
- Requires anticipating that extension points are needed — premature abstraction is a real risk
- Adds indirection (interfaces, abstract classes) that can be overkill for code that truly never changes
My Take:
OCP doesn't mean "abstract everything, just in case." It means: once you notice a piece of code keeps growing new elif branches, that's the signal to introduce an extension point — not before.
The key insight: Don't design for hypothetical future requirements. Refactor toward OCP the moment real requirements start repeating the same conditional shape.
One clarification, since this is the most misread principle on the list: OCP does not mean "never touch a file again." It means the abstraction (DiscountStrategy) stays closed once it's stable, while new implementations (StudentDiscount) extend it freely. DiscountCalculator itself is still allowed to change — just not every time a new customer type shows up.
3. Liskov Substitution Principle (LSP)
Subtypes must be substitutable for their base types without breaking the program.
If Bird promises fly(), every subclass of Bird must be able to actually fly — or Bird shouldn't promise it.
Bad code (subclass breaks the parent's contract)
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def set_width(self, width):
self.width = width
def set_height(self, height):
self.height = height
def area(self):
return self.width * self.height
class Square(Rectangle):
"""A square IS a rectangle... right?"""
def set_width(self, width):
self.width = width
self.height = width # forces height to match — breaks the parent's behavior
def set_height(self, height):
self.width = height
self.height = height
def resize_and_check(rect: Rectangle):
rect.set_width(5)
rect.set_height(10)
assert rect.area() == 50, "Area should be width * height"
resize_and_check(Rectangle(2, 2)) # passes
resize_and_check(Square(2, 2)) # AssertionError! area is 100, not 50
# Square LOOKS like a valid Rectangle subclass, but it silently changes behavior
# in a way that breaks code written against the Rectangle contract.
Good code (don't force an "is-a" that isn't behaviorally true)
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
pass
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
def area(self):
return self.side ** 2
# Square is no longer pretending to be a mutable Rectangle.
# Both honor the same Shape contract without hidden surprises.
Pros:
- Code that works with a base type can safely work with any subtype, no surprises
- Prevents subtle bugs where a subclass "technically" fits but behaves inconsistently
- Encourages thinking about behavior, not just structural similarity, when designing hierarchies
Cons:
- Requires genuinely thinking through behavioral contracts, not just "is this conceptually a subtype?"
- Sometimes means avoiding a tempting but flawed inheritance relationship (like Square/Rectangle)
My Take:
LSP is the principle that catches "well it's technically an is-a relationship" mistakes. The classic Square/Rectangle problem exists precisely because mathematical is-a and behavioral is-a aren't the same thing. Trust behavior over taxonomy.
The key insight: If using a subclass anywhere the parent is expected requires the caller to know which subclass they got, LSP is already broken.
4. Interface Segregation Principle (ISP)
Clients shouldn't be forced to depend on methods they don't use.
Many small, focused interfaces beat one large, general-purpose one.
Bad code (a bloated interface forces irrelevant methods on everyone)
from abc import ABC, abstractmethod
class Worker(ABC):
@abstractmethod
def work(self):
pass
@abstractmethod
def eat(self):
pass
@abstractmethod
def sleep(self):
pass
class HumanWorker(Worker):
def work(self):
print("Human working")
def eat(self):
print("Human eating lunch")
def sleep(self):
print("Human sleeping")
class RobotWorker(Worker):
def work(self):
print("Robot working")
def eat(self):
raise NotImplementedError("Robots don't eat!") # forced to implement anyway
def sleep(self):
raise NotImplementedError("Robots don't sleep!")
# RobotWorker is forced to implement methods that make no sense for it,
# just because they were bundled into one fat interface.
Good code (small, role-specific interfaces)
from abc import ABC, abstractmethod
class Workable(ABC):
@abstractmethod
def work(self):
pass
class Eatable(ABC):
@abstractmethod
def eat(self):
pass
class Sleepable(ABC):
@abstractmethod
def sleep(self):
pass
class HumanWorker(Workable, Eatable, Sleepable):
def work(self):
print("Human working")
def eat(self):
print("Human eating lunch")
def sleep(self):
print("Human sleeping")
class RobotWorker(Workable):
def work(self):
print("Robot working")
# RobotWorker only implements what actually applies to it.
Pros:
- No class is forced to implement irrelevant, meaningless methods
- Interfaces stay focused and easier to understand
- Changes to one capability (e.g.,
Sleepable) don't ripple to classes that don't use it
Cons:
- More interfaces to define and track
- Can fragment a genuinely cohesive interface if split too aggressively
My Take:
Python's duck typing makes ISP violations less common than in Java, since you're rarely forced to implement an entire interface just to use one method. But it still bites you with ABC hierarchies and Protocol definitions — keep them narrow.
The key insight: If implementing an interface means writing raise NotImplementedError for half its methods, the interface is too big.
A more modern-Python way to write this: instead of ABC, you can use typing.Protocol for structural typing — a class satisfies Workable just by having a matching work() method, with no explicit inheritance required:
from typing import Protocol
class Workable(Protocol):
def work(self) -> None: ...
class RobotWorker:
def work(self):
print("Robot working")
# RobotWorker satisfies Workable automatically — no "class RobotWorker(Workable)" needed.
This fits ISP even better than ABC in many cases, since nothing is forced onto a class it never explicitly agreed to.
5. Dependency Inversion Principle (DIP)
High-level modules shouldn't depend on low-level modules. Both should depend on abstractions.
This is the principle behind Hexagonal Architecture from the previous article — business logic shouldn't know about specific frameworks or databases.
Bad code (high-level logic tightly coupled to a low-level detail)
class MySQLDatabase:
def save(self, data):
print(f"Saving {data} to MySQL")
class OrderService:
def __init__(self):
self.db = MySQLDatabase() # hardcoded dependency on a concrete class
def place_order(self, order):
self.db.save(order)
# OrderService can NEVER use a different database without editing this class.
# Testing OrderService also means testing against a real (or mocked) MySQLDatabase.
Good code (depend on an abstraction, inject the concrete implementation)
from abc import ABC, abstractmethod
class Database(ABC):
@abstractmethod
def save(self, data):
pass
class MySQLDatabase(Database):
def save(self, data):
print(f"Saving {data} to MySQL")
class PostgresDatabase(Database):
def save(self, data):
print(f"Saving {data} to Postgres")
class InMemoryDatabase(Database):
"""A fake database — perfect for tests, no real DB needed"""
def __init__(self):
self.storage = []
def save(self, data):
self.storage.append(data)
class OrderService:
def __init__(self, db: Database):
self.db = db # depends on the abstraction, not a specific database
def place_order(self, order):
self.db.save(order)
# Production
service = OrderService(PostgresDatabase())
service.place_order("Order #123")
# Testing — no real database needed at all
test_service = OrderService(InMemoryDatabase())
test_service.place_order("Test Order")
Pros:
- Swapping implementations (MySQL → Postgres → in-memory for tests) requires zero changes to business logic
- Massively improves testability — inject fakes instead of hitting real infrastructure
- Decouples the "what" (business rules) from the "how" (specific tools)
Cons:
- Adds an abstraction layer even when you're confident you'll never swap the implementation
- Can lead to "interface for everything" overkill in small codebases
My Take:
DIP is the principle behind dependency injection, and it's why your OrderService should never do from mysql_driver import connect directly. This is the exact idea Hexagonal Architecture builds an entire system-level structure around — DIP is just that idea applied one class at a time.
The key insight: Business logic should define the interface it needs; infrastructure should implement that interface — never the other way around.
Notice where Database is defined: conceptually, it belongs next to OrderService, not next to MySQLDatabase. The high-level module owns the contract; the low-level module just agrees to honor it. That's the "inversion" in Dependency Inversion — without it, you'd expect business logic to depend on infrastructure, but here infrastructure depends on a contract the business logic defines.
Comparison Table
| Principle | One-Line Rule | What It Prevents |
|---|---|---|
| SRP | One reason to change per class | God classes doing five unrelated jobs |
| OCP | Extend without modifying existing code | Editing tested code every time requirements grow |
| LSP | Subtypes must behave like their base type | Subclasses that quietly break the parent's contract |
| ISP | Don't force unused methods on implementers | Bloated interfaces nobody fully implements honestly |
| DIP | Depend on abstractions, not concrete implementations | Business logic hardwired to specific infrastructure |
Key Takeaways
- SRP — If you need "and" to describe a class's job, split it
- OCP — New requirements should mean new code, not edited code
- LSP — A subclass must be safe to use anywhere the parent is expected
- ISP — Small, role-specific interfaces beat one bloated one
- DIP — Business logic depends on abstractions; infrastructure depends on those same abstractions
The Golden Rule of SOLID:
Every design pattern you've learned is SOLID applied to a specific shape of problem. Learn the principles, and you stop needing to memorize the patterns — you start deriving them.
SOLID isn't a checklist to run through on every class. It's a set of smells to notice: giant classes, endless elif chains, subclasses that throw NotImplementedError, fat interfaces, and hardcoded infrastructure. Fix the smell, and you've applied the principle — whether or not you consciously named it.
Design Patterns & Architecture Series
- Creational Patterns — How to create objects properly
- Structural Patterns - Part 1 — Adapter, Bridge, Composite, Decorator
- Structural Patterns - Part 2 — Facade, Flyweight, Proxy
- Behavioral Patterns — How objects communicate
- OOP in Python — The foundations everything else builds on
- SOLID Principles (This Article) — The reasoning behind every pattern above
Want More Deep Dives?
If you enjoyed this article, check out my other production-focused guides:
- Message Brokers in 2026: Kafka, RabbitMQ, NATS — Architecture decisions
- OWASP Top 10 for Developers (2026 Edition) — Security patterns
- Injection Attacks Are Not Dead — Attack patterns and solutions
- Durable Workflow Engines: Temporal vs dbt OS — System patterns
🔗 LinkedIn: https://www.linkedin.com/in/mahdi-shamlou-3b52b8278
📱 Telegram: https://telegram.me/mahdi0shamlou
📸 Instagram: https://www.instagram.com/mahdi0shamlou/
Author: Mahdi Shamlou | مهدی شاملو



Top comments (0)