1. Introduction to SOLID Principles
When software is small, almost any design can appear to work, even one that ignores SOLID principles. You can put business logic, database operations, validation, and external API calls into a few classes and still get the application running.
The problem appears when the software starts changing.
A new payment method is introduced. A database is replaced. A new notification channel is added. A business rule changes. Suddenly, changing one part of the system requires modifying several unrelated parts, and a seemingly harmless change can break existing functionality.
This is where SOLID principles become useful.
1.1 What Are SOLID Principles?
SOLID is an acronym representing five principles of object-oriented software design:

These principles were popularized by Robert C. Martin (Uncle Bob) and are designed to help developers build software that is easier to maintain, extend, test, and modify.
However, there's an important point:
SOLID is not a collection of rules that you must blindly follow. It is a set of design principles that help you manage change and dependencies in software.
1.2 Why Were SOLID Principles Introduced?
The biggest problem SOLID addresses is code that becomes difficult to change as the system grows.
Consider a simple e-commerce application:
OrderService
├── Create Order
├── Calculate Price
├── Process Payment
├── Save Order
├── Send Email
└── Generate InvoiceInitially, this might look convenient. But imagine the requirements change:
Add UPI payments
Add PayPal
Replace MySQL with PostgreSQL
Add SMS notifications
Change invoice generation
Add another tax calculation rule
Now OrderService keeps growing.
OrderService
├── Order logic
├── Payment logic
├── Database logic
├── Email logic
├── SMS logic
├── Invoice logic
└── Tax logicThis creates several problems:
Changes become risky.
Testing becomes difficult.
Components become tightly coupled.
Reusing functionality becomes harder.
Developers become afraid to modify existing code.
A change in one feature can unintentionally affect another.
SOLID helps us separate responsibilities and dependencies so that changes remain localized.
1.3 The Problems SOLID Helps Solve
SOLID primarily helps address several common design problems.
Tight Coupling: One class directly depends on many concrete implementations.
OrderService
↓
MySQL
↓
EmailService
↓
StripeChanging one dependency may require changing OrderService.
SOLID encourages designs where components depend on stable abstractions.
Low Cohesion: A class contains unrelated responsibilities.
UserService
├── Register user
├── Send email
├── Generate PDF
├── Resize image
├── Save database record
└── Generate reportThe class has too many reasons to change. This is primarily related to SRP.
Difficult Extension: Suppose your payment system initially supports:
UPI
CardThen you add:
PayPal
Stripe
RazorpayIf you keep modifying a giant if/else block every time, the system becomes increasingly fragile.
OCP encourages designs where new behavior can be added with minimal modification to existing code.
Difficult Testing:
Consider
OrderService
↓
MySQLTesting OrderService now requires a real database or complicated setup.
With proper abstractions:
OrderService
↓
OrderRepository
↑
|
┌─────┴─────┐
↓ ↓
MySQL MockYou can test the business logic using a mock implementation. This is one of the practical benefits of DIP.
1.4 Why SOLID Principles Matter
Maintainability
Readability
Extensibility
Testability
Reduced coupling
Better change management
Easier collaboration in teams
2. Single Responsibility Principle — SRP
When developers first learn SOLID, the Single Responsibility Principle (SRP) often sounds almost trivial:
A class should have one reason to change.
But in practice, SRP is one of the most misunderstood principles in software design. Developers often interpret it as:
“A class should do only one thing.”
That interpretation is too simplistic.
A class can have multiple methods and still follow SRP. Conversely, a class with only a few methods can violate it.
The real question is:
Are the responsibilities inside this class likely to change independently?
Let's understand what that means.

