Seshadri Logo
Seshadri
Seshadri Logo
Seshadri

ยฉ 2026 All rights reserved.

LinkedInDev CommunityTwitterGitHub
BlogTags
Back to Blog

SOLID Principles in Python: A Visual and Practical Guide

February 28, 2026โ€ข25 min readโ€ข4,803 words
PythonSOLIDDesign PatternsClean CodeSoftware ArchitectureBest Practices

SOLID Principles in Python: A Visual and Practical Guide

If you've ever inherited a codebase and thought "Why does everything break when I change one small thing?"... chances are, nobody followed the SOLID principles.

SOLID isn't some academic thing you learn and forget. These five principles are what separate codebases that grow gracefully from ones that turn into a nightmare after six months. And Python, with its protocols and duck typing, makes them really clean to implement.

We'll walk through each principle using a real e-commerce platform as our running example. Products, orders, payments, shipping, notifications. The kind of stuff you'll actually build at work.


What is SOLID?

SOLID is an acronym for five design principles. The short version: they help you write code that's easier to maintain, easier to scale, and way less painful to change.

mindmap
  root((SOLID))
    S
      Single Responsibility
      One class = One job
    O
      Open/Closed
      Extend, don't modify
    L
      Liskov Substitution
      Subtypes are drop-in
    I
      Interface Segregation
      Small, focused interfaces
    D
      Dependency Inversion
      Depend on abstractions

Think of it like building an e-commerce platform. The payment module shouldn't break when you update the product catalog. Each piece has a clear boundary, talks to other pieces through clean contracts, and can be swapped out independently.


S: Single Responsibility Principle

"A class should have one, and only one, reason to change."

The Core Idea

Every class should do one thing and do it well. Picture this: you're building an online store. Should the Product class also handle inventory counts, tax calculations, and sending restock alerts? Obviously not. But you'd be surprised how often this happens.

Real-World Scenario: Product Management

flowchart TB
    subgraph BAD["โŒ WITHOUT SRP"]
        A["๐Ÿ—๏ธ ProductManager\nGod Class"]
        A --> B["calculate_price"]
        A --> C["save_to_db"]
        A --> D["check_inventory"]
        A --> E["send_alert"]
        A --> F["generate_invoice"]
    end

    BAD -.->|" refactor into "| GOOD

    subgraph GOOD["โœ… WITH SRP"]
        G["๐Ÿ“ฆ Product\ndata only"]
        H["๐Ÿ’ฐ PricingService"]
        I["๐Ÿ’พ ProductRepo"]
        J["๐Ÿ“Š InventoryTracker"]
        K["๐Ÿ”” AlertService"]
    end

    style BAD fill:#fef2f2,stroke:#ef4444,color:#991b1b
    style GOOD fill:#f0fdf4,stroke:#22c55e,color:#166534
    style A fill:#fee2e2,stroke:#ef4444,color:#991b1b
    style G fill:#dcfce7,stroke:#22c55e,color:#166534
    style H fill:#dcfce7,stroke:#22c55e,color:#166534
    style I fill:#dcfce7,stroke:#22c55e,color:#166534
    style J fill:#dcfce7,stroke:#22c55e,color:#166534
    style K fill:#dcfce7,stroke:#22c55e,color:#166534
โŒ Bad: The "God Class" that does everything

We've all seen this. One developer builds everything into a single class during a sprint. It works fine at first, until someone else needs to change anything.

class ProductManager:
    """Does EVERYTHING related to products. This will bite you eventually."""

    def __init__(self, name: str, sku: str, price: float, stock: int):
        self.name = name
        self.sku = sku
        self.price = price
        self.stock = stock

    def calculate_discounted_price(self, discount_pct: float) -> float:
        """Pricing logic (responsibility #1)"""
        return self.price * (1 - discount_pct / 100)

    def apply_tax(self, tax_rate: float) -> float:
        """Tax logic (responsibility #2)"""
        return self.price * (1 + tax_rate)

    def save(self):
        """Database logic (responsibility #3)"""
        db.execute(
            "INSERT INTO products (name, sku, price, stock) VALUES (%s, %s, %s, %s)",
            (self.name, self.sku, self.price, self.stock)
        )

    def check_stock(self) -> bool:
        """Inventory logic (responsibility #4)"""
        return self.stock > 0

    def reduce_stock(self, qty: int):
        """Inventory mutation (still responsibility #4)"""
        if self.stock >= qty:
            self.stock -= qty
        else:
            raise ValueError("Not enough stock!")

    def send_restock_alert(self):
        """Notification logic (responsibility #5)"""
        if self.stock < 10:
            email.send(
                to="warehouse@shop.com",
                subject=f"Low stock: {self.name}",
                body=f"Only {self.stock} units left for {self.sku}!"
            )

    def generate_invoice_line(self, qty: int) -> str:
        """Invoice logic (responsibility #6)"""
        return f"{qty}x {self.name} @ ${self.price:.2f} = ${self.price * qty:.2f}"

What's wrong?

  • Database schema changes? Edit ProductManager.
  • Switch from email to Slack alerts? Edit ProductManager.
  • Tax rules change? Edit ProductManager.
  • Every change risks breaking unrelated features.
โœ… Good: Each class has one job
from dataclasses import dataclass


@dataclass
class Product:
    """Only holds product data, nothing else."""
    name: str
    sku: str
    base_price: float


class PricingService:
    """Only calculates prices and taxes."""

    def with_discount(self, product: Product, discount_pct: float) -> float:
        return product.base_price * (1 - discount_pct / 100)

    def with_tax(self, price: float, tax_rate: float) -> float:
        return price * (1 + tax_rate)


