Skip to content
SOLID Principles: A Practical Guide with Real-World Examples 31 min

SOLID Principles: A Practical Guide with Real-World Examples

ScaleDojo
ScaleDojo
•
Sep 15, 2026
•
31 min read
•7,375 words
SOLID.png

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:

SOLID

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 Invoice

Initially, 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 logic

This 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
     ↓
Stripe

Changing 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 report

The class has too many reasons to change. This is primarily related to SRP.


Difficult Extension: Suppose your payment system initially supports:

UPI
Card

Then you add:

PayPal
Stripe
Razorpay

If 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
       ↓
     MySQL

Testing OrderService now requires a real database or complicated setup.

With proper abstractions:

OrderService
       ↓
OrderRepository
       ↑
       |
 ┌─────┴─────┐
 ↓           ↓
MySQL      Mock

You 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.

SRP

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        Invoice

At 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
      └── EmailService

Now 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):
        pass

Let'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
       ↓
EmployeeReport

The 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 B

OrderService changes.

Database changes

Database A
        ↓
Database B

OrderService changes.

Notification system changes

REST API
    ↓
Message Queue

OrderService 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 schema

Different 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):
        pass

Notification

class NotificationService:

    def notify_restaurant(self, order):
        pass

Email

class EmailService:

    def send_confirmation(self, order):
        pass

Now 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 Card

Tomorrow, 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.

OCP

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 again

As 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 NotImplementedError

Then 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/elif

versus:

Extension

New Payment
    ↓
Create new implementation
    ↓
Existing code remains unchanged

That'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.20

This works.But then the business adds:

Student discount
Employee discount
Festival discount
First-order discount
Referral discount

Our 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):
        pass

Now each discount strategy implements its own behavior.

class RegularDiscount(DiscountStrategy):

    def calculate(self, amount):
        return amount * 0.05
class PremiumDiscount(DiscountStrategy):

    def calculate(self, amount):
        return amount * 0.10
class VipDiscount(DiscountStrategy):

    def calculate(self, amount):
        return amount * 0.20

The 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.15

The existing calculator remains unchanged.

The architecture now looks like:

                 DiscountStrategy
                       │
        ┌──────────────┼──────────────┐
        ↓              ↓              ↓
 RegularDiscount  PremiumDiscount  VipDiscount
        │              │              │
        └──────────────┼──────────────┘
                       ↓
               DiscountCalculator

This 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
  ↓
Implementation

Instead of:

Caller
  ↓
if/elif/else
  ↓
Every possible implementation

That 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):
        pass

Implementations:

class RegularPricing(PricingStrategy):

    def calculate(self, price):
        return price
class PremiumPricing(PricingStrategy):

    def calculate(self, price):
        return price * 0.90
class FestivalPricing(PricingStrategy):

    def calculate(self, price):
        return price * 0.80

New 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 D

The 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 Transfer

Every new provider requires modifying the payment service.

A more extensible architecture could define a payment-provider contract:

             PaymentProvider
                    │
       ┌────────────┼────────────┐
       ↓            ↓            ↓
    Stripe       Razorpay      PayPal

The payment service depends on the abstraction:

                 PaymentService
                        │
                        ↓
                PaymentProvider
                        │
          ┌─────────────┼─────────────┐
          ↓             ↓             ↓
       Stripe        Razorpay       PayPal

Adding another provider becomes an extension.

For example:

PaymentProvider
      +
NewProvider

rather than:

PaymentService
      ↓
Modify existing logic

This 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
     ↓
S3

you can introduce an abstraction:

Application
     ↓
ObjectStorage
   /    |    \
  /     |     \
S3    GCS    Azure

Now 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
       └── CashPayment

simply 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 registries

before 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 B is a subtype of A, objects of type B should be replaceable for objects of type A without 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.

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 contract

4.4 Rectangle-Square Problem

This is one of the most famous examples of LSP.

Mathematically:

Square IS-A Rectangle

But 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.height

Now:

rectangle = Rectangle(10, 5)

rectangle.set_width(20)
rectangle.set_height(10)

print(rectangle.area())

Output:

200

Now 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 = height

Because a square must always have:

width = height

Now:

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
    ↑
    |
 Square

may 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):
        pass

Then:

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

And:

class Square(Shape):
    def __init__(self, side):
        self.side = side

    def area(self):
        return self.side * self.side

Now both are simply:

Shape
 ├── Rectangle
 └── Square

They 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 > 0

A subclass should not make the precondition stronger.

Bad:

Parent:
amount > 0

Child:
amount > 100

The 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 += amount

The expected postcondition might be:

new_balance = old_balance + amount

A subclass shouldn't weaken this guarantee.


Invariants

An invariant is something that must always remain true.

For a bank account:

balance >= 0

might be an invariant.

For a square:

width == height

is 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 preserved

4.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):
    pass

Now:

Bird
 ├── FlyingBird
 │    └── Sparrow
 │
 └── Penguin

Only 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
       └── PayPalPayment

Your application might have:

def process_payment(payment_service, amount):
    payment_service.pay(amount)

You should be able to replace:

StripePayment

with:

RazorpayPayment

without changing the checkout logic.

If StripePayment.pay() behaves like:

returns success/failure

but RazorpayPayment.pay() unexpectedly:

throws an exception for normal failures

or requires completely different assumptions, then the implementations aren't truly substitutable.

System-design view

LSP helps us build:

        Payment Interface
              │
      ┌───────┼────────┐
      ↓       ↓        ↓
   Stripe  Razorpay  PayPal

The 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 > 0

Child:

accepts amount > 1000

The child is more restrictive.

LSP violation.


4. Child weakens postconditions

Parent promises:

payment returns transaction ID

Child returns:

None

The caller can no longer rely on the parent's guarantee.

LSP violation.


5. Child changes expected behavior

Parent:

save(data)

means:

data is persisted

Child:

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.

ISP

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):
        pass

Now suppose a Robot is also considered a worker:

class Robot(Worker):
    def work(self):
        print("Robot working")

    def eat(self):
        pass

    def sleep(self):
        pass

A 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
 └── Sleepable

Now 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):
        pass

This 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
photocopy

This 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):
        pass

Now 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):
        pass

We 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):
        pass

Now:

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
       ↓
    Reporter

The 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):
        pass

This 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 maintenance

So 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  AzureStorage

Instead 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/user

with:

GET     /profile
POST    /profile
DELETE  /profile
POST    /payment
POST    /orders
GET     /orders
POST    /upload
POST    /notifications

Different clients may need completely different subsets.

For example:

Mobile App
   ↓
Profile APIs
Order APIs

while:

Admin Dashboard
   ↓
User Management
Reports
Orders

and:

Payment Service
   ↓
Payment APIs
Refund APIs

A better API design separates capabilities:

/api/users
/api/orders
/api/payments
/api/refunds
/api/notifications
/api/uploads

The 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): pass

This 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 dependency

Mistake 5: Making interfaces artificially tiny

ISP doesn't mean:

Interface 1 → one method
Interface 2 → one method
Interface 3 → one method
Interface 4 → one method

That'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.

DIP

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
     ↓
MySQLDatabase

OrderService directly depends on MySQLDatabase. That's tight coupling.

Instead:

             Database
                ↑
                │
        ┌───────┴───────┐
        │               │
      MySQL           MongoDB
        ↑               ↑
        └───────┬───────┘
                │
          OrderService

Now 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
        pass

It answers questions like:

What should the application do?

Examples:

OrderService
PaymentService
UserService
CheckoutService
NotificationService

Low-Level Module

A low-level module handles implementation details.

For example:

MySQLDatabase
RedisCache
SMTPEmailService
StripePayment
AWSStorage

These 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
       ↓
Implementation

For example:

OrderService
     ↓
MySQL

The business logic is controlled by an implementation detail.

With DIP:

          Abstraction
          /          \
         ↑            ↑
         │            │
High-Level         Low-Level
Module             Module

For example:

             Repository
             Interface
             ↑       ↑
             │       │
       OrderService  MySQLRepository

Both depend on the abstraction.

Dependency direction

Instead of:

OrderService → MySQLRepository

we have:

OrderService → Repository ← MySQLRepository

The 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
     ↓
MySQLDatabase

Now 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 implementation

The 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):
        pass

Now 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
     ↓
MySQLDatabase

After

             Database
             Interface
             ↑       ↑
             │       │
           MySQL   PostgreSQL
             ↑       ↑
             └───┬───┘
                 │
            UserService

6.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 = database

Now the dependency is injected.

database = MySQLDatabase()

service = UserService(database)

The flow becomes:

Outside World
      ↓
Creates dependency
      ↓
MySQLDatabase
      ↓
Injects it
      ↓
UserService

DIP 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 = database

We 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
    ↓
FakeDatabase

Both satisfy:

Database Interface

Why this matters

You can test:

Business Logic
     ↓
Without
     ↓
Real Database

This 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
      ↓
Stripe

This is tightly coupled.

Instead:

             PaymentGateway
                   ↑
          ┌────────┼────────┐
          │        │        │
       Stripe   Razorpay   PayPal

The business logic depends on:

PaymentGateway

not:

Stripe

For example:

class PaymentGateway:

    def pay(self, amount):
        pass

Stripe:

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      PayPal

You 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 / Push

Your business logic can depend on abstractions such as:

Storage
Cache
Repository
MessagePublisher
NotificationService
PaymentGateway

rather 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 + b

Creating:

ICalculator
Calculator
CalculatorImpl

just 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  = Technique

Dependency 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
      ↑
StripePayment

The 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 logic

Now your business logic is tightly coupled to infrastructure.

Better:

OrderService
    ↓
Repository
Cache
EventPublisher
    ↑
    │
Concrete implementations

Mistake 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 = database

The 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 Design

7.1 SRP + OCP

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
            pass

This class has multiple reasons to change:

PaymentService
 ├── Stripe changes
 ├── Razorpay changes
 └── PayPal changes

This 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 code

So:

SRP helps create the right boundaries, while OCP helps extend those boundaries safely.


7.2 OCP + LSP

OCP+LSP

Suppose:

class PaymentProcessor:
    def pay(self, amount):
        pass

We 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 working

Therefore:

OCP allows us to add implementations, while LSP ensures those implementations remain valid substitutions.


7.3 ISP + DIP

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):
        pass

Suppose 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):
        pass

Then:

class CheckoutService:

    def __init__(self, order_service, payment_service):
        self.order_service = order_service
        self.payment_service = payment_service

Now the checkout logic depends only on the abstractions it actually needs.

Together

ISP
 ↓
Small, focused interfaces
 ↓
DIP
 ↓
Depend on those abstractions
 ↓
Lower coupling

So:

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:

  1. Validate the order

  2. Process payment

  3. Save the order

  4. 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 directly

This creates several problems.

Checkout
 ├── Order validation
 ├── Stripe
 ├── Razorpay
 ├── MySQL
 └── Email

The 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
CheckoutService

Now each component has a focused responsibility.


Step 2: ISP

Create focused interfaces:

PaymentGateway
    └── pay()

OrderRepository
    ├── save()
    └── find()

NotificationService
    └── send()

Don't create one giant:

ECommerceInterface

containing 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_service

It doesn't know whether we're using:

Stripe
MySQL
PostgreSQL
SendGrid
Kafka

Step 4: OCP

Add new implementations without modifying CheckoutService.

For example:

PaymentGateway
      ↑
 ┌────┼─────┐
 ↓    ↓     ↓
Stripe Razorpay PayPal

Want to add:

PhonePe

Create:

class PhonePePayment(PaymentGateway):
    def pay(self, amount):
        ...

No modification to CheckoutService.


Step 5: LSP

Every payment implementation must respect the contract:

PaymentGateway
      ↑
      │
 ┌────┼─────────────┐
 ↓    ↓             ↓
Stripe Razorpay   PayPal

Any 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       SMS

Now 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 details

How SOLID Creates a Better System

Without SOLID:

                    Checkout
                       │
          ┌────────────┼─────────────┐
          ↓            ↓             ↓
        MySQL        Stripe        Email
          │            │             │
          └────────────┼─────────────┘
                       ↓
                  Tightly Coupled

Changing 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        Push

Now 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 substitutable

This 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 complexity

For example, creating:

Interface
   ↓
Factory
   ↓
Strategy
   ↓
Implementation

for 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
                    ↓
                  Balance

Good 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.

Enjoyed this article?

Share it with your network to help others level up their system design skills.

Discussion0

Join the Discussion

Sign in to leave comments, reply to others, or like insights.

Sign In to ScaleDojo

No comments yet. Be the first to start the thread!

Enjoyed this? Get more like it.

New levels, features, and one concept worth knowing - straight to your inbox.

No spam, ever. Unsubscribe in one click.

Enjoyed this content?

Your support keeps us creating free resources

We put a lot of hours into researching and writing these guides. If it helped you, consider buying us a coffee. Every bit goes toward keeping ScaleDojo's content free and growing.

$

One-time payment via Stripe. ScaleDojo account required.