2.1 Definition
The Single Responsibility Principle states:
A class should have one, and only one, reason to change.
The important part is “reason to change.” SRP isn't about counting methods. Consider this Python class:
OrderService
│
┌─────────────────┼──────────────────┐
↓ ↓ ↓
Validation Pricing Payment
↓ ↓ ↓
Database Notification InvoiceAt first, this might look reasonable because everything appears to be related to a user. But there are actually several independent concerns:
OrderService
│
├── OrderValidator
├── PricingService
├── OrderRepository
├── PaymentService
├── NotificationService
└── EmailServiceNow imagine:
The user creation rules change.
The database is migrated.
The email provider is changed.
All three changes require modifying the same class. That's a warning sign. SRP is therefore not about how many methods a class has. It is about how many independent reasons exist for that class to change.
2.2 Understanding Responsibility
Before applying SRP, we need to understand what a responsibility actually is. A responsibility is an area of concern that a piece of code owns. Consider an e-commerce application.
An order might involve:
Create order
Calculate price
Save order
Process payment
Send confirmation
Generate invoice
It is tempting to put everything into one class:
class OrderService:
def create_order(self, order):
pass
def calculate_price(self, order):
pass
def save_order(self, order):
pass
def process_payment(self, order):
pass
def send_confirmation(self, order):
pass
def generate_invoice(self, order):
pass
The problem isn't that this class has six methods. The problem is that those methods represent different areas of concern.
For example:
Pricing
↓
Business rules
Persistence
↓
Database
Payment
↓
Payment provider
Notification
↓
Email / SMS
Invoice
↓
Document generation
These concerns can evolve independently. That's what makes them separate responsibilities. A useful question when identifying responsibilities is:
“What kind of change would make me modify this code?”
If you can answer with several unrelated things, you may have multiple responsibilities mixed together.
2.3 One Reason to Change
This is the heart of SRP.
Consider:
class Employee:
def calculate_salary(self):
pass
def save_to_database(self):
pass
def generate_report(self):
passLet's think about why this class could change.
Reason 1: Salary rules change
Suppose the company introduces a new bonus calculation.
We need to modify:
calculate_salary()Reason 2: Database changes
Suppose the application moves from one database to another.
We need to modify:
save_to_database()Reason 3: Reporting requirements change
Suppose management wants a completely different report format.
We need to modify:
generate_report()So we have:
Employee
│
├── Salary rules
├── Database rules
└── Reporting rules
Three different reasons to change. That is the problem SRP is trying to address. A better design could separate these concerns:
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
Salary calculation:
class SalaryCalculator:
def calculate(self, employee):
pass
Database persistence:
class EmployeeRepository:
def save(self, employee):
pass
Reporting:
class EmployeeReport:
def generate(self, employee):
pass
Now the responsibilities are clearer:
Salary rules change
↓
SalaryCalculator
Database changes
↓
EmployeeRepository
Report changes
↓
EmployeeReportThe changes are isolated.
2.4 SRP Violation — Bad Design
Let's look at a more realistic example. Imagine we're building a food delivery application. We start with:
class OrderService:
def place_order(self, order):
# Validate order
pass
# Calculate price
pass
# Save order
pass
# Process payment
pass
# Notify restaurant
pass
# Send confirmation email
pass
# Generate invoice
pass
Initially, this looks convenient. Everything related to placing an order is in one place. But over time, the class becomes a change hotspot. Imagine the following changes.
Payment provider changes
Payment provider A
↓
Payment provider BOrderService changes.
Database changes
Database A
↓
Database BOrderService changes.
Notification system changes
REST API
↓
Message QueueOrderService changes.
Invoice requirements change
OrderService changes again.
Pricing rules change
OrderService changes again.
The problem can be visualized as:
OrderService
│
┌────────────────┼────────────────┐
↓ ↓ ↓
Pricing Payment Database
│ │ │
↓ ↓ ↓
Business rules Provider API DB schemaDifferent parts of the system are changing for different reasons, but they're all coupled to the same class.
This is a classic SRP violation.
2.5 Applying SRP — Refactoring
Now let's refactor the previous design.
Instead of making OrderService responsible for everything, we can separate the concerns.
Validation
class OrderValidator:
def validate(self, order):
pass
Pricing
class PricingService:
def calculate(self, order):
pass
Persistence
class OrderRepository:
def save(self, order):
pass
Payment
class PaymentService:
def charge(self, order):
passNotification
class NotificationService:
def notify_restaurant(self, order):
passclass EmailService:
def send_confirmation(self, order):
passNow our OrderService can focus on coordinating the workflow:
class OrderService:
def place_order(self, order):
self.validator.validate(order)
price = self.pricing.calculate(order)
self.repository.save(order)
self.payment.charge(order)
self.notification.notify_restaurant(order)
self.email.send_confirmation(order)
Notice something important.
OrderService still interacts with multiple components.
That doesn't necessarily violate SRP. Its responsibility can be:
Orchestrating the order placement workflow.
That's one cohesive responsibility. This is why SRP does not mean:
One class
↓
One method
Instead:
One class
↓
One cohesive responsibility
↓
One primary reason to change
2.6 SRP and Cohesion
SRP is closely connected to another important concept:
Cohesion: Cohesion describes how closely related the responsibilities inside a module are.
Consider:
class ShoppingCart:
def add_item(self, item):
pass
def remove_item(self, item):
pass
def update_quantity(self, item, quantity):
pass
def calculate_total(self):
pass
These methods are strongly related. They all deal with the state and behavior of a shopping cart. That's high cohesion.
Compare that with:
class Utils:
def calculate_salary(self):
pass
def send_email(self):
pass
def resize_image(self):
pass
def generate_invoice(self):
pass
def parse_csv(self):
pass
These operations have almost nothing to do with one another. That's low cohesion. A useful way to visualize it:
HIGH COHESION
┌──────────────────────┐
│ ShoppingCart │
│ │
│ add_item() │
│ remove_item() │
│ update_quantity() │
│ calculate_total() │
└──────────────────────┘versus:
LOW COHESION
┌──────────────────────┐
│ Utils │
│ │
│ calculate_salary() │
│ send_email() │
│ resize_image() │
│ parse_csv() │
└──────────────────────┘SRP encourages us to keep related behavior together while separating unrelated behavior. So a good mental model is:
SRP helps us create highly cohesive modules.
2.7 SRP in System Design
SRP isn't only applicable to classes.
The same principle can be applied at different levels of a system.
Class level
Order
PaymentService
OrderRepository
EmailService
Module level
orders/
payments/
notifications/
inventory/
Service level
In a larger system, we might have:
┌──────────────────────┐
│ Order Service │
│ Order lifecycle │
└──────────────────────┘
┌──────────────────────┐
│ Payment Service │
│ Payments & refunds │
└──────────────────────┘
┌──────────────────────┐
│ Notification Service │
│ Email / SMS / Push │
└──────────────────────┘
┌──────────────────────┐
│ Inventory Service │
│ Stock management │
└──────────────────────┘
The goal isn't necessarily to turn every class into a microservice.
That's an important distinction.
SRP does not mean:
1 responsibility
↓
1 class
↓
1 service
↓
1 microservice
That would be over-engineering.
Instead, the principle should guide us toward logical boundaries.
A useful system-design interpretation is:
Things that change together should stay together. Things that change independently should be separated.
This applies to:
Classes
Modules
Packages
Services
Teams
Sometimes even entire systems
2.8 Common Mistakes
Mistake 1: “One class should have only one method”
No.
This is perfectly reasonable:
class ShoppingCart:
def add_item(self, item):
pass
def remove_item(self, item):
pass
def update_quantity(self, item, quantity):
pass
def calculate_total(self):
pass
There are multiple methods, but they all belong to the same responsibility.
Mistake 2: Creating a class for everything
You can take SRP too far.
For example:
UserNameGetter
UserEmailGetter
UserAddressGetter
UserPasswordChanger
You may have technically separated responsibilities, but you've also introduced unnecessary complexity.
Instead of:
user.get_email()
you might end up navigating through multiple objects just to perform a simple operation.
SRP is about managing complexity, not maximizing the number of classes.
Mistake 3: Confusing functionality with responsibility
A class can perform multiple operations and still have one responsibility.
For example:
class ShoppingCart:
def add_item(self, item):
pass
def remove_item(self, item):
pass
def apply_coupon(self, coupon):
pass
def calculate_total(self):
pass
Four operations don't necessarily mean four responsibilities.
All four contribute to:
Managing the shopping cart.
Mistake 4: Treating every dependency as an SRP violation
A class can depend on multiple components and still follow SRP.
For example:
class OrderService:
def place_order(self, order):
self.validator.validate(order)
self.payment.charge(order)
self.repository.save(order)
The class has three dependencies. That doesn't automatically mean three responsibilities. Its responsibility may simply be:
Coordinating the order placement process.
SRP in Single Frame
3. Open/Closed Principle — OCP
Imagine you have a payment system that already works in production.
Today, it supports:
Credit Card
Debit CardTomorrow, the product team says:
“We also need UPI.”
A week later:
“Add PayPal.”
Then:
“Add Apple Pay.”
If every new payment method requires opening the same class and adding another if/elif branch, the code becomes increasingly fragile.
This is the kind of problem the Open/Closed Principle (OCP) tries to solve.
The principle says:
Software entities should be open for extension but closed for modification.
At first, this sounds contradictory. How can code be extended without modifying it? The answer is abstraction and polymorphism. Let's understand it step by step.