class ProductRepository:
    """Only handles database operations for products."""

    def save(self, product: Product):
        db.execute(
            "INSERT INTO products (name, sku, price) VALUES (%s, %s, %s)",
            (product.name, product.sku, product.base_price)
        )

    def find_by_sku(self, sku: str) -> Product:
        row = db.query("SELECT * FROM products WHERE sku = %s", (sku,))
        return Product(name=row["name"], sku=row["sku"], base_price=row["price"])


class InventoryTracker:
    """Only manages stock levels."""

    def __init__(self):
        self._stock: dict[str, int] = {}

    def in_stock(self, sku: str) -> bool:
        return self._stock.get(sku, 0) > 0

    def reduce(self, sku: str, qty: int):
        current = self._stock.get(sku, 0)
        if current < qty:
            raise ValueError(f"Not enough stock for {sku}")
        self._stock[sku] = current - qty

    def get_level(self, sku: str) -> int:
        return self._stock.get(sku, 0)


class RestockAlertService:
    """Only sends low-stock notifications."""

    def check_and_alert(self, sku: str, current_stock: int, threshold: int = 10):
        if current_stock < threshold:
            email.send(
                to="warehouse@shop.com",
                subject=f"Low stock alert: {sku}",
                body=f"Only {current_stock} units remaining!"
            )

Now each class changes for exactly ONE reason:

  • Tax rules change โ†’ only PricingService changes
  • Switch to MongoDB โ†’ only ProductRepository changes
  • Need Slack alerts โ†’ only RestockAlertService changes

๐Ÿ’ก Quick Test: Ask yourself: "If I describe what this class does, do I use the word AND?"

  • "This class manages products AND calculates prices AND sends alerts" โ†’ โŒ Violates SRP
  • "This class tracks inventory levels" โ†’ โœ… Single responsibility

O: Open/Closed Principle

"Software entities should be open for extension, but closed for modification."

The Core Idea

You should be able to add new behavior without touching existing code. This comes up constantly in e-commerce: new payment methods, new shipping carriers, new promotion types. If adding a feature means editing a working class, you're doing it wrong.

Real-World Scenario: Shipping Cost Calculator

Your store ships worldwide. You start with flat-rate shipping, then add express, then free shipping for orders over $50, then same-day delivery. You can see where this is going.

flowchart TB
    subgraph BAD["โŒ WITHOUT OCP"]
        A["ShippingCalculator"] --> B["if standard"]
        A --> C["if express"]
        A --> D["if overnight"]
        A --> E["if ??? ๐Ÿ’ฅ\nEdit forever!"]
    end

    subgraph GOOD["โœ… WITH OCP"]
        F["ShippingStrategy\nProtocol"]
        F --> G["Standard\n$5.99"]
        F --> H["Express\n$12.99"]
        F --> I["Overnight\n$24.99"]
        F --> J["๐Ÿ†• Free\n$0 over $50"]
    end

    style BAD fill:#fef2f2,stroke:#ef4444,color:#991b1b
    style GOOD fill:#f0fdf4,stroke:#22c55e,color:#166534
    style E fill:#fee2e2,stroke:#ef4444,color:#991b1b
    style J fill:#bbf7d0,stroke:#22c55e,color:#166534
    style F fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f
โŒ Bad: Adding new shipping means editing existing code
class ShippingCalculator:
    def calculate(self, order_total: float, weight_kg: float, method: str) -> float:
        if method == "standard":
            return 5.99 if weight_kg <= 5 else 5.99 + (weight_kg - 5) * 1.50
        elif method == "express":
            return 12.99 + weight_kg * 0.75
        elif method == "overnight":
            return 24.99 + weight_kg * 2.00
        elif method == "free":
            return 0.0 if order_total >= 50 else 5.99
        # ๐Ÿšจ New carrier next quarter? Add another elif...
        # ๐Ÿšจ Meanwhile, a typo in "express" breaks existing orders!
        else:
            raise ValueError(f"Unknown shipping method: {method}")

Every new shipping option means editing this class, and you risk breaking things that already work.

โœ… Good: Just plug in a new shipping class
from typing import Protocol
from dataclasses import dataclass


@dataclass
class ShippingRequest:
    order_total: float
    weight_kg: float
    destination: str


class ShippingStrategy(Protocol):
    """Contract: every shipping method must implement calculate()"""
    def calculate(self, request: ShippingRequest) -> float: ...
    def estimated_days(self) -> str: ...


class StandardShipping:
    def calculate(self, request: ShippingRequest) -> float:
        base = 5.99
        return base if request.weight_kg <= 5 else base + (request.weight_kg - 5) * 1.50

    def estimated_days(self) -> str:
        return "5-7 business days"


class ExpressShipping:
    def calculate(self, request: ShippingRequest) -> float:
        return 12.99 + request.weight_kg * 0.75

    def estimated_days(self) -> str:
        return "2-3 business days"


class OvernightShipping:
    def calculate(self, request: ShippingRequest) -> float:
        return 24.99 + request.weight_kg * 2.00

    def estimated_days(self) -> str:
        return "Next business day"


# โœ… New requirement: free shipping for orders over $50!
# Just add a class. ZERO changes to existing code!
class FreeShippingPromo:
    def calculate(self, request: ShippingRequest) -> float:
        return 0.0 if request.order_total >= 50 else 5.99

    def estimated_days(self) -> str:
        return "5-7 business days"


# The checkout service doesn't care WHICH strategy is used
def calculate_shipping(strategy: ShippingStrategy, request: ShippingRequest) -> dict:
    cost = strategy.calculate(request)
    return {
        "cost": cost,
        "delivery": strategy.estimated_days(),
        "total_with_shipping": request.order_total + cost,
    }


