Table of Contents
- 1. Single Responsibility Principle (SRP)
- 2. Open/Closed Principle (OCP)
- 3. Liskov Substitution Principle (LSP)
- 4. Interface Segregation Principle (ISP)
- 5. Dependency Inversion Principle (DIP)
- Conclusion
- References
1. Single Responsibility Principle (SRP)
What is SRP?
“A class should have only one reason to change.”
In other words, a class should focus on one job. If a class handles multiple responsibilities, changes to one responsibility risk breaking the others. This leads to fragile, hard-to-maintain code.
Why It Matters
- Reduced Complexity: A class with one job is easier to understand, test, and debug.
- Improved Reusability: Small, focused classes can be reused across the codebase.
- Lower Risk of Side Effects: Changes to one responsibility won’t accidentally break unrelated functionality.
Bad Example: A Bloated User Class
Suppose we have a User class that manages user data and sends welcome emails. This violates SRP because it has two reasons to change:
- If user data storage logic (e.g., adding a
middle_namefield) changes. - If email-sending logic (e.g., switching from SMTP to an API) changes.
# Bad: Violation of SRP
class User:
def __init__(self, name: str, email: str):
self.name = name
self.email = email
def save_to_database(self) -> None:
"""Save user data to a database."""
print(f"Saving {self.name} to database...")
def send_welcome_email(self) -> None:
"""Send a welcome email to the user."""
print(f"Sending welcome email to {self.email}...")
# Imagine complex SMTP logic here
Problem: The User class is responsible for both data management and email delivery. If the email service requires authentication (e.g., adding an API key), we’d have to modify User—even though the change is unrelated to user data.
Refactored Example: Separating Concerns
Split the User class into two classes, each with a single responsibility:
User: Manages user data (e.g., storage, retrieval).EmailService: Handles email delivery (e.g., welcome emails, notifications).
# Good: Follows SRP
class User:
def __init__(self, name: str, email: str):
self.name = name
self.email = email
def save_to_database(self) -> None:
"""Save user data to a database."""
print(f"Saving {self.name} to database...")
class EmailService:
@staticmethod
def send_welcome_email(user: User) -> None:
"""Send a welcome email to a user."""
print(f"Sending welcome email to {user.email}...")
# SMTP/API logic is encapsulated here
# Usage
user = User("Alice", "[email protected]")
user.save_to_database() # User handles data
EmailService.send_welcome_email(user) # EmailService handles emails
Why It Works:
Usernow only changes when user data logic changes (e.g., adding aphonefield).EmailServiceonly changes when email logic changes (e.g., switching to SendGrid).- Testing is easier: You can test
EmailServiceindependently without a database.
Key Takeaways
- A class should have one job (one reason to change).
- Split bloated classes into smaller, focused ones.
- Ask: “What happens if X changes? Will this class need modification?” If the answer is “yes” for multiple Xs, SRP is violated.
2. Open/Closed Principle (OCP)
What is OCP?
“Software entities (classes, modules, functions) should be open for extension, but closed for modification.”
In short: You should be able to add new functionality by extending existing code, not by modifying it. This prevents breaking existing features when adding new ones.
Why It Matters
- Stability: Existing, tested code remains untouched when adding features.
- Scalability: New functionality is added via new classes/modules, keeping the codebase organized.
- Reduced Risk: Modifying working code introduces bugs; extending avoids this.
Bad Example: Hardcoded Shape Logic
Suppose you have a Shape class that calculates the area of different shapes using if-elif checks. Adding a new shape (e.g., Triangle) requires modifying the Shape class itself.
# Bad: Violation of OCP
class Shape:
def __init__(self, shape_type: str, **kwargs):
self.shape_type = shape_type
self.kwargs = kwargs # e.g., radius for circle, side for square
def calculate_area(self) -> float:
if self.shape_type == "circle":
return 3.14 * self.kwargs["radius"] ** 2
elif self.shape_type == "square":
return self.kwargs["side"] ** 2
else:
raise ValueError("Unsupported shape")
Problem: Adding a Triangle requires editing calculate_area (modification), risking bugs in existing circle/square logic. The Shape class is not closed for modification.
Refactored Example: Extending with Abstraction
Use abstraction (via Python’s abc module) to define a base Shape class with an abstract calculate_area method. Then, create subclasses for each shape (e.g., Circle, Square) that extend Shape and implement their own area logic.
from abc import ABC, abstractmethod
# Good: Follows OCP
class Shape(ABC):
@abstractmethod
def calculate_area(self) -> float:
"""Abstract method to calculate area (must be implemented by subclasses)."""
pass
class Circle(Shape):
def __init__(self, radius: float):
self.radius = radius
def calculate_area(self) -> float:
return 3.14 * self.radius ** 2
class Square(Shape):
def __init__(self, side: float):
self.side = side
def calculate_area(self) -> float:
return self.side ** 2
# Now, add a Triangle without modifying existing code!
class Triangle(Shape):
def __init__(self, base: float, height: float):
self.base = base
self.height = height
def calculate_area(self) -> float:
return 0.5 * self.base * self.height
Why It Works:
- The base
Shapeclass is closed for modification (we never edit it). - New shapes (e.g.,
Triangle,Hexagon) are added by extendingShapewith new subclasses (open for extension). - Existing circle/square logic remains untouched and bug-free.
Key Takeaways
- Use abstraction (abstract base classes in Python) to define contracts.
- Extend functionality via subclasses or composition, not by modifying existing code.
- Avoid hardcoded
if-elifchecks for different cases—these are red flags for OCP violations.
3. Liskov Substitution Principle (LSP)
What is LSP?
“Subtypes must be substitutable for their base types.”
In plain English: If you have a function that works with a base class, it should work with any subclass of that base class without breaking. Subclasses should behave consistently with their parents.
Why It Matters
- Polymorphism Safety: Ensures that code using polymorphism (treating subclasses as base classes) works as expected.
- Predictability: Subclasses don’t surprise developers with unexpected behavior.
- Reliability: Functions/classes using base types won’t fail when given subclasses.
Bad Example: The Penguin Problem
A classic violation: A Penguin subclass of Bird that cannot fly(), even though the base Bird class defines fly().
# Bad: Violation of LSP
class Bird:
def fly(self) -> None:
print("Flying...")
class Penguin(Bird):
def fly(self) -> None:
# Penguins can't fly!
raise NotImplementedError("Penguins can't fly")
# Function that expects a Bird and calls fly()
def make_bird_fly(bird: Bird) -> None:
bird.fly() # Works for Sparrow, but crashes for Penguin!
# Usage
sparrow = Bird()
make_bird_fly(sparrow) # Works: "Flying..."
penguin = Penguin()
make_bird_fly(penguin) # Error: NotImplementedError
Problem: Penguin is a Bird, but substituting it for Bird in make_bird_fly breaks the function. LSP is violated because the subclass (Penguin) does not behave like the base class (Bird).
Refactored Example: Correct Inheritance Hierarchy
Fix this by redefining the inheritance hierarchy. Separate flying and non-flying birds into distinct classes.
# Good: Follows LSP
from abc import ABC, abstractmethod
class Bird(ABC):
@abstractmethod
def move(self) -> None:
"""All birds can move; implementation varies."""
class FlyingBird(Bird):
def move(self) -> None:
print("Flying...")
class NonFlyingBird(Bird):
def move(self) -> None:
print("Walking or swimming...")
class Sparrow(FlyingBird):
pass # Inherits move() from FlyingBird
class Penguin(NonFlyingBird):
pass # Inherits move() from NonFlyingBird
# Now, functions depend on the appropriate base class
def make_flying_bird_fly(bird: FlyingBird) -> None:
bird.move() # Only accepts FlyingBirds; guaranteed to fly
def make_bird_move(bird: Bird) -> None:
bird.move() # Works for all Birds (Flying or NonFlying)
# Usage
sparrow = Sparrow()
make_flying_bird_fly(sparrow) # Works: "Flying..."
penguin = Penguin()
make_bird_move(penguin) # Works: "Walking or swimming..."
Why It Works:
FlyingBirdandNonFlyingBirdare subclasses ofBird, each with consistentmove()behavior.make_flying_bird_flynow only acceptsFlyingBirdsubclasses (e.g.,Sparrow), ensuringmove()works.Penguinis aNonFlyingBird, so substituting it intomake_bird_move(which expects aBird) works perfectly.
Key Takeaways
- Subclasses must honor the contract of their base class. If a base class method promises to do X, subclasses must either do X or not inherit from the base.
- Avoid overriding methods to throw errors or return unexpected values.
- Ask: “Can I replace the base class with this subclass anywhere in the code?” If not, rethink the inheritance.
4. Interface Segregation Principle (ISP)
What is ISP?
“Clients should not be forced to depend on interfaces they do not use.”
In Python (which lacks formal interfaces), this translates to: Keep abstract base classes (ABCs) small and focused. Clients (classes implementing the ABC) shouldn’t have to implement methods they don’t need.
Why It Matters
- Minimalism: Clients only implement methods relevant to their role.
- Reduced Bloat: Avoids “fat” interfaces with dozens of methods, many unused.
- Maintainability: Changes to an interface only affect clients that actually use it.
Bad Example: The Overloaded Worker Interface
An abstract Worker class with methods for work() and eat(), but a Robot worker doesn’t need to eat().
# Bad: Violation of ISP
from abc import ABC, abstractmethod
class Worker(ABC):
@abstractmethod
def work(self) -> None:
pass
@abstractmethod
def eat(self) -> None:
pass
class HumanWorker(Worker):
def work(self) -> None:
print("Working...")
def eat(self) -> None:
print("Eating lunch...")
class RobotWorker(Worker):
def work(self) -> None:
print("Working (no rest!)...")
def eat(self) -> None:
# Robots don't eat! Forcing implementation is bad.
raise NotImplementedError("Robots don't eat")
Problem: RobotWorker is forced to implement eat(), even though it doesn’t need it. This bloats the class and leads to misleading NotImplementedError hacks.
Refactored Example: Segregated Interfaces
Split the Worker interface into smaller, focused ABCs: Workable (for working) and Eatable (for eating). Clients implement only the interfaces they need.
# Good: Follows ISP
from abc import ABC, abstractmethod
class Workable(ABC):
@abstractmethod
def work(self) -> None:
pass
class Eatable(ABC):
@abstractmethod
def eat(self) -> None:
pass
# HumanWorker needs both work and eat
class HumanWorker(Workable, Eatable):
def work(self) -> None:
print("Working...")
def eat(self) -> None:
print("Eating lunch...")
# RobotWorker only needs work
class RobotWorker(Workable):
def work(self) -> None:
print("Working (no rest!)...")
# Usage
def manage_worker(worker: Workable) -> None:
worker.work() # Works for both Human and Robot
def feed_worker(worker: Eatable) -> None:
worker.eat() # Only called for Eatable workers (Humans)
human = HumanWorker()
manage_worker(human) # "Working..."
feed_worker(human) # "Eating lunch..."
robot = RobotWorker()
manage_worker(robot) # "Working (no rest!)..."
# feed_worker(robot) # Error: RobotWorker is not Eatable (good!)
Why It Works:
- Interfaces are minimal:
Workablehas onlywork(),Eatablehas onlyeat(). - Clients implement only the interfaces they need:
RobotWorkerusesWorkable;HumanWorkeruses both. - No more
NotImplementedErrorhacks—unused methods are avoided entirely.
Key Takeaways
- Keep interfaces/ABCs small and focused (the “I” in ISP is for “Interface”).
- Avoid “kitchen sink” interfaces with methods irrelevant to some clients.
- Use multiple inheritance (in Python) to combine small interfaces when needed.
5. Dependency Inversion Principle (DIP)
What is DIP?
“Depend on abstractions, not concretions.”
Two key rules:
- High-level modules should not depend on low-level modules. Both should depend on abstractions.
- Abstractions should not depend on details. Details should depend on abstractions.
Why It Matters
- Flexibility: Swap low-level implementations without changing high-level code.
- Testability: Mock abstractions in unit tests (e.g., mock a database logger with a test logger).
- Decoupling: High-level modules aren’t tied to specific low-level tools/libraries.
Bad Example: Tight Coupling to a Concrete Logger
A Report class (high-level) that directly uses FileLogger (a concrete low-level module) to log errors. If you want to switch to DatabaseLogger, you must modify Report.
# Bad: Violation of DIP
class FileLogger:
def log(self, message: str) -> None:
with open("report.log", "a") as f:
f.write(f"Log: {message}\n")
# High-level module depends directly on low-level FileLogger
class Report:
def __init__(self):
self.logger = FileLogger() # Tight coupling!
def generate(self) -> None:
try:
print("Generating report...")
# ... report logic ...
except Exception as e:
self.logger.log(f"Report failed: {e}") # Depends on FileLogger
Problem: Report (high-level) depends on FileLogger (concrete low-level). To log to a database instead, you’d have to edit Report’s __init__ method, violating OCP and creating tight coupling.
Refactored Example: Depend on Abstractions
Define a Logger abstraction (ABC) that both high-level (Report) and low-level (FileLogger, DatabaseLogger) modules depend on. Report now accepts a Logger via dependency injection.
# Good: Follows DIP
from abc import ABC, abstractmethod
# Abstraction: Logger interface
class Logger(ABC):
@abstractmethod
def log(self, message: str) -> None:
pass
# Low-level modules depend on the Logger abstraction
class FileLogger(Logger):
def log(self, message: str) -> None:
with open("report.log", "a") as f:
f.write(f"Log: {message}\n")
class DatabaseLogger(Logger):
def log(self, message: str) -> None:
print(f"Logging to DB: {message}") # Simulated DB log
# High-level module depends on the Logger abstraction (not concretions)
class Report:
def __init__(self, logger: Logger): # Dependency injection
self.logger = logger # Works with ANY Logger implementation
def generate(self) -> None:
try:
print("Generating report...")
# ... report logic ...
except Exception as e:
self.logger.log(f"Report failed: {e}") # Depends on Logger abstraction
# Usage: Inject different loggers without changing Report
file_report = Report(FileLogger())
file_report.generate() # Logs to file
db_report = Report(DatabaseLogger())
db_report.generate() # Logs to DB (no changes to Report!)
Why It Works:
Report(high-level) depends on theLoggerabstraction, not concrete loggers.- Low-level loggers (
FileLogger,DatabaseLogger) depend onLogger(they implement it). - Switching loggers is trivial: Just inject a different
Loggersubclass intoReport.
Key Takeaways
- Depend on abstractions (ABCs) instead of concrete classes.
- Use dependency injection to pass abstractions into high-level modules.
- Avoid hardcoding low-level tools (e.g.,
FileLogger,MySQLClient) in high-level code.
Conclusion
SOLID isn’t just a set of rules—it’s a mindset for writing code that’s maintainable, scalable, and resilient to change. By applying these principles in Python OOP:
- SRP keeps classes focused and easy to debug.
- OCP lets you add features without breaking existing code.
- LSP ensures polymorphism works safely and predictably.
- ISP prevents clients from being burdened with unused methods.
- DIP decouples high-level logic from low-level tools, making code flexible.
Remember: SOLID isn’t about perfection. Start small—refactor one class at a time, and over time, your codebase will become cleaner, more modular, and a joy to work with.