3.1 Definition
The Open/Closed Principle was introduced by Bertrand Meyer and later became one of the five SOLID principles.
The principle states:
Software entities should be open for extension but closed for modification.
Here, software entities can include:
Classes
Functions
Modules
Components
Services
The idea is not that your code should literally never change.
Instead, it means that adding new behavior should ideally not require repeatedly changing already-tested, stable code.
Consider a notification system:
class NotificationService:
def send(self, notification_type, message):
if notification_type == "email":
self.send_email(message)
elif notification_type == "sms":
self.send_sms(message)
elif notification_type == "push":
self.send_push(message)Every time we introduce a new notification type, we need to modify NotificationService.
Add WhatsApp?
Modify it.
Add Slack?
Modify it.
Add some future notification provider?
Modify it again.
This is the kind of design OCP encourages us to improve.
3.2 Extension vs Modification
To understand OCP, we need to distinguish between extension and modification.
Modification
Modification means changing existing code to support new behavior.
For example:
class PaymentProcessor:
def process(self, payment_type, amount):
if payment_type == "card":
self.process_card(amount)
elif payment_type == "upi":
self.process_upi(amount)
elif payment_type == "paypal":
self.process_paypal(amount)Every new payment method requires modifying this existing class.
The pattern becomes:
New requirement
↓
Open existing class
↓
Add another condition
↓
Test everything againAs the number of payment types increases, the class grows.
card
upi
paypal
stripe
wallet
bank_transfer
crypto
...The class becomes a large collection of conditions.
Extension
Extension means adding new behavior without changing the existing core logic.
For example:
class PaymentMethod:
def pay(self, amount):
raise NotImplementedErrorThen individual implementations provide the behavior:
class CardPayment(PaymentMethod):
def pay(self, amount):
print(f"Paid ₹{amount} using card")class UpiPayment(PaymentMethod):
def pay(self, amount):
print(f"Paid ₹{amount} using UPI")class PayPalPayment(PaymentMethod):
def pay(self, amount):
print(f"Paid ₹{amount} using PayPal")Now the payment processor doesn't need to know every payment method:
class PaymentProcessor:
def process(self, payment_method, amount):
payment_method.pay(amount)Adding a new payment method becomes:
class WalletPayment(PaymentMethod):
def pay(self, amount):
print(f"Paid ₹{amount} using wallet")We added new behavior by extending the system, rather than modifying PaymentProcessor.
The difference is:
Modification
New Payment
↓
Change existing class
↓
Add another if/elifversus:
Extension
New Payment
↓
Create new implementation
↓
Existing code remains unchangedThat's the core idea behind OCP.
3.3 OCP Violation — Bad Design
Let's look at a common example.
Suppose we're building a discount system for an e-commerce application.
We might initially write:
class DiscountCalculator:
def calculate(self, customer_type, amount):
if customer_type == "regular":
return amount * 0.05
elif customer_type == "premium":
return amount * 0.10
elif customer_type == "vip":
return amount * 0.20This works.But then the business adds:
Student discount
Employee discount
Festival discount
First-order discount
Referral discountOur class becomes:
class DiscountCalculator:
def calculate(self, customer_type, amount):
if customer_type == "regular":
...
elif customer_type == "premium":
...
elif customer_type == "vip":
...
elif customer_type == "student":
...
elif customer_type == "employee":
...
elif customer_type == "festival":
...
elif customer_type == "first_order":
...
elif customer_type == "referral":
...This design has several problems.
1. The class keeps growing
Every new discount requires another branch.
2. Existing code keeps changing
A class that was already tested must repeatedly be modified.
3. Regression risk increases
Changing existing logic can accidentally break previously working behavior.
4. Responsibilities become mixed
The calculator now knows about every possible discount strategy.
5. Testing becomes harder
Every modification potentially requires testing the entire conditional structure.
This is a common OCP violation.
3.4 Applying OCP — Abstraction and Polymorphism
The usual solution is to introduce an abstraction.
Instead of asking:
“What type of discount is this?”
we can ask:
“Can this object calculate a discount?”
Define a common interface:
from abc import ABC, abstractmethod
class DiscountStrategy(ABC):
@abstractmethod
def calculate(self, amount):
passNow each discount strategy implements its own behavior.
class RegularDiscount(DiscountStrategy):
def calculate(self, amount):
return amount * 0.05class PremiumDiscount(DiscountStrategy):
def calculate(self, amount):
return amount * 0.10class VipDiscount(DiscountStrategy):
def calculate(self, amount):
return amount * 0.20The calculator doesn't need to know the details:
class DiscountCalculator:
def calculate(self, strategy, amount):
return strategy.calculate(amount)Now adding a student discount doesn't require modifying DiscountCalculator.
We simply extend the system:
class StudentDiscount(DiscountStrategy):
def calculate(self, amount):
return amount * 0.15The existing calculator remains unchanged.
The architecture now looks like:
DiscountStrategy
│
┌──────────────┼──────────────┐
↓ ↓ ↓
RegularDiscount PremiumDiscount VipDiscount
│ │ │
└──────────────┼──────────────┘
↓
DiscountCalculatorThis is polymorphism in action.
The calculator doesn't care which specific strategy it receives.
It only relies on the contract:
strategy.calculate(amount)That gives us extensibility.
Why Polymorphism Helps OCP
Without polymorphism:
if type == "card":
...
elif type == "upi":
...
elif type == "paypal":
...The caller needs to know every implementation.
With polymorphism:
payment_method.pay(amount)The caller only needs to know the abstraction.
This gives us:
Caller
↓
Abstraction
↓
ImplementationInstead of:
Caller
↓
if/elif/else
↓
Every possible implementationThat separation is what makes adding new implementations easier.
3.5 OCP and Design Patterns
Several common design patterns naturally support the Open/Closed Principle.
Strategy Pattern
The Strategy Pattern is probably the clearest example.
Suppose we have different pricing strategies:
class PricingStrategy(ABC):
@abstractmethod
def calculate(self, price):
passImplementations:
class RegularPricing(PricingStrategy):
def calculate(self, price):
return priceclass PremiumPricing(PricingStrategy):
def calculate(self, price):
return price * 0.90class FestivalPricing(PricingStrategy):
def calculate(self, price):
return price * 0.80New pricing rules can be introduced by creating new strategies.
Factory Pattern
Factories can help isolate object creation.
Instead of spreading construction logic everywhere:
if payment_type == "card":
payment = CardPayment()
elif payment_type == "upi":
payment = UpiPayment()we can centralize creation:
class PaymentFactory:
@staticmethod
def create(payment_type):
...This doesn't automatically make a system OCP-compliant, but it can keep creation logic separate from business logic.
Plugin Architecture
Plugins are an even stronger example.
Imagine an application supporting plugins:
Core Application
│
├── Plugin A
├── Plugin B
├── Plugin C
└── Plugin DThe core system doesn't need to be modified every time a new plugin is introduced.
The new functionality is added through extension.
This is OCP at a larger scale.
3.6 OCP in System Design
OCP becomes particularly interesting in system design.
Consider a payment platform.
A naive design might look like:
Payment Service
│
├── if Stripe
├── if Razorpay
├── if PayPal
├── if Adyen
└── if Bank TransferEvery new provider requires modifying the payment service.
A more extensible architecture could define a payment-provider contract:
PaymentProvider
│
┌────────────┼────────────┐
↓ ↓ ↓
Stripe Razorpay PayPalThe payment service depends on the abstraction:
PaymentService
│
↓
PaymentProvider
│
┌─────────────┼─────────────┐
↓ ↓ ↓
Stripe Razorpay PayPalAdding another provider becomes an extension.
For example:
PaymentProvider
+
NewProviderrather than:
PaymentService
↓
Modify existing logicThis idea appears frequently in real systems:
Payment providers
Authentication providers
Storage providers
Notification channels
Search engines
Shipping providers
Cloud providers
Logging systems
For example, instead of tightly coupling your application to one storage implementation:
Application
↓
S3you can introduce an abstraction:
Application
↓
ObjectStorage
/ | \
/ | \
S3 GCS AzureNow the application can support additional storage implementations without changing its core business logic.
3.7 Common Mistakes
Mistake 1: Thinking OCP means "never modify code"
This is one of the biggest misconceptions.
OCP doesn't mean:
“Existing code must never be changed.”
Real software evolves.
Requirements change.
Bugs need fixing.
Architecture needs improvement.
The idea is:
When adding a new variation of existing behavior, try to avoid modifying stable code unnecessarily.
OCP is a design goal, not a prohibition against code changes.
Mistake 2: Adding abstractions everywhere
You don't need an interface for every class.
For example, this:
class UserRepository:
...doesn't automatically need:
class IUserRepository(ABC):
...just because OCP exists. Abstraction is useful when you actually expect variation.
If there is no meaningful variation, introducing an abstraction may simply add complexity.
Mistake 3: Replacing every if statement
Not every conditional violates OCP.
This is perfectly normal:
if user.is_admin:
...The problem isn't if.
The problem is a conditional structure that keeps growing because the system continually adds new variants of the same behavior.
For example:
if payment_type == "card":
...
elif payment_type == "upi":
...
elif payment_type == "paypal":
...
elif payment_type == "wallet":
...
elif payment_type == "bank_transfer":
...That is a much stronger signal.
Mistake 4: Using inheritance just to satisfy OCP
Inheritance is not the goal.
You don't need a complicated class hierarchy like:
Payment
├── OnlinePayment
│ ├── CardPayment
│ │ └── VisaPayment
│ └── UpiPayment
└── OfflinePayment
└── CashPaymentsimply because you want to follow OCP.
Composition and dependency injection can often provide cleaner solutions.
The real goal is isolating variation, not maximizing inheritance.
Mistake 5: Predicting every possible future requirement
Developers sometimes try to make code extensible for things that may never happen.
For example:
“What if someday we support 25 different payment providers?”
So they build:
10 interfaces
20 abstract classes
15 factories
8 registriesbefore the application even has its second payment provider.
That's premature abstraction.
A better approach is:
Identify variation that is real or reasonably expected, rather than designing for every imaginable future.
OCP in One Frame
4. Liskov Substitution Principle — LSP
Liskov Substitution Principle (LSP) is the third principle of SOLID.
The simplest way to remember LSP is:
A child class should be usable wherever its parent class is expected, without breaking the program's correctness.
In other words:
Inheritance should preserve behavior, not just reuse code.
4.1 Definition
LSP was introduced by Barbara Liskov.
The formal idea is:
If
Bis a subtype ofA, objects of typeBshould be replaceable for objects of typeAwithout changing the correctness of the program.
For example:
class Bird:
def fly(self):
print("Flying")Suppose we create:
class Sparrow(Bird):
def fly(self):
print("Sparrow is flying")We can do:
bird = Sparrow()
bird.fly()Nothing breaks.
Sparrow behaves like a Bird that supports fly(). So this follows LSP.