# Usage: swap strategies at runtime
req = ShippingRequest(order_total=75.00, weight_kg=2.5, destination="US")

print(calculate_shipping(StandardShipping(), req))
# {'cost': 5.99, 'delivery': '5-7 business days', 'total_with_shipping': 80.99}

print(calculate_shipping(FreeShippingPromo(), req))
# {'cost': 0.0, 'delivery': '5-7 business days', 'total_with_shipping': 75.0}

๐Ÿ’ก Quick Test: "To add a new shipping carrier, do I need to edit the existing calculator?"

  • Yes, I add another elif โ†’ โŒ Violates OCP
  • No, I create a new class โ†’ โœ… Open/Closed

L: Liskov Substitution Principle

"If S is a subtype of T, objects of type T may be replaced with objects of type S without breaking the program."

The Core Idea

If your checkout accepts a PaymentMethod, then every type of payment (credit card, PayPal, crypto) should work without surprises. No special cases, no random crashes.

Real-World Scenario: Payment Methods

flowchart TB
    subgraph BAD["โŒ VIOLATES LSP"]
        A["๐Ÿ’ณ PaymentMethod\ncharge amount"] --> B["CreditCard โœ…"]
        A --> C["GiftCard ๐Ÿ’ฅ\ncrashes on\nlow balance!"]
    end

    subgraph GOOD["โœ… FOLLOWS LSP"]
        D["๐Ÿ’ณ PaymentMethod\ncharge + can_cover"] --> E["CreditCard โœ…"]
        D --> F["GiftCard โœ…"]
        D --> G["PayPal โœ…"]
    end

    style BAD fill:#fef2f2,stroke:#ef4444,color:#991b1b
    style GOOD fill:#f0fdf4,stroke:#22c55e,color:#166534
    style C fill:#fee2e2,stroke:#ef4444,color:#991b1b
    style D fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f
โŒ Bad: Gift card breaks the payment contract
class PaymentMethod:
    def charge(self, amount: float) -> bool:
        """Charge the given amount."""
        return True


class CreditCard(PaymentMethod):
    def __init__(self, number: str, cvv: str):
        self.number = number
        self.cvv = cvv

    def charge(self, amount: float) -> bool:
        print(f"๐Ÿ’ณ Charged ${amount:.2f} to card ending {self.number[-4:]}")
        return True  # โœ… Always works for valid cards


class GiftCard(PaymentMethod):
    def __init__(self, code: str, balance: float):
        self.code = code
        self.balance = balance

    def charge(self, amount: float) -> bool:
        if amount > self.balance:
            # ๐Ÿ’ฅ Surprise! This FAILS when balance is low.
            # The caller trusted that charge() always works!
            raise ValueError(
                f"Gift card {self.code} has only ${self.balance:.2f}"
            )
        self.balance -= amount
        return True


# Checkout trusts that ALL payment methods just work
def process_checkout(payment: PaymentMethod, total: float):
    payment.charge(total)  # ๐Ÿ’ฅ Crashes with gift card if balance < total!
    print("Order confirmed!")

process_checkout(CreditCard("4111111111111111", "123"), 99.99)  # โœ… Fine
process_checkout(GiftCard("GIFT-50", 50.00), 99.99)  # ๐Ÿ’ฅ ValueError!
โœ… Good: Every payment method honors the contract
from abc import ABC, abstractmethod
from dataclasses import dataclass


@dataclass
class ChargeResult:
    success: bool
    amount_charged: float
    message: str


class PaymentMethod(ABC):
    @abstractmethod
    def can_cover(self, amount: float) -> bool:
        """Check if this method can handle the amount."""
        ...

    @abstractmethod
    def charge(self, amount: float) -> ChargeResult:
        """Charge the amount. Always returns a result, never crashes."""
        ...


class CreditCard(PaymentMethod):
    def __init__(self, number: str, limit: float = 10_000):
        self.number = number
        self.limit = limit

    def can_cover(self, amount: float) -> bool:
        return amount <= self.limit

    def charge(self, amount: float) -> ChargeResult:
        if not self.can_cover(amount):
            return ChargeResult(False, 0, f"Exceeds card limit ${self.limit:.2f}")
        self.limit -= amount
        return ChargeResult(True, amount, f"๐Ÿ’ณ Charged to card ending {self.number[-4:]}")


class GiftCard(PaymentMethod):
    def __init__(self, code: str, balance: float):
        self.code = code
        self.balance = balance

    def can_cover(self, amount: float) -> bool:
        return amount <= self.balance

    def charge(self, amount: float) -> ChargeResult:
        if not self.can_cover(amount):
            return ChargeResult(
                False, 0, f"Gift card {self.code} has only ${self.balance:.2f}"
            )
        self.balance -= amount
        return ChargeResult(True, amount, f"๐ŸŽ Charged to gift card {self.code}")


class PayPalWallet(PaymentMethod):
    def __init__(self, email: str, balance: float):
        self.email = email
        self.balance = balance

    def can_cover(self, amount: float) -> bool:
        return amount <= self.balance

    def charge(self, amount: float) -> ChargeResult:
        if not self.can_cover(amount):
            return ChargeResult(False, 0, "Insufficient PayPal balance")
        self.balance -= amount
        return ChargeResult(True, amount, f"๐Ÿ…ฟ๏ธ Charged via PayPal ({self.email})")


# โœ… Works with ANY payment method. No crashes, no surprises
def process_checkout(payment: PaymentMethod, total: float):
    if not payment.can_cover(total):
        print(f"โŒ This payment method can't cover ${total:.2f}")
        return

    result = payment.charge(total)
    if result.success:
        print(f"โœ… {result.message} | ${result.amount_charged:.2f}")
    else:
        print(f"โŒ Payment failed: {result.message}")


