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())

Composition over Inheritance

❌ Deep inheritance is fragile
class Animal: ...
class Mammal(Animal): ...
class Pet(Mammal): ...
class Dog(Pet): ...
class ServiceDog(Dog): ...
# 5 levels deep!
✅ Prefer composition
class Dog:
    def __init__(self, name: str):
        self.name = name
        self._behaviours = []

    def add_behaviour(self, behaviour):
        self._behaviours.append(behaviour)

dog = Dog("Rex")
dog.add_behaviour(GuideAssistant())
dog.add_behaviour(SearchAndRescue())

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.

Class Design Checklist