4.2 Understanding Substitutability
Substitutability simply means:
"Can I replace the parent object with the child object without causing unexpected behavior?"
Consider:
class Payment:
def pay(self, amount):
print(f"Paid {amount}")And:
class CreditCardPayment(Payment):
def pay(self, amount):
print(f"Paid {amount} using credit card")Now:
def process_payment(payment: Payment):
payment.pay(100)
process_payment(CreditCardPayment())This works correctly. The function expects a Payment, and CreditCardPayment can safely replace it. That's substitutability.
4.3 Inheritance and Behavioral Contracts
This is where LSP becomes more important. A parent class creates a kind of contract.
For example:
class Payment:
def pay(self, amount):
if amount <= 0:
raise ValueError("Amount must be positive")
print("Payment successful")A subclass should respect the expected behavior.
Bad example:
class FreePayment(Payment):
def pay(self, amount):
raise Exception("I don't support payments")Now imagine:
def checkout(payment: Payment):
payment.pay(500)With:
checkout(FreePayment())The program breaks. The problem isn't that the subclass has different implementation.
Different implementation is completely fine.
The problem is that the subclass violates the behavior expected from the parent.
Think of it as:
Parent
↓
Contract
↓
Child must respect the contract4.4 Rectangle-Square Problem
This is one of the most famous examples of LSP.
Mathematically:
Square IS-A RectangleBut from an object-oriented behavioral perspective, making Square inherit from Rectangle can cause problems.
Consider:
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.heightNow:
rectangle = Rectangle(10, 5)
rectangle.set_width(20)
rectangle.set_height(10)
print(rectangle.area())Output:
200Now create:
class Square(Rectangle):
def set_width(self, width):
self.width = width
self.height = width
def set_height(self, height):
self.width = height
self.height = heightBecause a square must always have:
width = heightNow:
square = Square(10, 10)
square.set_width(20)
square.set_height(10)
print(square.area())The behavior is no longer what the Rectangle contract suggests.
A caller expects:
set_width(20)to change only the width.
But the Square changes both width and height.
Therefore:
Rectangle
↑
|
Squaremay be mathematically valid but behaviorally problematic. This is an LSP violation.
Better design
Instead of forcing Square to inherit from Rectangle, we can use a common abstraction:
class Shape:
def area(self):
passThen:
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.heightAnd:
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side * self.sideNow both are simply:
Shape
├── Rectangle
└── SquareThey don't make promises that they cannot keep.
4.5 Preconditions, Postconditions and Invariants
This is one of the most important advanced concepts in LSP. LSP is closely related to three concepts.
Preconditions
A precondition is something that must be true before a method executes.
Example:
def withdraw(amount):
if amount <= 0:
raise ValueError()The method requires:
amount > 0A subclass should not make the precondition stronger.
Bad:
Parent:
amount > 0
Child:
amount > 100The child is now more restrictive.
A caller that was valid for the parent can fail with the child.
Postconditions
A postcondition is something that must be true after the method executes.
Suppose:
def deposit(amount):
balance += amountThe expected postcondition might be:
new_balance = old_balance + amountA subclass shouldn't weaken this guarantee.
Invariants
An invariant is something that must always remain true.
For a bank account:
balance >= 0might be an invariant.
For a square:
width == heightis an invariant. A subclass must preserve the relevant invariants of the parent contract.
Easy rule
Remember:
Preconditions → Don't make stronger
Postconditions → Don't make weaker
Invariants → Must be preserved4.6 LSP Violation — Bad Design
A very common violation looks like this:
class Bird:
def fly(self):
print("Flying")Then:
class Penguin(Bird):
def fly(self):
raise Exception("Penguins cannot fly")Now:
def make_bird_fly(bird):
bird.fly()This works:
make_bird_fly(Sparrow())But:
make_bird_fly(Penguin())throws an exception. The problem is the abstraction:
Bird
└── fly()doesn't correctly represent all birds. A better design is:
class Bird:
pass
class FlyingBird(Bird):
def fly(self):
print("Flying")
class Sparrow(FlyingBird):
pass
class Penguin(Bird):
passNow:
Bird
├── FlyingBird
│ └── Sparrow
│
└── PenguinOnly birds capable of flying receive the fly() behavior.
4.7 LSP in System Design
LSP is not only about inheritance.
It is extremely useful when designing systems with interchangeable implementations.
For example, suppose you have:
PaymentService
│
├── StripePayment
├── RazorpayPayment
└── PayPalPaymentYour application might have:
def process_payment(payment_service, amount):
payment_service.pay(amount)You should be able to replace:
StripePaymentwith:
RazorpayPaymentwithout changing the checkout logic.
If StripePayment.pay() behaves like:
returns success/failurebut RazorpayPayment.pay() unexpectedly:
throws an exception for normal failuresor requires completely different assumptions, then the implementations aren't truly substitutable.
System-design view
LSP helps us build:
Payment Interface
│
┌───────┼────────┐
↓ ↓ ↓
Stripe Razorpay PayPalThe caller depends on the contract, not the implementation.
This is closely related to:
Interfaces
Polymorphism
Dependency Injection
Strategy Pattern
Plugin architectures
Adapter Pattern
Microservice contracts
API compatibility
4.8 Common Violations
Here are the most common LSP violations you should recognize.
1. Child throws unexpected exceptions
class Parent:
def process(self):
pass
class Child(Parent):
def process(self):
raise Exception("Not supported")If normal parent usage doesn't expect this exception, substitutability is broken.
2. Child refuses to implement parent behavior
class Bird:
def fly(self):
pass
class Penguin(Bird):
def fly(self):
raise Exception("Cannot fly")The abstraction is wrong.
3. Child strengthens preconditions
Parent:
accepts amount > 0Child:
accepts amount > 1000The child is more restrictive.
LSP violation.
4. Child weakens postconditions
Parent promises:
payment returns transaction IDChild returns:
NoneThe caller can no longer rely on the parent's guarantee.
LSP violation.
5. Child changes expected behavior
Parent:
save(data)means:
data is persistedChild:
save(data)only stores it temporarily.
The method name is the same, but the behavioral contract has changed.
6. isinstance() checks everywhere
If your code constantly does:
if isinstance(obj, SomeChild):
...
elif isinstance(obj, AnotherChild):
...that's often a warning sign that your abstraction isn't designed correctly. Instead, polymorphism should generally allow:
obj.process()without caring about the concrete implementation.
OCP in One Frame
5. Interface Segregation Principle — ISP
The Interface Segregation Principle (ISP) is the fourth principle of SOLID. The easiest way to remember ISP is:
Clients should not be forced to depend on methods they do not use.
In simple words:
Prefer small, focused interfaces over large, general-purpose interfaces.

