The 5 “SOLID” Principles in Python: A Practical Guide

SOLID Principles in Python: A Practical Guide

In the realm of object-oriented programming, SOLID principles serve as the cornerstone of writing maintainable, flexible, and robust code. Let’s dive deep into each principle with practical Python examples that illuminate these concepts.

Single Responsibility Principle (SRP)

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

Consider this violation of SRP:

class Order:
    def __init__(self, items):
        self.items = items

    def calculate_total(self):
        return sum(item.price for item in self.items)

    def save_to_db(self):
        # Database logic here
        pass

    def generate_invoice_pdf(self):
        # PDF generation logic here
        pass

Here’s a better approach that follows SRP:

class Order:
    def __init__(self, items):
        self.items = items

    def calculate_total(self):
        return sum(item.price for item in self.items)

class OrderRepository:
    def save(self, order):
        # Database logic here
        pass

class InvoiceGenerator:
    def generate_pdf(self, order):
        # PDF generation logic here
        pass

Open/Closed Principle (OCP)

“Software entities should be open for extension but closed for modification.”

Consider this violation:

class DiscountCalculator:
    def calculate(self, order_type, order_amount):
        if order_type == "regular":
            return order_amount * 0.1
        elif order_type == "premium":
            return order_amount * 0.2
        elif order_type == "vip":
            return order_amount * 0.3

Here’s the OCP-compliant version:

from abc import ABC, abstractmethod

class DiscountStrategy(ABC):
    @abstractmethod
    def calculate(self, order_amount):
        pass

class RegularDiscount(DiscountStrategy):
    def calculate(self, order_amount):
        return order_amount * 0.1

class PremiumDiscount(DiscountStrategy):
    def calculate(self, order_amount):
        return order_amount * 0.2

class VIPDiscount(DiscountStrategy):
    def calculate(self, order_amount):
        return order_amount * 0.3

class DiscountCalculator:
    def __init__(self, strategy: DiscountStrategy):
        self.strategy = strategy

    def calculate(self, order_amount):
        return self.strategy.calculate(order_amount)

Liskov Substitution Principle (LSP)

“Objects of a superclass should be replaceable with objects of its subclasses without breaking the application.”

Here’s an example that violates LSP:

class Bird:
    def fly(self):
        return "Flying high!"

class Penguin(Bird):
    def fly(self):
        raise Exception("Can't fly!")  # Violates LSP

Here’s a better design:

from abc import ABC, abstractmethod

class Bird(ABC):
    @abstractmethod
    def move(self):
        pass

class FlyingBird(Bird):
    def move(self):
        return "Flying high!"

class SwimmingBird(Bird):
    def move(self):
        return "Swimming gracefully!"

def bird_migration(birds: list[Bird]):
    return [bird.move() for bird in birds]

Interface Segregation Principle (ISP)

“Clients should not be forced to depend upon interfaces they do not use.”

Here’s an ISP violation:

from abc import ABC, abstractmethod

class Worker(ABC):
    @abstractmethod
    def work(self):
        pass

    @abstractmethod
    def eat(self):
        pass

    @abstractmethod
    def sleep(self):
        pass

Here’s a better approach:

from abc import ABC, abstractmethod

class Workable(ABC):
    @abstractmethod
    def work(self):
        pass

class Eatable(ABC):
    @abstractmethod
    def eat(self):
        pass

class Sleepable(ABC):
    @abstractmethod
    def sleep(self):
        pass

class Human(Workable, Eatable, Sleepable):
    def work(self):
        return "Working..."

    def eat(self):
        return "Eating..."

    def sleep(self):
        return "Sleeping..."

class Robot(Workable):
    def work(self):
        return "Working non-stop..."

Dependency Inversion Principle (DIP)

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

Here’s a DIP violation:

class MySQLDatabase:
    def save(self, data):
        # Save to MySQL
        pass

class UserService:
    def __init__(self):
        self.db = MySQLDatabase()  # Direct dependency on concrete class

    def save_user(self, user):
        self.db.save(user)

Here’s the DIP-compliant version:

from abc import ABC, abstractmethod

class Database(ABC):
    @abstractmethod
    def save(self, data):
        pass

class MySQLDatabase(Database):
    def save(self, data):
        # Save to MySQL
        pass

class PostgresDatabase(Database):
    def save(self, data):
        # Save to Postgres
        pass

class UserService:
    def __init__(self, db: Database):  # Depends on abstraction
        self.db = db

    def save_user(self, user):
        self.db.save(user)

Practical Application

Let’s see how these principles work together in a real-world scenario:

from abc import ABC, abstractmethod
from typing import List, Protocol

# Interfaces
class PaymentProcessor(Protocol):
    def process(self, amount: float) -> bool:
        pass

class OrderStorage(Protocol):
    def save(self, order: 'Order') -> bool:
        pass

# Concrete implementations
class StripePaymentProcessor:
    def process(self, amount: float) -> bool:
        # Stripe-specific implementation
        return True

class MongoDBStorage:
    def save(self, order: 'Order') -> bool:
        # MongoDB-specific implementation
        return True

# Domain entities
class OrderItem:
    def __init__(self, product_id: str, quantity: int, price: float):
        self.product_id = product_id
        self.quantity = quantity
        self.price = price

    @property
    def total(self) -> float:
        return self.quantity * self.price

class Order:
    def __init__(self, items: List[OrderItem], payment_processor: PaymentProcessor):
        self.items = items
        self._payment_processor = payment_processor
        self.is_paid = False

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

    def process_payment(self) -> bool:
        if not self.is_paid:
            self.is_paid = self._payment_processor.process(self.total)
        return self.is_paid

# Service layer
class OrderService:
    def __init__(self, storage: OrderStorage, payment_processor: PaymentProcessor):
        self.storage = storage
        self.payment_processor = payment_processor

    def create_order(self, items: List[OrderItem]) -> Order:
        order = Order(items, self.payment_processor)
        if order.process_payment():
            self.storage.save(order)
        return order

This comprehensive example demonstrates:

  • SRP: Each class has a single responsibility
  • OCP: New payment processors can be added without modifying existing code
  • LSP: Any PaymentProcessor implementation can be used interchangeably
  • ISP: Interfaces are small and specific
  • DIP: High-level OrderService depends on abstractions

Conclusion

SOLID principles are not just theoretical concepts but practical guidelines that lead to more maintainable and flexible code. By following these principles, we create systems that are:

  • Easier to maintain and test
  • More resilient to changes
  • More reusable and modular
  • Easier to understand and reason about

Remember that SOLID principles are guidelines, not strict rules. Use them wisely and pragmatically, always considering your specific context and requirements.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *