SOLID Principles: The Foundation of OOP Design
Writing code that works is easy. Writing code that can survive changing requirements, feature additions, and team turnover without turning into a tangled, unmaintainable mess—that is the hard part of software engineering.
In the world of Object-Oriented Programming (OOP), the biggest enemy is rigidity. When a system is rigid, a simple change in one place causes cascading breaks in seemingly unrelated places.
Enter SOLID. Coined by Robert C. Martin (Uncle Bob), these five principles are the bedrock of clean, maintainable, and scalable OOP design. They aren't rigid rules, but guiding lights to help you design software that is easy to extend and hard to break.
Here is a practical breakdown of the SOLID principles, complete with the "before" and "after" code that shows why they matter.
1. Single Responsibility Principle (SRP)
The Definition: A class should have one, and only one, reason to change.
The keyword here isn't "single purpose"—it's "reason to change." If a class handles UI rendering, business logic, and database access, it has three reasons to change. A change in the database schema could break your UI.
❌ The Violation: The "God Object"
class Employee:
def calculate_pay(self):
# Complex payroll logic
pass
def save_to_database(self):
# DB connection and SQL queries
pass
def generate_employment_report(self):
# PDF generation logic
pass
If HR changes the payroll formula, the DBA migrates the database, and the design team updates the report template, they are all editing the same class. Merge conflicts galore.
✅ The Fix: Separate the Concerns
class PayrollCalculator:
def calculate_pay(self, employee): ...
class EmployeeRepository:
def save(self, employee): ...
class EmploymentReportGenerator:
def generate(self, employee): ...
2. Open/Closed Principle (OCP)
The Definition: Software entities should be open for extension, but closed for modification.
You should be able to add new functionality to a system without modifying existing, tested code. If you find yourself opening a switch statement to add a new case every time a new type is introduced, you are violating OCP.
❌ The Violation: The Brittle Switch Statement
class DiscountCalculator:
def calculate(self, customer_type, price):
if customer_type == "regular":
return price * 0.9
elif customer_type == "vip":
return price * 0.8
# Every new customer type requires modifying this class!
✅ The Fix: Polymorphism / Strategy Pattern
from abc import ABC, abstractmethod
class DiscountStrategy(ABC):
@abstractmethod
def apply(self, price: float) -> float: pass
class RegularDiscount(DiscountStrategy):
def apply(self, price): return price * 0.9
class VIPDiscount(DiscountStrategy):
def apply(self, price): return price * 0.8
# Now, adding a "PremiumDiscount" means writing a NEW class.
# DiscountCalculator never changes.
class DiscountCalculator:
def calculate(self, strategy: DiscountStrategy, price: float) -> float:
return strategy.apply(price)
graph TD
Calculator[DiscountCalculator] -->|Depends on| Interface[DiscountStrategy]
Interface --> Impl1[RegularDiscount]
Interface --> Impl2[VIPDiscount]
Interface --> Impl3[New PremiumDiscount: No existing code changed!]
style Calculator fill:#bbf,stroke:#333
style Interface fill:#ff9,stroke:#333
3. Liskov Substitution Principle (LSP)
The Definition: Subtypes must be substitutable for their base types without altering the correctness of the program.
If you replace a parent class with a child class, the program should still behave as expected. If a child class throws an exception or silently ignores a method it inherited, it violates LSP.
❌ The Violation: The Square/Rectangle Problem
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 # Forces height to match!
def set_height(self, h):
self.height = h
self.width = h # Forces width to match!
# Client code expects a Rectangle, but gets a Square
def resize_shape(rect: Rectangle):
rect.set_width(5)
rect.set_height(10)
assert rect.width == 5 # BOOM! Fails if rect is a Square (width becomes 10)
✅ The Fix: Redesign the Hierarchy A Square isn't a Rectangle that behaves differently; they are both just Shapes.
class Shape(ABC):
@abstractmethod
def area(self) -> float: pass
class Rectangle(Shape):
def area(self): return self.width * self.height
class Square(Shape):
def area(self): return self.side ** 2
4. Interface Segregation Principle (ISP)
The Definition: Clients should not be forced to depend on interfaces they do not use.
Fat interfaces are a nightmare. If an interface has 10 methods, but a specific implementing class only cares about 2, it is forced to implement stub methods or throw NotImplementedException.
❌ The Violation: The Monolithic Interface
from abc import ABC, abstractmethod
class Machine(ABC):
@abstractmethod
def print(self, doc): pass
@abstractmethod
def scan(self, doc): pass
@abstractmethod
def fax(self, doc): pass
class OldPrinter(Machine):
def print(self, doc): # Actually prints
pass
def scan(self, doc): raise NotImplementedError("I can't scan!")
def fax(self, doc): raise NotImplementedError("I can't fax!")
✅ The Fix: Split into Focused Interfaces
class Printer(ABC):
@abstractmethod
def print(self, doc): pass
class Scanner(ABC):
@abstractmethod
def scan(self, doc): pass
class OldPrinter(Printer):
def print(self, doc): # Clean!
pass
class MultiFunctionDevice(Printer, Scanner):
def print(self, doc): ...
def scan(self, doc): ...
5. Dependency Inversion Principle (DIP)
The Definition: 1. High-level modules should not depend on low-level modules. Both should depend on abstractions. 2. Abstractions should not depend on details. Details should depend on abstractions.
The business logic (high-level) should not care about the database or UI (low-level). It should define an interface it needs, and the low-level module implements that interface. This is the foundation of Dependency Injection.
❌ The Violation: Hardcoded Dependency
class MySQLDatabase:
def insert(self, data): ...
class OrderService:
def __init__(self):
# Tightly coupled to MySQL! What if we want Postgres for testing?
self.db = MySQLDatabase()
def create_order(self, order):
self.db.insert(order)
✅ The Fix: Depend on Abstractions
from abc import ABC, abstractmethod
class DatabaseInterface(ABC):
@abstractmethod
def insert(self, data): pass
class MySQLDatabase(DatabaseInterface):
def insert(self, data): ...
class PostgresDatabase(DatabaseInterface):
def insert(self, data): ...
class OrderService:
# Dependency is injected via the constructor
def __init__(self, db: DatabaseInterface):
self.db = db
def create_order(self, order):
self.db.insert(order)
# Now we can easily pass a Postgres DB, or a Mock DB for unit testing!
graph LR
subgraph "Without DIP (Tight Coupling)"
A1[OrderService] -->|Directly creates| B1[MySQLDatabase]
end
subgraph "With DIP (Loose Coupling)"
A2[OrderService] -->|Depends on| C[DatabaseInterface]
D1[MySQLDatabase] -->|Implements| C
D2[PostgresDatabase] -->|Implements| C
end
style A1 fill:#f96,stroke:#333
style A2 fill:#4CAF50,color:#fff
style C fill:#ff9,stroke:#333
Conclusion: SOLID is a Compass, Not a Lawbook
If you try to apply all five SOLID principles to every single class you write on day one of a project, you will suffer from analysis paralysis and over-engineering.
SOLID is not a starting point; it is a target. Start with the simplest code that works. When you feel the pain—when adding a feature requires touching 10 files, or when a mock object forces you to implement empty methods—that is when you reach for SOLID to refactor your way out of the corner.
- SRP keeps your classes focused and easy to merge.
- OCP protects your existing code from regressions.
- LSP ensures your polymorphism actually works.
- ISP prevents your interfaces from becoming bloated contracts.
- DIP decouples your system so you can swap databases, frameworks, and test in peace.
Master these principles, and you will write code that not only works today, but welcomes the changes of tomorrow.