5.1 Definition
The Interface Segregation Principle was introduced by Robert C. Martin (Uncle Bob).
The formal definition is:
No client should be forced to depend on methods it does not use.
Let's break this down. Suppose we have an interface:
class Worker:
def work(self):
pass
def eat(self):
pass
def sleep(self):
passNow suppose a Robot is also considered a worker:
class Robot(Worker):
def work(self):
print("Robot working")
def eat(self):
pass
def sleep(self):
passA robot doesn't need eat() or sleep().
Yet because the interface contains these methods, the robot is forced to implement them.
That's an ISP violation. The better approach is to split the interface:
Worker
├── Workable
├── Eatable
└── SleepableNow a robot only implements what it actually needs.
5.2 Understanding Fat Interfaces
A fat interface is an interface containing too many unrelated methods.
For example:
class Machine:
def print_document(self):
pass
def scan_document(self):
pass
def fax_document(self):
pass
def staple_document(self):
pass
def photocopy_document(self):
passThis looks convenient initially. But imagine a simple printer:
class SimplePrinter(Machine):
def print_document(self):
print("Printing")
def scan_document(self):
raise NotImplementedError()
def fax_document(self):
raise NotImplementedError()
def staple_document(self):
raise NotImplementedError()
def photocopy_document(self):
raise NotImplementedError()The printer only supports printing. But the interface forces it to depend on:
scan
fax
staple
photocopyThis is exactly what ISP tells us to avoid.
Fat interface
Machine
│
┌─────────┼──────────┐
↓ ↓ ↓
print scan fax
↓ ↓ ↓
staple photocopy ...The interface is trying to represent too many capabilities.
5.3 ISP Violation — Bad Design
Let's look at a realistic example.
Suppose we create:
class Employee:
def work(self):
pass
def attend_meeting(self):
pass
def write_code(self):
pass
def manage_team(self):
pass
def prepare_report(self):
passNow we create:
class Developer(Employee):
def work(self):
print("Developer working")
def attend_meeting(self):
print("Developer attending meeting")
def write_code(self):
print("Writing code")
def manage_team(self):
raise NotImplementedError()
def prepare_report(self):
raise NotImplementedError()The developer doesn't manage a team, but is forced to implement:
manage_team()The same problem happens with:
prepare_report()The problem isn't simply that the class has extra methods.
The real problem is:
The interface contains responsibilities that don't belong to every client.
5.4 Applying ISP — Focused Interfaces
Instead of creating one large interface:
class Employee:
def work(self):
pass
def write_code(self):
pass
def manage_team(self):
pass
def prepare_report(self):
passWe can split it:
class Workable:
def work(self):
pass
class Coder:
def write_code(self):
pass
class Manager:
def manage_team(self):
pass
class Reporter:
def prepare_report(self):
passNow:
class Developer(Workable, Coder):
def work(self):
print("Developer working")
def write_code(self):
print("Writing code")And:
class EngineeringManager(Workable, Manager):
def work(self):
print("Manager working")
def manage_team(self):
print("Managing team")Each class depends only on the capabilities it actually needs.
Before ISP
Employee
│
┌────────────┼─────────────┐
↓ ↓ ↓
Developer Manager Reporter
│ │ │
└──── forced methods ──────┘After ISP
Workable
/ \
↓ ↓
Developer Manager
Coder Manager
↓ ↓
Developer Engineering
Manager
Reporter
↓
ReporterThe interfaces are now small and focused.
5.5 ISP and Cohesion
ISP is strongly connected to cohesion.
Cohesion means:
How closely related the responsibilities inside a module or interface are.
Consider:
class UserService:
def create_user(self):
pass
def delete_user(self):
pass
def send_email(self):
pass
def generate_invoice(self):
pass
def upload_file(self):
passThis interface has unrelated responsibilities. It has low cohesion.
A better design could be:
UserService
├── create_user()
└── delete_user()
EmailService
└── send_email()
InvoiceService
└── generate_invoice()
FileService
└── upload_file()Now each interface has a clear purpose.
Relationship
Smaller interface
↓
Focused responsibilities
↓
Higher cohesion
↓
Less unnecessary dependency
↓
Easier maintenanceSo ISP isn't just about "having fewer methods." It's about grouping related capabilities together.
5.6 ISP in System Design
ISP becomes especially useful when designing large systems.
Imagine an e-commerce platform. You could have a giant interface:
ECommerceService
├── createOrder()
├── cancelOrder()
├── processPayment()
├── refundPayment()
├── shipOrder()
├── trackShipment()
├── sendNotification()
├── generateInvoice()
├── updateInventory()
└── applyCoupon()This creates unnecessary coupling.
Instead, separate responsibilities:
OrderService
├── createOrder()
└── cancelOrder()
PaymentService
├── processPayment()
└── refundPayment()
ShippingService
├── shipOrder()
└── trackShipment()
NotificationService
└── sendNotification()
InventoryService
└── updateInventory()
InvoiceService
└── generateInvoice()Now each service exposes a focused contract.
This is especially useful in:
Microservices
Plugin systems
Payment systems
Notification systems
Storage systems
Authentication systems
Cloud abstractions
For example:
Storage
│
┌──────────┼──────────┐
↓ ↓ ↓
S3Storage GCSStorage AzureStorageInstead of forcing every storage implementation to support dozens of unrelated operations, define focused capabilities.
5.7 ISP and API Design
ISP is also important when designing APIs.
Suppose you create one huge API:
/api/userwith:
GET /profile
POST /profile
DELETE /profile
POST /payment
POST /orders
GET /orders
POST /upload
POST /notificationsDifferent clients may need completely different subsets.
For example:
Mobile App
↓
Profile APIs
Order APIswhile:
Admin Dashboard
↓
User Management
Reports
Ordersand:
Payment Service
↓
Payment APIs
Refund APIsA better API design separates capabilities:
/api/users
/api/orders
/api/payments
/api/refunds
/api/notifications
/api/uploadsThe idea is similar to ISP:
A consumer should interact with the contract it actually needs rather than depending on an unnecessarily large contract.
Important distinction
ISP does not mean:
"Every API must have only one endpoint."
That's an incorrect interpretation.
The goal is:
Keep interfaces and contracts focused around related client needs.
5.8 Common Mistakes
Mistake 1: Making interfaces too large
class Everything:
def login(self): pass
def logout(self): pass
def pay(self): pass
def ship(self): pass
def send_email(self): pass
def generate_report(self): passThis is a classic fat interface.
Mistake 2: Creating interfaces with unrelated responsibilities
Bad:
UserInterface
├── login()
├── uploadFile()
├── processPayment()
└── generateInvoice()These responsibilities aren't naturally related.
Better:
Authentication
├── login()
└── logout()
FileStorage
└── uploadFile()
Payment
└── processPayment()
Invoice
└── generateInvoice()Mistake 3: Using NotImplementedError everywhere
If you frequently write:
def scan(self):
raise NotImplementedError()or:
def fly(self):
raise NotImplementedError()ask yourself:
Should this method actually be part of this interface?
Sometimes the correct solution isn't implementing the method differently. It's removing the method from the interface.
Mistake 4: Confusing ISP with SRP
These two principles are related but different.
SRP asks:
Does this class have more than one reason to change?
ISP asks:
Are clients being forced to depend on methods they don't need?
For example:
SRP
↓
Focus on class responsibility
ISP
↓
Focus on interface/client dependencyMistake 5: Making interfaces artificially tiny
ISP doesn't mean:
Interface 1 → one method
Interface 2 → one method
Interface 3 → one method
Interface 4 → one methodThat's not automatically good design.
If several methods naturally belong together and are used together by the same clients, keeping them together can be perfectly reasonable.
ISP is about client needs and cohesion, not blindly minimizing method count.
ISP in One Frame
6. Dependency Inversion Principle — DIP
The Dependency Inversion Principle (DIP) is the fifth and final principle of SOLID.
The easiest way to remember DIP is:
High-level modules should not depend directly on low-level modules. Both should depend on abstractions.
And:
Abstractions should not depend on details. Details should depend on abstractions.
DIP is one of the most important SOLID principles for system design, clean architecture, dependency injection, testing, and loosely coupled systems.

