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
PricingServicechanges - Switch to MongoDB โ only
ProductRepositorychanges - Need Slack alerts โ only
RestockAlertServicechanges
๐ก 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,
DigitalProducthasget_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
| Principle | E-Commerce Example | Smell if Violated |
|---|---|---|
| S Single Responsibility | PricingService only prices, InventoryTracker only tracks stock | One class handles pricing AND inventory AND emails |
| O Open/Closed | Add FreeShippingPromo without editing ShippingCalculator | Every new shipping method means editing a giant if/elif |
| L Liskov Substitution | GiftCard and CreditCard both return ChargeResult | GiftCard.charge() throws surprise exceptions |
| I Interface Segregation | PhysicalProduct is Shippable, DigitalProduct is Downloadable | DigitalProduct forced to implement get_weight() |
| D Dependency Inversion | OrderService takes PaymentGateway protocol, not StripeGateway | Can'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
- SRP: If
ProductManagerhandles pricing AND inventory AND alerts, split it into 3 classes - OCP: New shipping carrier = new class, not another
elifin a 200-line function - LSP: Every payment method should return results consistently, not throw surprise errors
- ISP: Digital products shouldn't implement
get_weight(). Use separateDownloadableandShippableinterfaces - DIP: Your
CheckoutServiceshould accept aPaymentGatewayprotocol, 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.