process_checkout(CreditCard("4111111111111111"), 99.99)
# โœ… ๐Ÿ’ณ Charged to card ending 1111 | $99.99

process_checkout(GiftCard("GIFT-50", 50.00), 99.99)
# โŒ This payment method can't cover $99.99

process_checkout(PayPalWallet("user@email.com", 200.00), 99.99)
# โœ… ๐Ÿ…ฟ๏ธ Charged via PayPal (user@email.com) | $99.99

๐Ÿ’ก Quick Test: "Can I replace CreditCard with GiftCard in checkout and everything still works?"

  • No, the gift card crashes โ†’ โŒ Violates LSP
  • Yes, every payment method returns a proper result โ†’ โœ… Liskov approved

I: Interface Segregation Principle

"No client should be forced to depend on methods it does not use."

The Core Idea

Don't create bloated interfaces that force classes to implement methods they don't need. In an e-commerce system, a digital product doesn't need calculate_shipping_weight(), and a physical product doesn't need generate_download_link(). So why put both in the same interface?

Real-World Scenario: Product Types

flowchart TB
    subgraph BAD["โŒ FAT INTERFACE"]
        A["Sellable\nget_price + get_weight\n+ download_url + shipping"]
        A --> B["๐Ÿ“ฆ Physical\nprice โœ… weight โœ…\ndownload ๐Ÿคท"]
        A --> C["๐Ÿ’ฟ Digital\nprice โœ… weight ๐Ÿคท\ndownload โœ…"]
    end

    subgraph GOOD["โœ… SEGREGATED INTERFACES"]
        D["Purchasable\nget_price"]
        E["Shippable\nget_weight"]
        F["Downloadable\nget_url"]
        D --> G["๐Ÿ“ฆ Physical"]
        E --> G
        D --> H["๐Ÿ’ฟ Digital"]
        F --> H
    end

    style BAD fill:#fef2f2,stroke:#ef4444,color:#991b1b
    style GOOD fill:#f0fdf4,stroke:#22c55e,color:#166534
    style C fill:#fee2e2,stroke:#ef4444,color:#991b1b
    style D fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f
    style E fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f
    style F fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f
โŒ Bad: One bloated interface forces fake implementations
from abc import ABC, abstractmethod


class Sellable(ABC):
    """Every product MUST implement ALL of these, even if they're irrelevant."""

    @abstractmethod
    def get_price(self) -> float: ...

    @abstractmethod
    def get_weight_kg(self) -> float: ...

    @abstractmethod
    def get_download_url(self) -> str: ...

    @abstractmethod
    def requires_shipping(self) -> bool: ...

    @abstractmethod
    def get_shipping_dimensions(self) -> tuple[float, float, float]: ...


class PhysicalProduct(Sellable):
    def __init__(self, name: str, price: float, weight: float):
        self.name = name
        self.price = price
        self.weight = weight

    def get_price(self) -> float:      return self.price          # โœ…
    def get_weight_kg(self) -> float:  return self.weight         # โœ…
    def requires_shipping(self) -> bool: return True              # โœ…
    def get_shipping_dimensions(self) -> tuple: return (30, 20, 15) # โœ…

    def get_download_url(self) -> str:
        raise NotImplementedError("Physical products aren't downloadable!")  # ๐Ÿคท


class DigitalProduct(Sellable):
    def __init__(self, name: str, price: float, url: str):
        self.name = name
        self.price = price
        self.url = url

    def get_price(self) -> float:      return self.price          # โœ…
    def get_download_url(self) -> str: return self.url            # โœ…
    def requires_shipping(self) -> bool: return False             # โœ…

    def get_weight_kg(self) -> float:
        raise NotImplementedError("Digital products have no weight!")  # ๐Ÿคท

    def get_shipping_dimensions(self) -> tuple:
        raise NotImplementedError("Digital products have no dimensions!")  # ๐Ÿคท

Every digital product has 2 useless methods. Every physical product has 1 useless method. That's not just a code smell, it's a maintenance headache waiting to happen.

โœ… Good: Small, focused interfaces
from abc import ABC, abstractmethod


class Purchasable(ABC):
    """Anything that can be bought."""
    @abstractmethod
    def get_price(self) -> float: ...

    @abstractmethod
    def get_display_name(self) -> str: ...


class Shippable(ABC):
    """Anything that needs physical delivery."""
    @abstractmethod
    def get_weight_kg(self) -> float: ...

    @abstractmethod
    def get_dimensions(self) -> tuple[float, float, float]: ...


class Downloadable(ABC):
    """Anything delivered digitally."""
    @abstractmethod
    def get_download_url(self) -> str: ...

    @abstractmethod
    def get_file_size_mb(self) -> float: ...


# Physical products: purchasable + shippable
class PhysicalProduct(Purchasable, Shippable):
    def __init__(self, name: str, price: float, weight: float):
        self.name = name
        self.price = price
        self.weight = weight

    def get_price(self) -> float:       return self.price
    def get_display_name(self) -> str:  return self.name
    def get_weight_kg(self) -> float:   return self.weight
    def get_dimensions(self) -> tuple:  return (30.0, 20.0, 15.0)


# Digital products: purchasable + downloadable
class DigitalProduct(Purchasable, Downloadable):
    def __init__(self, name: str, price: float, url: str, size_mb: float):
        self.name = name
        self.price = price
        self.url = url
        self.size_mb = size_mb

    def get_price(self) -> float:        return self.price
    def get_display_name(self) -> str:   return self.name
    def get_download_url(self) -> str:   return self.url
    def get_file_size_mb(self) -> float: return self.size_mb