6.1 Definition
DIP has two main rules:
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.
Let's understand each part. Suppose we have:
OrderService
↓
MySQLDatabaseOrderService directly depends on MySQLDatabase. That's tight coupling.
Instead:
Database
↑
│
┌───────┴───────┐
│ │
MySQL MongoDB
↑ ↑
└───────┬───────┘
│
OrderServiceNow OrderService depends on the Database abstraction, not a specific database. This is DIP.
6.2 High-Level vs Low-Level Modules
Before understanding DIP, you need to understand high-level and low-level modules.
High-Level Module
A high-level module contains business logic.
For example:
class OrderService:
def create_order(self):
# business logic
passIt answers questions like:
What should the application do?
Examples:
OrderService
PaymentService
UserService
CheckoutService
NotificationServiceLow-Level Module
A low-level module handles implementation details.
For example:
MySQLDatabase
RedisCache
SMTPEmailService
StripePayment
AWSStorageThese answer:
How is something actually done?
For example:
Business Logic
↓
"Store the order"
Implementation Detail
↓
"MySQL INSERT query"The business logic shouldn't necessarily care whether the order is stored in MySQL, PostgreSQL, MongoDB, etc.
6.3 Dependency Direction
This is the heart of DIP.
Without DIP:
High-Level Module
↓
Low-Level Module
↓
ImplementationFor example:
OrderService
↓
MySQLThe business logic is controlled by an implementation detail.
With DIP:
Abstraction
/ \
↑ ↑
│ │
High-Level Low-Level
Module ModuleFor example:
Repository
Interface
↑ ↑
│ │
OrderService MySQLRepositoryBoth depend on the abstraction.
Dependency direction
Instead of:
OrderService → MySQLRepositorywe have:
OrderService → Repository ← MySQLRepositoryThe arrow direction is important. The high-level business logic doesn't know about the concrete database implementation.
6.4 DIP Violation — Bad Design
Let's look at a simple example.
class MySQLDatabase:
def save(self, data):
print("Saving data to MySQL")
class UserService:
def __init__(self):
self.database = MySQLDatabase()
def create_user(self, user):
self.database.save(user)Usage:
service = UserService()
service.create_user("Ritesh")It works. But there is a problem. UserService directly creates:
MySQLDatabase()Therefore:
UserService
↓
MySQLDatabaseNow imagine tomorrow you want PostgreSQL. You have to modify UserService.
class UserService:
def __init__(self):
self.database = PostgreSQLDatabase()This creates tight coupling.
Why is this bad?
Because UserService contains two responsibilities:
UserService
├── Business logic
└── Choosing database implementationThe second responsibility shouldn't belong there.
6.5 Applying DIP — Abstractions
We can solve this using an abstraction. First create an interface:
from abc import ABC, abstractmethod
class Database(ABC):
@abstractmethod
def save(self, data):
passNow MySQL implements the abstraction:
class MySQLDatabase(Database):
def save(self, data):
print("Saving data to MySQL")And PostgreSQL can also implement it:
class PostgreSQLDatabase(Database):
def save(self, data):
print("Saving data to PostgreSQL")Now UserService depends on the abstraction:
class UserService:
def __init__(self, database: Database):
self.database = database
def create_user(self, user):
self.database.save(user)We can provide MySQL:
mysql = MySQLDatabase()
service = UserService(mysql)
service.create_user("Ritesh")Or PostgreSQL:
postgres = PostgreSQLDatabase()
service = UserService(postgres)
service.create_user("Ritesh")The UserService doesn't change. That's the key benefit of DIP.
Before
UserService
↓
MySQLDatabaseAfter
Database
Interface
↑ ↑
│ │
MySQL PostgreSQL
↑ ↑
└───┬───┘
│
UserService6.6 Dependency Injection
Dependency Injection (DI) is one of the most common techniques used to implement DIP.
The basic idea is:
Instead of creating a dependency inside a class, provide the dependency from outside.
Bad:
class UserService:
def __init__(self):
self.database = MySQLDatabase()The class creates its own dependency.
Better:
class UserService:
def __init__(self, database):
self.database = databaseNow the dependency is injected.
database = MySQLDatabase()
service = UserService(database)The flow becomes:
Outside World
↓
Creates dependency
↓
MySQLDatabase
↓
Injects it
↓
UserServiceDIP vs Dependency Injection
Don't confuse these two. DIP is a design principle. Dependency Injection is a technique/pattern that can help implement DIP.
Think:
DIP
↓
"Depend on abstractions"
Dependency Injection
↓
"Provide dependencies from outside"DI is one way of achieving loose coupling, but simply using dependency injection doesn't automatically guarantee good DIP design.
6.7 DIP and Testing
DIP makes unit testing much easier.
Consider this:
class UserService:
def __init__(self):
self.database = MySQLDatabase()Testing becomes difficult because every test requires the real database implementation.
Instead:
class UserService:
def __init__(self, database):
self.database = databaseWe can create a fake database:
class FakeDatabase:
def save(self, data):
print("Fake save")Then:
fake_db = FakeDatabase()
service = UserService(fake_db)
service.create_user("Ritesh")No real database is required.
This gives us:
Production
↓
MySQLDatabase
Testing
↓
FakeDatabaseBoth satisfy:
Database InterfaceWhy this matters
You can test:
Business Logic
↓
Without
↓
Real DatabaseThis makes tests:
Faster
More isolated
Easier to write
Easier to maintain
Less dependent on infrastructure
6.8 DIP in System Design
DIP becomes extremely powerful in large systems. Consider a payment system:
CheckoutService
↓
StripeThis is tightly coupled.
Instead:
PaymentGateway
↑
┌────────┼────────┐
│ │ │
Stripe Razorpay PayPalThe business logic depends on:
PaymentGatewaynot:
StripeFor example:
class PaymentGateway:
def pay(self, amount):
passStripe:
class StripePayment(PaymentGateway):
def pay(self, amount):
print("Stripe payment")Razorpay:
class RazorpayPayment(PaymentGateway):
def pay(self, amount):
print("Razorpay payment")Checkout:
class CheckoutService:
def __init__(self, payment_gateway):
self.payment_gateway = payment_gateway
def checkout(self, amount):
self.payment_gateway.pay(amount)Now:
CheckoutService
↓
PaymentGateway
↑
┌────────────┼────────────┐
↓ ↓ ↓
Stripe Razorpay PayPalYou can change the payment provider without rewriting the checkout business logic.
Other system-design examples
The same idea applies to:
Storage
↓
S3 / GCS / Azure Blob
Cache
↓
Redis / Memcached
Database
↓
PostgreSQL / MySQL / MongoDB
Message Queue
↓
Kafka / RabbitMQ / SQS
Notification
↓
Email / SMS / PushYour business logic can depend on abstractions such as:
Storage
Cache
Repository
MessagePublisher
NotificationService
PaymentGatewayrather than directly depending on infrastructure technologies.
6.9 Common Mistakes
Mistake 1: Thinking DIP means "always use interfaces"
Not necessarily.
You don't need an interface for every single class.
For example:
class Calculator:
def add(self, a, b):
return a + bCreating:
ICalculator
Calculator
CalculatorImpljust for the sake of DIP can create unnecessary complexity.
Use abstractions where multiple implementations, volatility, testing, or architectural boundaries justify them.
Mistake 2: Confusing DIP with Dependency Injection
Remember:
DIP = Principle
DI = TechniqueDependency Injection can help implement DIP, but they are not the same thing.
Mistake 3: Abstraction still depends on implementation
Bad:
class PaymentGateway:
def pay(self):
stripe_api.call()The abstraction itself now knows about Stripe.
That's backwards.
Instead:
PaymentGateway
↑
StripePaymentThe concrete implementation should depend on the abstraction.
Mistake 4: Putting infrastructure logic in business logic
Bad:
class OrderService:
def create_order(self):
mysql_connection = MySQLConnection()
redis = Redis()
kafka = KafkaProducer()
# business logicNow your business logic is tightly coupled to infrastructure.
Better:
OrderService
↓
Repository
Cache
EventPublisher
↑
│
Concrete implementationsMistake 5: Using a service locator everywhere
Some developers try to avoid direct dependencies by doing:
database = ServiceLocator.get("database")everywhere.
This can hide dependencies rather than properly managing them. Explicit dependency injection is generally easier to understand:
def __init__(self, database):
self.database = databaseThe dependency is visible.
DIP in One Frame
7. How SOLID Principles Work Together
The five SOLID principles are not isolated rules. They work together to help you build software that is easier to change, extend, test, maintain, and scale. A common mistake is to think:
"I need to apply SRP, then OCP, then LSP, etc."
That's not how SOLID should be used. Instead, think of SOLID as a set of design guidelines that reinforce each other.
SOLID
│
┌──────┼──────┐
↓ ↓ ↓
SRP OCP LSP
│ │ │
└──────┼──────┘
↓
ISP + DIP
↓
Loosely Coupled
Maintainable Design7.1 SRP + OCP

