SOLID Principles
S Single Responsibility Principle
A class should have only one reason to change.
❌ Violates SRP
class Report:
def generate(self): ...
def format_as_html(self): ...
def save_to_disk(self): ...
✅ Separate responsibilities
class Report:
def generate(self): ...
class HTMLFormatter:
def format(self, report): ...
class ReportSaver:
def save(self, report): ...
O Open/Closed Principle
Open for extension, closed for modification.
❌ Must modify to extend
class PriceCalculator:
def calculate(self, product, type):
if type == "percentage":
return product.price * 0.9
elif type == "fixed":
return product.price - 10
# Must add elif here every time
✅ Extend via new classes
class DiscountStrategy(ABC):
@abstractmethod
def apply(self, price: float) -> float: ...
class PercentageDiscount(DiscountStrategy):
def __init__(self, rate):
self.rate = rate
def apply(self, price):
return price * (1 - self.rate)
# New discounts: just add a new class!
L Liskov Substitution Principle
Subclasses must be substitutable for their base classes.
❌ Square breaks Rectangle
class Rectangle:
def set_width(self, w):
self.width = w
def set_height(self, h):
self.height = h
class Square(Rectangle):
def set_width(self, w):
self.width = self.height = w
# breaks Rectangle's assumption!
✅ Use shared abstraction
class Shape(ABC):
@abstractmethod
def area(self) -> float: ...
class Rectangle(Shape):
def area(self): ...
class Square(Shape):
def area(self): ...
I Interface Segregation Principle
Clients shouldn't depend on methods they don't use.
❌ Fat interface
class Animal(ABC):
def eat(self): ...
def sleep(self): ...
def fly(self): ...
# penguins can't fly!
✅ Small, focused interfaces
class Eater(ABC):
@abstractmethod
def eat(self): ...
class Flyer(ABC):
@abstractmethod
def fly(self): ...
class Bird(Eater, Flyer): ...
class Penguin(Eater): ... # no fly
D Dependency Inversion Principle
Depend on abstractions, not concretions.
❌ Tightly coupled
class DataProcessor:
def __init__(self):
self.db = MySQLDatabase()
# can't swap or test!
✅ Inject dependency
class Database(ABC):
@abstractmethod
def save(self, data): ...
class DataProcessor:
def __init__(self, db: Database):
self.db = db
# Easy to swap:
processor = DataProcessor(MySQL())
processor = DataProcessor(Mock())
SOLID in Practice: Refactoring a Messy Codebase
A real-world example applying all SOLID principles step by step to a payment processing system.
Initial Messy Code
class PaymentProcessor:
def process_credit_card_payment(self, amount, card_number, card_cvv):
if len(card_number) != 16: raise ValueError("Invalid card")
if len(card_cvv) != 3: raise ValueError("Invalid CVV")
print(f"Charging £{amount} to {card_number}")
def process_paypal_payment(self, amount, email):
if not email: raise ValueError("No email")
print(f"Charging £{amount} via PayPal to {email}")
def process_apple_pay_payment(self, amount, token):
print(f"Charging £{amount} via Apple Pay")
def refund_payment(self, payment_id): ...
def send_receipt_email(self, customer_email, amount): ...
def generate_invoice_pdf(self, order_id): ...
Problems: Handles 3 payment methods + refunds + emails + PDFs in one class. Adding new payment method requires modifying the class. Different methods don't follow a consistent contract. Tightly coupled to Stripe, PayPal, Apple.
Step 1 — Single Responsibility (S)
from abc import ABC, abstractmethod
class CardValidator:
def validate(self, card_number, cvv):
if len(card_number) != 16: raise ValueError("Invalid card")
if len(cvv) != 3: raise ValueError("Invalid CVV")
class StripePaymentProcessor:
def __init__(self, validator: CardValidator):
self.validator = validator
def process(self, amount, card_number, cvv):
self.validator.validate(card_number, cvv)
print(f"Processing via Stripe: £{amount}")
return f"stripe_{id(self)}"
Step 2 — Open/Closed (O)
class PaymentProcessor(ABC):
@abstractmethod
def process(self, amount, **kwargs) -> str:
"""Process payment. Returns transaction ID."""
pass
class StripeProcessor(PaymentProcessor):
def process(self, amount, card_number, cvv):
return "stripe_txn_123"
class PayPalProcessor(PaymentProcessor):
def process(self, amount, email):
return "paypal_txn_123"
# Adding ApplePay = NEW class, NOT modifying old ones!
class ApplePayProcessor(PaymentProcessor):
def process(self, amount, token):
return "apple_txn_123"
Step 3 — Liskov Substitution (L)
# All processors implement same interface — interchangeable
def charge_customer(processor: PaymentProcessor, amount, **kwargs) -> str:
"""Works with ANY PaymentProcessor subclass."""
return processor.process(amount, **kwargs)
stripe = StripeProcessor(CardValidator())
paypal = PayPalProcessor(EmailValidator())
txn_1 = charge_customer(stripe, 100, card_number="1111222233334444", cvv="123")
txn_2 = charge_customer(paypal, 100, email="[email protected]")
# Both work identically from client perspective
Step 4 — Interface Segregation (I)
class PaymentProcessor(ABC):
@abstractmethod
def process(self, amount, **kwargs) -> str: pass
class Refundable(ABC):
@abstractmethod
def refund(self, txn_id) -> bool: pass
# Processors implement only what they support
class StripeProcessor(PaymentProcessor, Refundable):
def process(self, amount, **kwargs) -> str: return "txn_123"
def refund(self, txn_id) -> bool: return True
class ApplePayProcessor(PaymentProcessor):
def process(self, amount, **kwargs) -> str: return "apple_txn"
# No refund — not supported, not forced to implement
# Email sending is separate (not a payment concern!)
class ReceiptService:
def send_receipt(self, email, amount): ...
Step 5 — Dependency Inversion (D)
class CheckoutController:
def __init__(self, processor: PaymentProcessor, receipt: ReceiptService):
self.processor = processor # could be anything!
self.receipt = receipt
def checkout(self, amount, email, **payment_details):
txn_id = self.processor.process(amount, **payment_details)
self.receipt.send_receipt(email, amount)
return txn_id
# Easy to test with mock
class MockProcessor(PaymentProcessor):
def process(self, amount, **kwargs): return "mock_txn"
# Easy to swap real implementations
ctrl_stripe = CheckoutController(StripeProcessor(), ReceiptService())
ctrl_test = CheckoutController(MockProcessor(), ReceiptService())
Final Architecture
class PaymentProcessor(ABC):
@abstractmethod
def process(self, amount, **kwargs) -> str: pass
class Refundable(ABC):
@abstractmethod
def refund(self, txn_id) -> bool: pass
class StripeProcessor(PaymentProcessor, Refundable):
def process(self, amount, card_number, cvv): return "stripe_txn_123"
def refund(self, txn_id): return True
class PayPalProcessor(PaymentProcessor, Refundable):
def process(self, amount, email): return "paypal_txn_123"
def refund(self, txn_id): return True
class ApplePayProcessor(PaymentProcessor):
def process(self, amount, token): return "apple_txn_123"
class CheckoutService:
def __init__(self, processor: PaymentProcessor):
self.processor = processor
def checkout(self, amount, **details) -> str:
return self.processor.process(amount, **details)
class RefundService:
def __init__(self, processor: Refundable):
self.processor = processor
def refund(self, txn_id) -> bool:
return self.processor.refund(txn_id)
Benefits: Each class has ONE responsibility. Adding new payment methods doesn't modify existing code. All processors are interchangeable. No fat interfaces. Easy to test with mocks.