SOLID Principles
Five core object-oriented design guidelines for maintainable software
Why SOLID Matters
SOLID is an acronym for five design principles that make object-oriented code more maintainable, flexible, and scalable. Coined by Robert C. Martin (Uncle Bob), these principles aren't rules to follow blindly, they're guidelines that help you write code that's easier to understand, test, and modify. Mastering SOLID transforms you from someone who writes code that works to someone who writes code that lasts.
Foundation: Cohesion & Coupling
Before diving into SOLID, understand these fundamental concepts that underpin all the principles:
Cohesion
High cohesion means a class does one thing well. All methods and properties work together toward a single purpose.
Coupling
Loose coupling means classes depend on abstractions, not concrete implementations. Changes in one class don't ripple through the system.
The Five SOLID Principles
Single Responsibility Principle
A class should have one, and only one, reason to change.
Open/Closed Principle
Open for extension, closed for modification.
Liskov Substitution Principle
Subtypes must be substitutable for their base types.
Interface Segregation Principle
No client should depend on methods it doesn't use.
Dependency Inversion Principle
Depend on abstractions, not implementations.
SSingle Responsibility Principle (SRP)
"A class should have one, and only one, reason to change."
Real-World Analogy
Think of a chef in a restaurant. The chef cooks, that's their single responsibility. They don't also take orders, wash dishes, and manage inventory. Each role has one clear purpose, making the restaurant run smoothly. Same with classes!
How to Spot SRP Violations
- Class name includes "and" (UserManagerAndLogger)
- Multiple unrelated methods (save(), sendEmail(), generateReport())
- Changes for different reasons affect the same class
- Class has more than 200-300 lines (rule of thumb)
Violating SRP
# BAD: Multiple responsibilities
class User:
def __init__(self, name, email):
self.name = name
self.email = email
def save_to_database(self):
db.execute("INSERT INTO users...")
def send_welcome_email(self):
smtp.send(self.email, "Welcome!")
def generate_report(self):
return f"Report: {self.name}"Following SRP
# GOOD: Single responsibilities
class User:
def __init__(self, name, email):
self.name = name
self.email = email
class UserRepository:
def save(self, user):
db.execute("INSERT INTO users...")
class EmailService:
def send_welcome(self, user):
smtp.send(user.email, "Welcome!")
class ReportGenerator:
def generate(self, user):
return f"Report: {user.name}"Key Takeaway
High cohesion beats sprawling classes. Ask: "What is this class's job?" If the answer has "and" in it, split it up.
OOpen/Closed Principle (OCP)
"Software entities should be open for extension, but closed for modification."
Real-World Analogy
Think of USB ports. Your laptop has USB ports (interface), and you can plug in new devices (extensions) without modifying the laptop itself. The laptop is "closed for modification" but "open for extension" via the USB standard.
How to Spot OCP Violations
- Long if/else or switch statements checking types
- Adding new features requires modifying existing code
- Fear of breaking things when adding functionality
- No use of interfaces or abstract classes
Violating OCP
# BAD: Must modify for new types
class PaymentProcessor:
def process(self, amount, method):
if method == "credit_card":
print(f"CC: {amount}")
elif method == "paypal":
print(f"PayPal: {amount}")
# Adding ApplePay requires
# modifying this method!Following OCP
# GOOD: Extend via new classes
from abc import ABC, abstractmethod
class PaymentMethod(ABC):
@abstractmethod
def process(self, amount): pass
class CreditCard(PaymentMethod):
def process(self, amount):
print(f"CC: {amount}")
class ApplePay(PaymentMethod):
def process(self, amount):
print(f"ApplePay: {amount}")
# Add new methods without changes!Key Takeaway
Use abstraction (interfaces, abstract classes) to allow new behavior without touching existing code. Polymorphism is your friend.
LLiskov Substitution Principle (LSP)
"Objects of a superclass should be replaceable with objects of subclasses without breaking the application."
Real-World Analogy
If you ask for a "bird," you expect it to fly. If someone gives you a penguin (a bird subclass), and your code crashes because penguins can't fly, the substitution principle is violated. Subtypes must honor their parent's contracts.
How to Spot LSP Violations
- Subclass throws unexpected exceptions
- Subclass has stricter preconditions than parent
- Type checking before using objects (instanceof checks)
- Empty or NotImplemented method overrides
Violating LSP
# BAD: Square breaks Rectangle contract
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 = w
self.height = w # Breaks!
def set_height(self, h):
self.width = h
self.height = h
# This fails with Square!
rect.set_width(5)
rect.set_height(10)
assert rect.area() == 50Following LSP
# GOOD: No inheritance, no violation
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self): pass
class Rectangle(Shape):
def __init__(self, w, h):
self._width = w
self._height = h
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
# Both work correctly
shapes = [Rectangle(5,10), Square(7)]Key Takeaway
Subclasses must strengthen, not weaken, the parent's behavior. If substitution breaks things, rethink your inheritance hierarchy.
IInterface Segregation Principle (ISP)
"No client should be forced to depend on methods it does not use."
Real-World Analogy
Imagine a Swiss Army knife vs. specialized tools. A robot worker shouldn't be forced to implement "eat()" and "sleep()" methods just because the Worker interface demands it. Give it only the tools (methods) it actually needs, like a dedicated screwdriver instead of a full toolkit.
How to Spot ISP Violations
- Interfaces with many unrelated methods
- Empty or NotImplementedError method stubs
- Classes implementing interfaces with unused methods
- "Fat" interfaces that do too much
Violating ISP
# BAD: Fat interface
from abc import ABC, abstractmethod
class Worker(ABC):
@abstractmethod
def work(self): pass
@abstractmethod
def eat(self): pass
@abstractmethod
def sleep(self): pass
class RobotWorker(Worker):
def work(self):
print("Working")
def eat(self):
raise NotImplementedError
def sleep(self):
raise NotImplementedErrorFollowing ISP
# GOOD: Segregated 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("Working")
def eat(self): print("Eating")
def sleep(self): print("Sleeping")
class RobotWorker(Workable):
def work(self): print("Working")Key Takeaway
Many small, focused interfaces beat one large, general-purpose interface. Clients should only know about methods they use.
DDependency Inversion Principle (DIP)
"High-level modules should not depend on low-level modules. Both should depend on abstractions."
Real-World Analogy
Your TV remote (high-level) doesn't care if your TV is Sony, Samsung, or LG (low-level). Both depend on the infrared signal standard (abstraction). You can swap TVs without changing the remote. That's dependency inversion!
How to Spot DIP Violations
- Directly instantiating dependencies inside classes
- Hard-coded references to concrete classes
- Difficult to unit test (can't mock dependencies)
- Tight coupling to specific implementations
Violating DIP
# BAD: Depends on concrete class
class MySQLDatabase:
def save(self, data):
print("Saving to MySQL")
class UserService:
def __init__(self):
self.db = MySQLDatabase() # Tight!
def create_user(self, data):
self.db.save(data)
# Can't easily test or switch DBFollowing DIP
# GOOD: Depends on abstraction
from abc import ABC, abstractmethod
class Database(ABC):
@abstractmethod
def save(self, data): pass
class MySQLDatabase(Database):
def save(self, data):
print("Saving to MySQL")
class UserService:
def __init__(self, db: Database):
self.db = db # Loose!
def create_user(self, data):
self.db.save(data)
# Easy to test and swap
service = UserService(MySQLDatabase())Key Takeaway
Inject dependencies through constructors or setters. Depend on interfaces, not concrete implementations. This enables loose coupling and testability.
When to Apply SOLID
Apply SOLID When
- Building systems that will grow and evolve
- Multiple developers working on the codebase
- Frequent changes and new features expected
- Testing and maintainability are priorities
- Long-term production systems
Simpler Approaches When
- Writing scripts and one-off utilities
- Building prototypes and MVPs quickly
- Code is simple, stable, and unlikely to change
- Small solo projects with limited scope
- Performance-critical inner loops
Pragmatic Advice
Don't over-engineer. Start simple, then refactor toward SOLID as complexity grows. These are principles, not laws. Use judgment, sometimes breaking a principle leads to better code.
How SOLID Principles Work Together
SOLID principles reinforce each other:
- SRP + ISP: Single responsibility naturally leads to smaller, focused interfaces
- OCP + DIP: Open/Closed depends on abstractions (Dependency Inversion)
- LSP + OCP: Proper substitution enables safe extension
- All together: Result in high cohesion, loose coupling, and flexible design
Key Takeaways
- S - Single Responsibility: One class, one job
- O - Open/Closed: Extend via abstraction, not modification
- L - Liskov Substitution: Subtypes must honor contracts
- I - Interface Segregation: Small, focused interfaces
- D - Dependency Inversion: Depend on abstractions
- Together: High cohesion + Loose coupling = Maintainable code