Consider an e-commerce payment system.
Bad design:
class PaymentService:
def process_payment(self, payment_type, amount):
if payment_type == "stripe":
# Stripe logic
pass
elif payment_type == "razorpay":
# Razorpay logic
pass
elif payment_type == "paypal":
# PayPal logic
passThis class has multiple reasons to change:
PaymentService
├── Stripe changes
├── Razorpay changes
└── PayPal changesThis violates SRP. It also violates OCP, because adding a new payment provider requires modifying the existing class.
Applying SRP + OCP
Separate the payment implementations:
class PaymentProcessor:
def pay(self, amount):
pass
class StripePayment(PaymentProcessor):
def pay(self, amount):
print("Stripe payment")
class RazorpayPayment(PaymentProcessor):
def pay(self, amount):
print("Razorpay payment")
class PayPalPayment(PaymentProcessor):
def pay(self, amount):
print("PayPal payment")Now adding a new provider:
class PhonePePayment(PaymentProcessor):
def pay(self, amount):
print("PhonePe payment")doesn't require modifying the existing payment implementations.
How they work together
SRP
↓
Separate responsibilities
↓
Smaller focused classes
↓
OCP
↓
Extend through new classes
↓
Less modification of existing codeSo:
SRP helps create the right boundaries, while OCP helps extend those boundaries safely.
7.2 OCP + LSP

Suppose:
class PaymentProcessor:
def pay(self, amount):
passWe have:
class StripePayment(PaymentProcessor):
def pay(self, amount):
print("Stripe payment")
class RazorpayPayment(PaymentProcessor):
def pay(self, amount):
print("Razorpay payment")Our checkout code doesn't need to change:
def checkout(payment_processor):
payment_processor.pay(1000)We can add:
class PayPalPayment(PaymentProcessor):
def pay(self, amount):
print("PayPal payment")That's OCP.
But PayPal must also respect the contract of PaymentProcessor.
If it does something completely unexpected:
class PayPalPayment(PaymentProcessor):
def pay(self, amount):
raise Exception("PayPal cannot process payments")then substituting it for PaymentProcessor breaks the caller.
That's an LSP violation.
Together
OCP
↓
Add new implementation
↓
LSP
↓
New implementation respects abstraction
↓
Existing code continues workingTherefore:
OCP allows us to add implementations, while LSP ensures those implementations remain valid substitutions.
7.3 ISP + DIP

