SOLID Principles Explained with Practical Python Examples

Master SOLID design principles in Python with real-world examples. Learn Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion with hands-on code

πŸ“… Published: January 12, 2025 ✏️ Updated: February 5, 2025 By Ojaswi Athghara
#solid #python #design #principles #oop

SOLID Principles Explained with Practical Python Examples

My 800-Line Class: A Cautionary Tale About Ignoring SOLID

Two years ago, I built a user management system. It started simple:

class UserManager:
    def create_user(self, user_data):
        # Validate user data
        # Save to database
        # Send welcome email
        # Log the activity
        # Update cache
        # Send webhook notification
        pass

Within three months, this one class grew to 800 lines. Every new feature meant modifying this monster. Every change broke something else. Testing was a nightmare.

My senior developer did a code review and said: "This violates every SOLID principle. Let me show you how to fix it."

That refactoring session changed my career. Today, I'll teach you the SOLID principles with practical Python examples that you can use immediately.

What Are SOLID Principles?

SOLID is an acronym for five object-oriented design principles:

  1. Single Responsibility Principle
  2. Open/Closed Principle
  3. Liskov Substitution Principle
  4. Interface Segregation Principle
  5. Dependency Inversion Principle

These principles help you write code that's maintainable, testable, and scalable.

1. Single Responsibility Principle (SRP)

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

Each class should do one thing and do it well.

❌ Bad Example (Violates SRP)

class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email
    
    def save_to_database(self):
        """Save user to database"""
        print(f"Saving {self.name} to database...")
        # Database logic
    
    def send_welcome_email(self):
        """Send welcome email"""
        print(f"Sending welcome email to {self.email}...")
        # Email logic
    
    def generate_report(self):
        """Generate user activity report"""
        print(f"Generating report for {self.name}...")
        # Report logic

Problems:

  • One class doing too many things (database, email, reports)
  • Changes to email logic require modifying User class
  • Hard to test (need database and email server for tests)
  • Poor reusability

βœ… Good Example (Follows SRP)

class User:
    """Represents a user (single responsibility: user data)"""
    def __init__(self, name, email):
        self.name = name
        self.email = email
    
    def get_info(self):
        return f"User: {self.name} ({self.email})"


class UserRepository:
    """Handles user database operations (single responsibility: persistence)"""
    def save(self, user):
        print(f"πŸ’Ύ Saving {user.name} to database...")
        # Database logic
    
    def find_by_email(self, email):
        print(f"πŸ” Finding user by email: {email}")
        # Database query logic


class EmailService:
    """Handles email operations (single responsibility: email)"""
    def send_welcome_email(self, user):
        print(f"πŸ“§ Sending welcome email to {user.email}")
        # Email logic


class ReportGenerator:
    """Generates reports (single responsibility: reporting)"""
    def generate_user_report(self, user):
        print(f"πŸ“Š Generating report for {user.name}")
        # Report generation logic


# Usage
user = User("Alice", "alice@example.com")
repository = UserRepository()
email_service = EmailService()
report_generator = ReportGenerator()

repository.save(user)
email_service.send_welcome_email(user)
report_generator.generate_user_report(user)

Benefits:

  • βœ… Each class has one responsibility
  • βœ… Easy to test (mock dependencies)
  • βœ… Changes are isolated
  • βœ… Reusable components

Real-World Example: Order Processing

# ❌ BAD: One class doing everything
class Order:
    def __init__(self, items):
        self.items = items
    
    def calculate_total(self):
        pass
    
    def validate_payment(self):
        pass
    
    def save_to_database(self):
        pass
    
    def send_confirmation_email(self):
        pass
    
    def update_inventory(self):
        pass


# βœ… GOOD: Separate responsibilities
class Order:
    """Represents order data"""
    def __init__(self, items):
        self.items = items
        self.total = 0


class OrderCalculator:
    """Calculates order totals"""
    def calculate_total(self, order):
        order.total = sum(item.price * item.quantity for item in order.items)
        return order.total


class PaymentValidator:
    """Validates payments"""
    def validate(self, payment_info):
        print("βœ… Payment validated")
        return True


class OrderRepository:
    """Handles order persistence"""
    def save(self, order):
        print(f"πŸ’Ύ Order saved (Total: ${order.total})")


class EmailNotificationService:
    """Sends email notifications"""
    def send_order_confirmation(self, order):
        print("πŸ“§ Order confirmation email sent")


class InventoryService:
    """Manages inventory"""
    def update_stock(self, order):
        print("πŸ“¦ Inventory updated")


# Clean usage
order = Order([Item("Laptop", 999, 1), Item("Mouse", 29, 2)])

