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

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:
- Single Responsibility Principle
- Open/Closed Principle
- Liskov Substitution Principle
- Interface Segregation Principle
- 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
Userclass - 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
| Principle | Abbreviation | Key Concept |
|---|---|---|
| Single Responsibility | SRP | One class, one job |
| Open/Closed | OCP | Open for extension, closed for modification |
| Liskov Substitution | LSP | Subtypes must be substitutable |
| Interface Segregation | ISP | Many specific interfaces > one general |
| Dependency Inversion | DIP | Depend 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:
- SRP: One class, one job
- OCP: Extend, don't modify
- LSP: Subtypes must be substitutable
- ISP: Small, focused interfaces
- 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