Consider a large interface:
class ECommerceService:
def create_order(self):
pass
def process_payment(self):
pass
def ship_order(self):
pass
def send_notification(self):
pass
def generate_invoice(self):
passSuppose CheckoutService only needs:
create_order()
process_payment()Making it depend on the entire ECommerceService violates ISP.
Instead:
class OrderService:
def create_order(self):
pass
class PaymentService:
def process_payment(self):
passThen:
class CheckoutService:
def __init__(self, order_service, payment_service):
self.order_service = order_service
self.payment_service = payment_serviceNow the checkout logic depends only on the abstractions it actually needs.
Together
ISP
↓
Small, focused interfaces
↓
DIP
↓
Depend on those abstractions
↓
Lower couplingSo:
ISP decides how to make interfaces focused, while DIP decides how higher-level code depends on those interfaces.
7.4 Applying All Five Together
Now let's put everything together using a real-world example. Imagine we're building an e-commerce checkout system. The checkout process needs to:
Validate the order
Process payment
Save the order
Send a confirmation notification
A poorly designed system might look like this:
class Checkout:
def checkout(self, order, payment_type):
# Validate order
if payment_type == "stripe":
# Stripe API
pass
elif payment_type == "razorpay":
# Razorpay API
pass
# Save directly to MySQL
# Send email directlyThis creates several problems.
Checkout
├── Order validation
├── Stripe
├── Razorpay
├── MySQL
└── EmailThe class has too many responsibilities. That's an SRP problem. Adding another payment provider requires modifying Checkout. That's an OCP problem. The class depends directly on concrete technologies. That's a DIP problem. And if the interfaces are huge and unrelated, that's an ISP problem.
Applying SOLID Step by Step
Step 1: SRP
Separate responsibilities:
OrderValidator
PaymentService
OrderRepository
NotificationService
CheckoutServiceNow each component has a focused responsibility.
Step 2: ISP
Create focused interfaces:
PaymentGateway
└── pay()
OrderRepository
├── save()
└── find()
NotificationService
└── send()Don't create one giant:
ECommerceInterfacecontaining everything.
Step 3: DIP
Make CheckoutService depend on abstractions:
class CheckoutService:
def __init__(
self,
payment_gateway,
order_repository,
notification_service
):
self.payment_gateway = payment_gateway
self.order_repository = order_repository
self.notification_service = notification_serviceIt doesn't know whether we're using:
Stripe
MySQL
PostgreSQL
SendGrid
KafkaStep 4: OCP
Add new implementations without modifying CheckoutService.
For example:
PaymentGateway
↑
┌────┼─────┐
↓ ↓ ↓
Stripe Razorpay PayPalWant to add:
PhonePeCreate:
class PhonePePayment(PaymentGateway):
def pay(self, amount):
...No modification to CheckoutService.
Step 5: LSP
Every payment implementation must respect the contract:
PaymentGateway
↑
│
┌────┼─────────────┐
↓ ↓ ↓
Stripe Razorpay PayPalAny of them should be safely usable here:
checkout = CheckoutService(
payment_gateway,
order_repository,
notification_service
)If replacing Stripe with Razorpay breaks the checkout flow, the implementation violates LSP.
All Five Together
The final architecture might look like:
CheckoutService
│
┌───────────────┼────────────────┐
↓ ↓ ↓
PaymentGateway OrderRepository Notification
↑ ↑ ↑
┌─────┼─────┐ │ ┌─────┼─────┐
↓ ↓ ↓ ↓ ↓ ↓
Stripe Razorpay PayPal PostgreSQL Email SMSNow see how each principle contributes:
SRP
↓
Each component has a focused responsibility
OCP
↓
New implementations can be added without modifying
stable business logic
LSP
↓
Implementations can safely replace one another
ISP
↓
Interfaces remain small and focused
DIP
↓
Business logic depends on abstractions,
not infrastructure detailsHow SOLID Creates a Better System
Without SOLID:
Checkout
│
┌────────────┼─────────────┐
↓ ↓ ↓
MySQL Stripe Email
│ │ │
└────────────┼─────────────┘
↓
Tightly CoupledChanging one technology can affect many parts of the system.
With SOLID:
Business Logic
│
Abstractions
│
┌────────────┼────────────┐
↓ ↓ ↓
Database Payment Notification
↑ ↑ ↑
│ │ │
MySQL Stripe Email
MongoDB Razorpay SMS
PostgreSQL PayPal PushNow implementation details can change without significantly affecting business logic.
The Most Important Connection
You can remember the relationship like this:
SRP
↓
Create focused components
↓
ISP
↓
Create focused interfaces
↓
DIP
↓
Depend on those abstractions
↓
OCP
↓
Add new implementations
↓
LSP
↓
Ensure those implementations remain substitutableThis is not a strict sequence you must follow every time. In real software design, these principles influence each other continuously.
One-line summary
SOLID works as a system of complementary principles: SRP keeps responsibilities focused, OCP makes the design extensible, LSP keeps extensions behaviorally safe, ISP keeps interfaces focused, and DIP keeps high-level business logic independent of implementation details.
Recap In Single Frame
8. SOLID Trade-offs
SOLID principles help us build maintainable and flexible software, but they are not absolute rules. A common mistake is thinking:
"More SOLID = better software."
That's not always true.
Applying SOLID introduces abstractions, interfaces, classes, and additional layers. These can be valuable in a large or changing system, but unnecessary in a small or stable one.
The goal is not maximum SOLID. The goal is:
Use the right amount of abstraction for the complexity and changeability of the system.
8.1 Benefits
SOLID helps you achieve:
Low coupling — components depend less on each other.
High cohesion — each component has a focused purpose.
Easy testing — dependencies can be replaced with mocks/fakes.
Easy extension — new features can be added with fewer changes.
Better maintenance — changes are easier to isolate.
8.2 Costs of Abstraction
Abstraction also has costs:
More interfaces
↓
More classes
↓
More code & indirection
↓
More complexityFor example, creating:
Interface
↓
Factory
↓
Strategy
↓
Implementationfor a very simple feature may make the code harder, not better.
So, abstraction has value only when it solves a real problem.
8.3 Avoiding Overengineering
Don't use SOLID just to create more classes or interfaces.
Follow:
YAGNI → You Aren't Gonna Need It
Don't build hypothetical future requirements.
KISS → Keep It Simple
Prefer the simplest design that properly solves the problem.
Simple rule
Use SOLID when it reduces real complexity; don't use it when it creates unnecessary complexity.
Think of the balance:
Too little abstraction → Tight coupling
Too much abstraction → Overengineering
↓
BalanceGood architecture = enough flexibility + minimum necessary complexity.
9. Conclusion
SOLID principles provide a practical foundation for writing software that is maintainable, flexible, testable, and easier to extend. Each principle addresses a different design problem: SRP keeps responsibilities focused, OCP makes systems easier to extend, LSP ensures safe substitution, ISP keeps interfaces focused, and DIP reduces tight coupling through abstractions.
However, SOLID is not about creating more classes or interfaces. The goal is to reduce complexity, not increase it. Good software design means applying these principles where they solve real problems while avoiding unnecessary abstraction and overengineering.
Ultimately, SOLID should be treated as a set of design guidelines rather than strict rules. When applied thoughtfully, these principles help developers build software that can evolve with changing requirements without becoming difficult to understand or maintain.