calculator = OrderCalculator()
validator = PaymentValidator()
repository = OrderRepository()
email_service = EmailNotificationService()
inventory_service = InventoryService()

calculator.calculate_total(order)
validator.validate(payment_info)
repository.save(order)
email_service.send_order_confirmation(order)
inventory_service.update_stock(order)

2. Open/Closed Principle (OCP)

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

You should be able to add new functionality without changing existing code.

❌ Bad Example (Violates OCP)

class PaymentProcessor:
    def process_payment(self, payment_type, amount):
        if payment_type == "credit_card":
            print(f"Processing credit card payment: ${amount}")
            # Credit card logic
        elif payment_type == "paypal":
            print(f"Processing PayPal payment: ${amount}")
            # PayPal logic
        elif payment_type == "bitcoin":  # Added later (modification!)
            print(f"Processing Bitcoin payment: ${amount}")
            # Bitcoin logic
        # What if we add 10 more payment types?

Problems:

  • Every new payment type requires modifying this class
  • Violates OCP (not closed for modification)
  • Hard to test all branches
  • Risk of breaking existing code

βœ… Good Example (Follows OCP)

from abc import ABC, abstractmethod

# Abstract base class (interface)
class PaymentMethod(ABC):
    @abstractmethod
    def process_payment(self, amount):
        pass


# Concrete implementations (extensions)
class CreditCardPayment(PaymentMethod):
    def process_payment(self, amount):
        print(f"πŸ’³ Processing credit card payment: ${amount}")


class PayPalPayment(PaymentMethod):
    def process_payment(self, amount):
        print(f"πŸ…ΏοΈ Processing PayPal payment: ${amount}")


class BitcoinPayment(PaymentMethod):
    def process_payment(self, amount):
        print(f"β‚Ώ Processing Bitcoin payment: ${amount}")


# Adding new payment type (no modification to existing code!)
class ApplePayPayment(PaymentMethod):
    def process_payment(self, amount):
        print(f"🍎 Processing Apple Pay payment: ${amount}")


# Payment processor (closed for modification, open for extension)
class PaymentProcessor:
    def process(self, payment_method: PaymentMethod, amount: float):
        payment_method.process_payment(amount)


# Usage
processor = PaymentProcessor()

processor.process(CreditCardPayment(), 99.99)
processor.process(PayPalPayment(), 149.99)
processor.process(BitcoinPayment(), 299.99)
processor.process(ApplePayPayment(), 199.99)  # New payment type!

Benefits:

  • βœ… Add new payment types without modifying existing code
  • βœ… Each payment type is independent
  • βœ… Easy to test
  • βœ… Follows OCP

Real-World Example: Discount System

# ❌ BAD: Modifying for each new discount type
class DiscountCalculator:
    def calculate_discount(self, order_total, customer_type):
        if customer_type == "regular":
            return order_total * 0.0
        elif customer_type == "premium":
            return order_total * 0.10
        elif customer_type == "vip":  # Modification!
            return order_total * 0.20


# βœ… GOOD: Open for extension, closed for modification
class DiscountStrategy(ABC):
    @abstractmethod
    def calculate_discount(self, order_total):
        pass


class NoDiscount(DiscountStrategy):
    def calculate_discount(self, order_total):
        return 0


class PremiumDiscount(DiscountStrategy):
    def calculate_discount(self, order_total):
        return order_total * 0.10


class VIPDiscount(DiscountStrategy):
    def calculate_discount(self, order_total):
        return order_total * 0.20


# New discount (no modification!)
class SeasonalDiscount(DiscountStrategy):
    def calculate_discount(self, order_total):
        return order_total * 0.15


class Order:
    def __init__(self, total, discount_strategy: DiscountStrategy):
        self.total = total
        self.discount_strategy = discount_strategy
    
    def get_final_price(self):
        discount = self.discount_strategy.calculate_discount(self.total)
        return self.total - discount


# Usage
regular_order = Order(100, NoDiscount())
premium_order = Order(100, PremiumDiscount())
vip_order = Order(100, VIPDiscount())
seasonal_order = Order(100, SeasonalDiscount())

print(f"Regular: ${regular_order.get_final_price()}")    # $100.00
print(f"Premium: ${premium_order.get_final_price()}")    # $90.00
print(f"VIP: ${vip_order.get_final_price()}")            # $80.00
print(f"Seasonal: ${seasonal_order.get_final_price()}")  # $85.00

3. Liskov Substitution Principle (LSP)

"Objects of a superclass should be replaceable with objects of a subclass without breaking the application."