# Course bundle: purchasable + downloadable (no shipping!)
class OnlineCourse(Purchasable, Downloadable):
    def __init__(self, title: str, price: float, platform_url: str):
        self.title = title
        self.price = price
        self.platform_url = platform_url

    def get_price(self) -> float:        return self.price
    def get_display_name(self) -> str:   return f"๐Ÿ“š {self.title}"
    def get_download_url(self) -> str:   return self.platform_url
    def get_file_size_mb(self) -> float: return 0  # Streamed, not downloaded


# Functions only depend on the interface they need
def calculate_shipping(item: Shippable) -> float:
    """Only needs Shippable. Doesn't care about price or downloads."""
    return item.get_weight_kg() * 2.50

def send_download_link(item: Downloadable, customer_email: str):
    """Only needs Downloadable. Doesn't care about weight."""
    print(f"๐Ÿ“ง Sending download link to {customer_email}: {item.get_download_url()}")

def add_to_cart(item: Purchasable):
    """Only needs Purchasable. Works with any product type."""
    print(f"๐Ÿ›’ Added {item.get_display_name()} | ${item.get_price():.2f}")


# Usage
laptop = PhysicalProduct("MacBook Pro", 2499.99, 2.1)
ebook = DigitalProduct("Python Mastery", 29.99, "https://store.com/download/python", 15.0)
course = OnlineCourse("SOLID Principles Bootcamp", 49.99, "https://learn.com/solid")

add_to_cart(laptop)   # ๐Ÿ›’ Added MacBook Pro | $2499.99
add_to_cart(ebook)    # ๐Ÿ›’ Added Python Mastery | $29.99
add_to_cart(course)   # ๐Ÿ›’ Added ๐Ÿ“š SOLID Principles Bootcamp | $49.99

calculate_shipping(laptop)        # โœ… Works, laptop is Shippable
send_download_link(ebook, "u@mail.com")  # โœ… Works, ebook is Downloadable
# calculate_shipping(ebook)       # โŒ Type error! ebook isn't Shippable. Caught early!

๐Ÿ’ก Quick Test: "Does this class have methods that don't make sense for it?"

  • Yes, DigitalProduct has get_weight_kg() โ†’ โŒ Violates ISP
  • No, each class only implements what's relevant โ†’ โœ… Properly segregated

D: Dependency Inversion Principle

"High-level modules should not depend on low-level modules. Both should depend on abstractions."

The Core Idea

Your core business logic (checkout, order processing) should never be hardwired to specific implementations (Stripe, SendGrid, PostgreSQL). Instead, depend on abstractions so you can swap providers without rewriting your entire checkout flow.

Real-World Scenario: Order Notification System

flowchart TB
    subgraph BAD["โŒ WITHOUT DIP"]
        A["OrderService"] -->|hardcoded| B["SendGrid"]
        A -->|hardcoded| C["PostgreSQL"]
    end

    subgraph GOOD["โœ… WITH DIP"]
        D["OrderService"]
        D --> E["Notifier Protocol"]
        D --> F["Repo Protocol"]
        E --> G["๐Ÿ“ง SendGrid"]
        E --> H["๐Ÿ“ฑ Twilio"]
        E --> I["๐Ÿ’ฌ Slack"]
        F --> J["๐Ÿ˜ Postgres"]
        F --> K["๐Ÿƒ MongoDB"]
    end

    style BAD fill:#fef2f2,stroke:#ef4444,color:#991b1b
    style GOOD fill:#f0fdf4,stroke:#22c55e,color:#166534
    style B fill:#fee2e2,stroke:#ef4444,color:#991b1b
    style C fill:#fee2e2,stroke:#ef4444,color:#991b1b
    style E fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f
    style F fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f
    style G fill:#dcfce7,stroke:#22c55e,color:#166534
    style H fill:#dcfce7,stroke:#22c55e,color:#166534
    style I fill:#dcfce7,stroke:#22c55e,color:#166534
    style J fill:#dcfce7,stroke:#22c55e,color:#166534
    style K fill:#dcfce7,stroke:#22c55e,color:#166534
โŒ Bad: Checkout is welded to specific providers
import sendgrid
import stripe
import psycopg2


class OrderService:
    def __init__(self):
        # ๐Ÿšจ Hardcoded to specific providers!
        self.db = psycopg2.connect("postgresql://localhost/shop")
        self.email_client = sendgrid.SendGridAPIClient(api_key="SG.xxx")
        self.stripe = stripe

    def place_order(self, user_email: str, product: str, amount: float):
        # Save to PostgreSQL directly
        cursor = self.db.cursor()
        cursor.execute(
            "INSERT INTO orders (email, product, amount) VALUES (%s, %s, %s)",
            (user_email, product, amount)
        )
        self.db.commit()

        # Charge via Stripe directly
        self.stripe.api_key = "sk_xxx"
        self.stripe.Charge.create(amount=int(amount * 100), currency="usd")

        # Send via SendGrid directly
        self.email_client.send(
            from_email="orders@shop.com",
            to_emails=user_email,
            subject="Order confirmed!",
            html_content=f"Thanks for buying {product}!",
        )

Want to test this? You'll need a real PostgreSQL database, a Stripe account, and a SendGrid API key. Want to switch to DynamoDB? Time to rewrite everything.

โœ… Good: Depend on abstractions, swap providers freely
from typing import Protocol
from dataclasses import dataclass, field
from datetime import datetime


@dataclass
class Order:
    order_id: str
    customer_email: str
    items: list[str]
    total: float
    created_at: datetime = field(default_factory=datetime.now)


# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• ABSTRACTIONS (the contracts) โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

class OrderRepository(Protocol):
    """How orders are stored. We don't care where."""
    def save(self, order: Order) -> None: ...
    def find_by_id(self, order_id: str) -> Order | None: ...


class PaymentGateway(Protocol):
    """How payments are processed. We don't care how."""
    def charge(self, amount: float, customer_email: str) -> bool: ...


class NotificationChannel(Protocol):
    """How customers are notified. We don't care which channel."""
    def send(self, to: str, subject: str, body: str) -> None: ...


# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• IMPLEMENTATIONS (swap these freely) โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

class PostgresOrderRepo:
    def save(self, order: Order) -> None:
        print(f"  ๐Ÿ˜ Saved order {order.order_id} to PostgreSQL")

    def find_by_id(self, order_id: str) -> Order | None:
        print(f"  ๐Ÿ˜ Looking up order {order_id} in PostgreSQL")
        return None


class DynamoDBOrderRepo:
    def save(self, order: Order) -> None:
        print(f"  โ˜๏ธ Saved order {order.order_id} to DynamoDB")

    def find_by_id(self, order_id: str) -> Order | None:
        print(f"  โ˜๏ธ Looking up order {order_id} in DynamoDB")
        return None


class StripeGateway:
    def charge(self, amount: float, customer_email: str) -> bool:
        print(f"  ๐Ÿ’ณ Stripe: charged ${amount:.2f} for {customer_email}")
        return True


class RazorpayGateway:
    def charge(self, amount: float, customer_email: str) -> bool:
        print(f"  ๐Ÿ‡ฎ๐Ÿ‡ณ Razorpay: charged โ‚น{amount * 83:.2f} for {customer_email}")
        return True


class EmailNotification:
    def send(self, to: str, subject: str, body: str) -> None:
        print(f"  ๐Ÿ“ง Email to {to}: {subject}")


class SMSNotification:
    def send(self, to: str, subject: str, body: str) -> None:
        print(f"  ๐Ÿ“ฑ SMS to {to}: {body[:50]}...")


class SlackNotification:
    def send(self, to: str, subject: str, body: str) -> None:
        print(f"  ๐Ÿ’ฌ Slack #{to}: {subject}")


# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• CORE SERVICE (doesn't know any provider) โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

class OrderService:
    """High-level business logic. Depends only on abstractions."""

    def __init__(
        self,
        repo: OrderRepository,
        payment: PaymentGateway,
        notifier: NotificationChannel,
    ):
        self.repo = repo
        self.payment = payment
        self.notifier = notifier

    def place_order(self, order: Order) -> bool:
        print(f"\n๐Ÿ›’ Processing order {order.order_id}...")

        # 1. Charge payment
        if not self.payment.charge(order.total, order.customer_email):
            print("  โŒ Payment failed!")
            return False

        # 2. Save order
        self.repo.save(order)

        # 3. Notify customer
        self.notifier.send(
            to=order.customer_email,
            subject=f"Order {order.order_id} confirmed!",
            body=f"Thanks for your purchase of ${order.total:.2f}!"
        )

        print(f"  โœ… Order {order.order_id} completed!")
        return True


# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• USAGE: wire up different configurations โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

order = Order(
    order_id="ORD-2026-001",
    customer_email="customer@example.com",
    items=["Python Course", "SOLID eBook"],
    total=79.98
)

# Config 1: US store (Stripe + PostgreSQL + Email)
us_store = OrderService(
    repo=PostgresOrderRepo(),
    payment=StripeGateway(),
    notifier=EmailNotification(),
)
us_store.place_order(order)

# Config 2: India store (Razorpay + DynamoDB + SMS)
india_store = OrderService(
    repo=DynamoDBOrderRepo(),
    payment=RazorpayGateway(),
    notifier=SMSNotification(),
)
india_store.place_order(order)

Output:

๐Ÿ›’ Processing order ORD-2026-001...
  ๐Ÿ’ณ Stripe: charged $79.98 for customer@example.com
  ๐Ÿ˜ Saved order ORD-2026-001 to PostgreSQL
  ๐Ÿ“ง Email to customer@example.com: Order ORD-2026-001 confirmed!
  โœ… Order ORD-2026-001 completed!

๐Ÿ›’ Processing order ORD-2026-001...
  ๐Ÿ‡ฎ๐Ÿ‡ณ Razorpay: charged โ‚น6638.34 for customer@example.com
  โ˜๏ธ Saved order ORD-2026-001 to DynamoDB
  ๐Ÿ“ฑ SMS to customer@example.com: Thanks for your purchase of $79.98!...
  โœ… Order ORD-2026-001 completed!

๐Ÿ’ก Quick Test: "If I want to switch from Stripe to Razorpay, do I rewrite my OrderService?"

  • Yes โ†’ โŒ Violates DIP
  • No, I just inject RazorpayGateway() โ†’ โœ… Dependency Inversion

Putting It All Together: Complete E-Commerce Checkout

Let's see all five SOLID principles working together in a full checkout flow:

flowchart TB
    CS["๐ŸŽฏ CheckoutService\nvalidate > pay > ship > notify > save"]

    CS --> V["CartValidator"]
    CS --> P["PaymentGateway"]
    CS --> S["ShippingStrategy"]
    CS --> N["Notifier"]
    CS --> R["OrderRepo"]

    V -.-> V1["StockValidator"]
    P -.-> P1["Stripe"]
    P -.-> P2["PayPal"]
    S -.-> S1["Standard"]
    S -.-> S2["Express"]
    N -.-> N1["Email"]
    N -.-> N2["SMS"]
    R -.-> R1["PostgreSQL"]
    R -.-> R2["DynamoDB"]

    style CS fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f
    style V fill:#f3e8ff,stroke:#a855f7,color:#581c87
    style P fill:#f3e8ff,stroke:#a855f7,color:#581c87
    style S fill:#f3e8ff,stroke:#a855f7,color:#581c87
    style N fill:#f3e8ff,stroke:#a855f7,color:#581c87
    style R fill:#f3e8ff,stroke:#a855f7,color:#581c87
    style V1 fill:#dcfce7,stroke:#22c55e,color:#166534
    style P1 fill:#dcfce7,stroke:#22c55e,color:#166534
    style P2 fill:#dcfce7,stroke:#22c55e,color:#166534
    style S1 fill:#dcfce7,stroke:#22c55e,color:#166534
    style S2 fill:#dcfce7,stroke:#22c55e,color:#166534
    style N1 fill:#dcfce7,stroke:#22c55e,color:#166534
    style N2 fill:#dcfce7,stroke:#22c55e,color:#166534
    style R1 fill:#dcfce7,stroke:#22c55e,color:#166534
    style R2 fill:#dcfce7,stroke:#22c55e,color:#166534
๐Ÿ›’ Full Checkout: All 5 SOLID Principles in Action
from typing import Protocol
from dataclasses import dataclass, field
from datetime import datetime


# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# ๐Ÿ“ฆ DOMAIN MODELS (SRP: only hold data)
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
@dataclass
class CartItem:
    product_name: str
    sku: str
    quantity: int
    unit_price: float

    @property
    def subtotal(self) -> float:
        return self.quantity * self.unit_price


@dataclass
class Cart:
    items: list[CartItem]
    customer_email: str

    @property
    def total(self) -> float:
        return sum(item.subtotal for item in self.items)

    @property
    def item_count(self) -> int:
        return sum(item.quantity for item in self.items)


@dataclass
class CheckoutResult:
    success: bool
    order_id: str
    message: str
    total_charged: float = 0.0


# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# ๐Ÿ“‹ PROTOCOLS: one method each (ISP)
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
class CartValidator(Protocol):
    def validate(self, cart: Cart) -> tuple[bool, str]: ...

class PaymentGateway(Protocol):
    def charge(self, amount: float, email: str) -> bool: ...

class ShippingCalculator(Protocol):
    def calculate(self, cart: Cart) -> float: ...

class Notifier(Protocol):
    def send(self, to: str, subject: str, body: str) -> None: ...

class OrderStore(Protocol):
    def save(self, order_id: str, cart: Cart, total: float) -> None: ...


# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# ๐Ÿ”Œ IMPLEMENTATIONS (OCP: add new, don't edit)
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
class StockValidator:
    """Checks that all items are in stock."""
    def validate(self, cart: Cart) -> tuple[bool, str]:
        # In real code: check inventory database
        if cart.item_count == 0:
            return False, "Cart is empty"
        return True, "All items in stock"

class FraudValidator:
    """Checks for suspicious orders."""
    def validate(self, cart: Cart) -> tuple[bool, str]:
        if cart.total > 10_000:
            return False, "Order exceeds fraud threshold, needs manual review"
        return True, "Order looks legitimate"


class StripePayment:
    def charge(self, amount: float, email: str) -> bool:
        print(f"    ๐Ÿ’ณ Stripe: ${amount:.2f} charged to {email}")
        return True

class PayPalPayment:
    def charge(self, amount: float, email: str) -> bool:
        print(f"    ๐Ÿ…ฟ๏ธ  PayPal: ${amount:.2f} charged to {email}")
        return True


class FlatRateShipping:
    def calculate(self, cart: Cart) -> float:
        return 5.99

class FreeOver50Shipping:
    def calculate(self, cart: Cart) -> float:
        return 0.0 if cart.total >= 50 else 5.99


class EmailNotifier:
    def send(self, to: str, subject: str, body: str) -> None:
        print(f"    ๐Ÿ“ง Email โ†’ {to}: {subject}")

class SlackNotifier:
    def send(self, to: str, subject: str, body: str) -> None:
        print(f"    ๐Ÿ’ฌ Slack โ†’ #orders: {subject}")


class PostgresStore:
    def save(self, order_id: str, cart: Cart, total: float) -> None:
        print(f"    ๐Ÿ˜ Saved {order_id} (${total:.2f}) to PostgreSQL")

class InMemoryStore:
    """Great for testing. No database needed!"""
    def __init__(self):
        self.orders = {}

    def save(self, order_id: str, cart: Cart, total: float) -> None:
        self.orders[order_id] = {"cart": cart, "total": total}
        print(f"    ๐Ÿงช Saved {order_id} (${total:.2f}) to memory (test mode)")


# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# ๐ŸŽฏ CHECKOUT SERVICE (SRP: only orchestrates)
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
class CheckoutService:
    """
    Pure orchestration. Knows what to do, not how to do it.

    SRP: only coordinates the checkout flow
    OCP: new payment? New class, zero edits here
    LSP: any Protocol implementation works
    ISP: each dependency has exactly 1 method
    DIP: depends on abstractions, not Stripe/Postgres/etc.
    """

    def __init__(
        self,
        validator: CartValidator,
        payment: PaymentGateway,
        shipping: ShippingCalculator,
        notifier: Notifier,
        store: OrderStore,
    ):
        self.validator = validator
        self.payment = payment
        self.shipping = shipping
        self.notifier = notifier
        self.store = store

    def checkout(self, cart: Cart) -> CheckoutResult:
        order_id = f"ORD-{datetime.now().strftime('%Y%m%d%H%M%S')}"
        print(f"\n๐Ÿ›’ Checkout started: {order_id}")
        print(f"   {cart.item_count} items, subtotal: ${cart.total:.2f}")

        # Step 1: Validate
        valid, reason = self.validator.validate(cart)
        if not valid:
            print(f"   โŒ {reason}")
            return CheckoutResult(False, order_id, reason)

        # Step 2: Calculate shipping
        shipping = self.shipping.calculate(cart)
        grand_total = cart.total + shipping
        print(f"   ๐Ÿ“ฆ Shipping: ${shipping:.2f} | Total: ${grand_total:.2f}")

        # Step 3: Charge payment
        if not self.payment.charge(grand_total, cart.customer_email):
            return CheckoutResult(False, order_id, "Payment declined")

        # Step 4: Save order
        self.store.save(order_id, cart, grand_total)

        # Step 5: Notify customer
        items_list = ", ".join(f"{i.product_name} x{i.quantity}" for i in cart.items)
        self.notifier.send(
            to=cart.customer_email,
            subject=f"Order {order_id} confirmed!",
            body=f"You ordered: {items_list}. Total: ${grand_total:.2f}"
        )

        print(f"   โœ… Order {order_id} completed!")
        return CheckoutResult(True, order_id, "Success", grand_total)


# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# ๐Ÿš€ WIRE IT UP: different configs, same service
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

cart = Cart(
    items=[
        CartItem("Python SOLID Course", "COURSE-001", 1, 49.99),
        CartItem("Clean Code eBook", "EBOOK-002", 2, 19.99),
    ],
    customer_email="developer@example.com"
)

# Production config: Stripe + PostgreSQL + Email
prod_checkout = CheckoutService(
    validator=StockValidator(),
    payment=StripePayment(),
    shipping=FreeOver50Shipping(),
    notifier=EmailNotifier(),
    store=PostgresStore(),
)
prod_checkout.checkout(cart)

# Test config: PayPal + InMemory + Slack
test_checkout = CheckoutService(
    validator=FraudValidator(),
    payment=PayPalPayment(),
    shipping=FlatRateShipping(),
    notifier=SlackNotifier(),
    store=InMemoryStore(),
)
test_checkout.checkout(cart)

Output:

๐Ÿ›’ Checkout started: ORD-20260228143022
   3 items, subtotal: $89.97
   ๐Ÿ“ฆ Shipping: $0.00 | Total: $89.97
    ๐Ÿ’ณ Stripe: $89.97 charged to developer@example.com
    ๐Ÿ˜ Saved ORD-20260228143022 ($89.97) to PostgreSQL
    ๐Ÿ“ง Email โ†’ developer@example.com: Order ORD-20260228143022 confirmed!
   โœ… Order ORD-20260228143022 completed!

๐Ÿ›’ Checkout started: ORD-20260228143022
   3 items, subtotal: $89.97
   ๐Ÿ“ฆ Shipping: $5.99 | Total: $95.96
    ๐Ÿ…ฟ๏ธ  PayPal: $95.96 charged to developer@example.com
    ๐Ÿงช Saved ORD-20260228143022 ($95.96) to memory (test mode)
    ๐Ÿ’ฌ Slack โ†’ #orders: Order ORD-20260228143022 confirmed!
   โœ… Order ORD-20260228143022 completed!

SOLID Cheat Sheet

PrincipleE-Commerce ExampleSmell if Violated
S Single ResponsibilityPricingService only prices, InventoryTracker only tracks stockOne class handles pricing AND inventory AND emails
O Open/ClosedAdd FreeShippingPromo without editing ShippingCalculatorEvery new shipping method means editing a giant if/elif
L Liskov SubstitutionGiftCard and CreditCard both return ChargeResultGiftCard.charge() throws surprise exceptions
I Interface SegregationPhysicalProduct is Shippable, DigitalProduct is DownloadableDigitalProduct forced to implement get_weight()
D Dependency InversionOrderService takes PaymentGateway protocol, not StripeGatewayCan't test without a real Stripe account

When to Apply SOLID

flowchart TD
    A["๐Ÿค” Should I apply SOLID?"] --> B{"What are you building?"}
    B -->|"Quick script"| C["Keep it simple ๐ŸŸข"]
    B -->|"Real product"| D["Apply SOLID ๐Ÿ”ต"]
    B -->|"Library or SDK"| E["SOLID is essential ๐ŸŸฃ"]

    style A fill:#f3e8ff,stroke:#a855f7,color:#581c87
    style B fill:#fff7ed,stroke:#f97316,color:#9a3412
    style C fill:#dcfce7,stroke:#22c55e,color:#166534
    style D fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f
    style E fill:#f3e8ff,stroke:#a855f7,color:#581c87

The goal isn't perfect code. The goal is code that doesn't fight you when things change. And in e-commerce, things change every sprint.


Key Takeaways

  1. SRP: If ProductManager handles pricing AND inventory AND alerts, split it into 3 classes
  2. OCP: New shipping carrier = new class, not another elif in a 200-line function
  3. LSP: Every payment method should return results consistently, not throw surprise errors
  4. ISP: Digital products shouldn't implement get_weight(). Use separate Downloadable and Shippable interfaces
  5. DIP: Your CheckoutService should accept a PaymentGateway protocol, not be hardcoded to Stripe

Where to start? Look at your biggest, most-changed class. If it has "AND" in its description ("manages products AND calculates shipping AND sends emails"), that's your first SRP refactor. Break it up, and everything else falls into place.

Related Posts

Building Scalable Web Applications - Lessons from the Field

Key principles and practices I have learned while building scalable web applications across different domains.

September 15, 2024โ€ข2 min read