Subtypes must be substitutable for their base types.

❌ Bad Example (Violates LSP)

class Bird:
    def fly(self):
        print("Flying...")


class Sparrow(Bird):
    def fly(self):
        print("Sparrow flying...")


class Penguin(Bird):  # Problem: Penguins can't fly!
    def fly(self):
        raise Exception("Penguins can't fly!")


# This breaks LSP
def make_bird_fly(bird: Bird):
    bird.fly()  # Works for Sparrow, crashes for Penguin!

sparrow = Sparrow()
penguin = Penguin()

make_bird_fly(sparrow)  # βœ… Works
make_bird_fly(penguin)  # ❌ Crashes!

Problem: Penguin can't be substituted for Bird without breaking code.

βœ… Good Example (Follows LSP)

class Bird:
    def eat(self):
        print("Eating...")


class FlyingBird(Bird):
    def fly(self):
        print("Flying...")


class SwimmingBird(Bird):
    def swim(self):
        print("Swimming...")


class Sparrow(FlyingBird):
    def fly(self):
        print("Sparrow flying...")


class Penguin(SwimmingBird):
    def swim(self):
        print("Penguin swimming...")


# Now it works correctly
def make_flying_bird_fly(bird: FlyingBird):
    bird.fly()

def make_swimming_bird_swim(bird: SwimmingBird):
    bird.swim()

sparrow = Sparrow()
penguin = Penguin()

make_flying_bird_fly(sparrow)    # βœ… Works
make_swimming_bird_swim(penguin)  # βœ… Works

Benefits:

  • βœ… Subtypes are truly substitutable
  • βœ… No unexpected exceptions
  • βœ… Clear hierarchy

Real-World Example: Rectangle vs Square

# ❌ BAD: Violates LSP
class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height
    
    def set_width(self, width):
        self.width = width
    
    def set_height(self, height):
        self.height = height
    
    def get_area(self):
        return self.width * self.height


class Square(Rectangle):  # Problem: Square's width and height must be equal
    def set_width(self, width):
        self.width = width
        self.height = width  # Maintain square property
    
    def set_height(self, height):
        self.width = height  # Maintain square property
        self.height = height


def test_rectangle(rect: Rectangle):
    rect.set_width(5)
    rect.set_height(10)
    expected_area = 5 * 10  # 50
    actual_area = rect.get_area()
    
    assert expected_area == actual_area, f"Expected {expected_area}, got {actual_area}"

rectangle = Rectangle(0, 0)
test_rectangle(rectangle)  # βœ… Passes

square = Square(0, 0)
test_rectangle(square)  # ❌ Fails! (area is 100, not 50)


# βœ… GOOD: Follows LSP
class Shape(ABC):
    @abstractmethod
    def get_area(self):
        pass


class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height
    
    def get_area(self):
        return self.width * self.height


class Square(Shape):
    def __init__(self, side):
        self.side = side
    
    def get_area(self):
        return self.side * self.side


# Now they're not substitutable (correct!)
def calculate_total_area(shapes: list[Shape]):
    return sum(shape.get_area() for shape in shapes)

shapes = [
    Rectangle(5, 10),
    Square(5)
]

print(f"Total area: {calculate_total_area(shapes)}")  # 50 + 25 = 75

4. Interface Segregation Principle (ISP)

"Clients should not be forced to depend on interfaces they don't use."

Create specific interfaces instead of one general-purpose interface.

❌ Bad Example (Violates ISP)

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


class Human(Worker):
    def work(self):
        print("πŸ‘· Human working...")
    
    def eat(self):
        print("πŸ” Human eating...")
    
    def sleep(self):
        print("😴 Human sleeping...")


class Robot(Worker):  # Problem: Robots don't eat or sleep!
    def work(self):
        print("πŸ€– Robot working...")
    
    def eat(self):
        raise NotImplementedError("Robots don't eat!")
    
    def sleep(self):
        raise NotImplementedError("Robots don't sleep!")

Problem: Robot is forced to implement methods it doesn't need.

βœ… Good Example (Follows ISP)

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):
        print("πŸ‘· Human working...")
    
    def eat(self):
        print("πŸ” Human eating...")
    
    def sleep(self):
        print("😴 Human sleeping...")


class Robot(Workable):  # Only implements what it needs!
    def work(self):
        print("πŸ€– Robot working...")


# Usage
def make_work(worker: Workable):
    worker.work()

def make_eat(eater: Eatable):
    eater.eat()

human = Human()
robot = Robot()

make_work(human)  # βœ… Works
make_work(robot)  # βœ… Works

make_eat(human)   # βœ… Works
# make_eat(robot) # ❌ Type error (correct!)

Benefits:

  • βœ… Classes only implement what they need
  • βœ… No empty or exception-throwing methods
  • βœ… More flexible design

Real-World Example: Document Printer

# ❌ BAD: Fat interface
class Printer(ABC):
    @abstractmethod
    def print_document(self, doc):
        pass
    
    @abstractmethod
    def scan_document(self, doc):
        pass
    
    @abstractmethod
    def fax_document(self, doc):
        pass


class MultiFunctionPrinter(Printer):
    def print_document(self, doc):
        print(f"πŸ–¨οΈ Printing: {doc}")
    
    def scan_document(self, doc):
        print(f"πŸ“„ Scanning: {doc}")
    
    def fax_document(self, doc):
        print(f"πŸ“  Faxing: {doc}")


class SimplePrinter(Printer):  # Problem: Can't scan or fax!
    def print_document(self, doc):
        print(f"πŸ–¨οΈ Printing: {doc}")
    
    def scan_document(self, doc):
        raise NotImplementedError("Can't scan!")
    
    def fax_document(self, doc):
        raise NotImplementedError("Can't fax!")


# βœ… GOOD: Segregated interfaces
class Printable(ABC):
    @abstractmethod
    def print_document(self, doc):
        pass


class Scannable(ABC):
    @abstractmethod
    def scan_document(self, doc):
        pass


class Faxable(ABC):
    @abstractmethod
    def fax_document(self, doc):
        pass


class SimplePrinter(Printable):
    def print_document(self, doc):
        print(f"πŸ–¨οΈ Printing: {doc}")


class MultiFunctionPrinter(Printable, Scannable, Faxable):
    def print_document(self, doc):
        print(f"πŸ–¨οΈ Printing: {doc}")
    
    def scan_document(self, doc):
        print(f"πŸ“„ Scanning: {doc}")
    
    def fax_document(self, doc):
        print(f"πŸ“  Faxing: {doc}")


# Usage
simple = SimplePrinter()
multi = MultiFunctionPrinter()

simple.print_document("Report.pdf")  # βœ… Works
multi.print_document("Report.pdf")   # βœ… Works
multi.scan_document("Contract.pdf")  # βœ… Works
multi.fax_document("Invoice.pdf")    # βœ… Works

5. Dependency Inversion Principle (DIP)

"Depend on abstractions, not concretions."

High-level modules shouldn't depend on low-level modules. Both should depend on abstractions.

❌ Bad Example (Violates DIP)

class MySQLDatabase:
    def save(self, data):
        print(f"Saving to MySQL: {data}")


class UserService:
    def __init__(self):
        self.database = MySQLDatabase()  # Tight coupling!
    
    def create_user(self, user_data):
        # Business logic
        self.database.save(user_data)


# Problem: Can't easily switch to PostgreSQL or MongoDB
# UserService is tightly coupled to MySQLDatabase

Problems:

  • Tight coupling to specific database
  • Hard to test (need real MySQL)
  • Can't switch databases easily

βœ… Good Example (Follows DIP)

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


class MySQLDatabase(Database):
    def save(self, data):
        print(f"πŸ’Ύ Saving to MySQL: {data}")


class PostgreSQLDatabase(Database):
    def save(self, data):
        print(f"🐘 Saving to PostgreSQL: {data}")


class MongoDBDatabase(Database):
    def save(self, data):
        print(f"πŸƒ Saving to MongoDB: {data}")


class UserService:
    def __init__(self, database: Database):  # Depends on abstraction!
        self.database = database
    
    def create_user(self, user_data):
        # Business logic
        self.database.save(user_data)


# Easy to switch implementations!
mysql_service = UserService(MySQLDatabase())
postgres_service = UserService(PostgreSQLDatabase())
mongo_service = UserService(MongoDBDatabase())

mysql_service.create_user({"name": "Alice"})
postgres_service.create_user({"name": "Bob"})
mongo_service.create_user({"name": "Charlie"})

Benefits:

  • βœ… Loose coupling
  • βœ… Easy to test (use mock database)
  • βœ… Easy to switch implementations
  • βœ… Depends on abstractions

Real-World Example: Notification System

# ❌ BAD: Tight coupling
class EmailSender:
    def send(self, message):
        print(f"πŸ“§ Sending email: {message}")


class NotificationService:
    def __init__(self):
        self.sender = EmailSender()  # Tight coupling!
    
    def notify(self, message):
        self.sender.send(message)


# βœ… GOOD: Dependency inversion
class MessageSender(ABC):
    @abstractmethod
    def send(self, message):
        pass


class EmailSender(MessageSender):
    def send(self, message):
        print(f"πŸ“§ Sending email: {message}")


class SMSSender(MessageSender):
    def send(self, message):
        print(f"πŸ“± Sending SMS: {message}")


class PushNotificationSender(MessageSender):
    def send(self, message):
        print(f"πŸ”” Sending push notification: {message}")


class NotificationService:
    def __init__(self, sender: MessageSender):  # Depends on abstraction!
        self.sender = sender
    
    def notify(self, message):
        self.sender.send(message)


# Easy to swap implementations!
email_service = NotificationService(EmailSender())
sms_service = NotificationService(SMSSender())
push_service = NotificationService(PushNotificationSender())

email_service.notify("Your order has shipped!")
sms_service.notify("Your order has shipped!")
push_service.notify("Your order has shipped!")

SOLID Principles Summary

PrincipleAbbreviationKey Concept
Single ResponsibilitySRPOne class, one job
Open/ClosedOCPOpen for extension, closed for modification
Liskov SubstitutionLSPSubtypes must be substitutable
Interface SegregationISPMany specific interfaces > one general
Dependency InversionDIPDepend on abstractions, not concretions

Real-World Complete Example: E-Commerce System

# Following ALL SOLID principles
from abc import ABC, abstractmethod
from typing import List

# DIP: Abstract interfaces
class PaymentMethod(ABC):
    @abstractmethod
    def process(self, amount: float) -> bool:
        pass

class NotificationService(ABC):
    @abstractmethod
    def send(self, message: str) -> None:
        pass

class Repository(ABC):
    @abstractmethod
    def save(self, data: dict) -> None:
        pass

# Concrete implementations
class CreditCardPayment(PaymentMethod):
    def process(self, amount: float) -> bool:
        print(f"πŸ’³ Processing ${amount} via credit card")
        return True

class EmailNotification(NotificationService):
    def send(self, message: str) -> None:
        print(f"πŸ“§ Email: {message}")

class DatabaseRepository(Repository):
    def save(self, data: dict) -> None:
        print(f"πŸ’Ύ Saved to database: {data}")

# SRP: Each class has one responsibility
class Order:
    def __init__(self, items: List[dict]):
        self.items = items
        self.total = 0

class OrderCalculator:
    def calculate_total(self, order: Order) -> float:
        order.total = sum(item['price'] * item['quantity'] for item in order.items)
        return order.total

class OrderProcessor:
    def __init__(
        self,
        payment: PaymentMethod,
        notification: NotificationService,
        repository: Repository
    ):
        self.payment = payment
        self.notification = notification
        self.repository = repository
        self.calculator = OrderCalculator()
    
    def process(self, order: Order) -> bool:
        total = self.calculator.calculate_total(order)
        
        if self.payment.process(total):
            self.repository.save({'order': order.items, 'total': total})
            self.notification.send(f"Order confirmed! Total: ${total}")
            return True
        return False

# Usage
order = Order([
    {'name': 'Laptop', 'price': 999.99, 'quantity': 1},
    {'name': 'Mouse', 'price': 29.99, 'quantity': 2}
])

processor = OrderProcessor(
    payment=CreditCardPayment(),
    notification=EmailNotification(),
    repository=DatabaseRepository()
)

processor.process(order)

Conclusion: Write Better Code with SOLID

SOLID principles aren't just theoryβ€”they're practical guidelines for writing maintainable code:

Benefits:

  • βœ… Maintainable: Easy to modify and extend
  • βœ… Testable: Easy to write unit tests
  • βœ… Flexible: Easy to swap implementations
  • βœ… Scalable: Grows without becoming messy

Start applying SOLID today:

  1. SRP: One class, one job
  2. OCP: Extend, don't modify
  3. LSP: Subtypes must be substitutable
  4. ISP: Small, focused interfaces
  5. DIP: Depend on abstractions

Your code quality will improve dramatically!


Building clean Python systems with SOLID principles? I'd love to hear about your experience! Connect with me on Twitter or LinkedIn!

Support My Work

If this guide helped you understand and apply SOLID principles in Python, I'd really appreciate your support, I'd really appreciate your support! Creating comprehensive, free content like this takes significant time and effort. Your support helps me continue sharing knowledge and creating more helpful resources for Python developers.

β˜• Buy me a coffee - Every contribution, big or small, means the world to me and keeps me motivated to create more content!


Cover image by Mick Haupt on Unsplash

Related Blogs

Ojaswi Athghara

SDE, 4+ Years

Β© ojaswiat.com 2025-2027