SOLID principles are five design guidelines that help developers write maintainable, flexible, and scalable code. Understanding and applying these principles separates professional-grade code from quick hacks. This guide covers practical SOLID application from a senior developer's perspective.
Why SOLID Matters
Following SOLID principles leads to:
- Maintainability: Code is easier to modify without breaking
- Testability: Components can be tested in isolation
- Flexibility: New features require less refactoring
- Readability: Clear responsibilities and interfaces
- Collaboration: Teams can work on separate components
S — Single Responsibility Principle (SRP)
Definition: A class should have one, and only one, reason to change.
Bad Example
class User:
def __init__(self, name, email):
self.name = name
self.email = email
def save_to_database(self):
# Database logic
db.execute("INSERT INTO users...")
def send_welcome_email(self):
# Email logic
smtp.send(self.email, "Welcome!")
def generate_report(self):
# Reporting logic
return f"User Report: {self.name}"
This class has four reasons to change: user data, database operations, email sending, and reporting.
Good Example
class User:
"""Only handles user data."""
def __init__(self, name, email):
self.name = name
self.email = email
class UserRepository:
"""Handles user persistence."""
def save(self, user: User):
db.execute("INSERT INTO users...", user.name, user.email)
def find_by_email(self, email: str) -> User:
row = db.query("SELECT * FROM users WHERE email = ?", email)
return User(row['name'], row['email'])
class EmailService:
"""Handles email sending."""
def send_welcome(self, user: User):
smtp.send(user.email, "Welcome!", self._welcome_template(user))
def _welcome_template(self, user: User) -> str:
return f"Hello {user.name}, welcome to our platform!"
class UserReportGenerator:
"""Handles user reports."""
def generate(self, user: User) -> str:
return f"User Report: {user.name}, Email: {user.email}"
Each class now has a single responsibility and can be modified independently.
O — Open/Closed Principle (OCP)
Definition: Classes should be open for extension but closed for modification.
Bad Example
class PaymentProcessor:
def process(self, payment_type: str, amount: float):
if payment_type == "credit_card":
# Credit card logic
return self._process_credit_card(amount)
elif payment_type == "paypal":
# PayPal logic
return self._process_paypal(amount)
elif payment_type == "bitcoin":
# Bitcoin logic
return self._process_bitcoin(amount)
# Adding a new payment method requires modifying this class
Every new payment method requires modifying the processor.
Good Example
from abc import ABC, abstractmethod
class PaymentMethod(ABC):
"""Abstract base class for payment methods."""
@abstractmethod
def process(self, amount: float) -> bool:
pass
class CreditCardPayment(PaymentMethod):
def __init__(self, card_number: str, cvv: str):
self.card_number = card_number
self.cvv = cvv
def process(self, amount: float) -> bool:
# Credit card processing logic
return gateway.charge(self.card_number, amount)
class PayPalPayment(PaymentMethod):
def __init__(self, email: str):
self.email = email
def process(self, amount: float) -> bool:
# PayPal processing logic
return paypal.charge(self.email, amount)
class BitcoinPayment(PaymentMethod):
def __init__(self, wallet_address: str):
self.wallet_address = wallet_address
def process(self, amount: float) -> bool:
# Bitcoin processing logic
return bitcoin.transfer(self.wallet_address, amount)
class PaymentProcessor:
"""Processor is closed for modification, open for extension."""
def process(self, method: PaymentMethod, amount: float) -> bool:
return method.process(amount)
# Usage: Adding a new payment method doesn't require changing PaymentProcessor
class ApplePayPayment(PaymentMethod):
def process(self, amount: float) -> bool:
return apple_pay.charge(amount)
New payment methods can be added without touching existing code.
L — Liskov Substitution Principle (LSP)
Definition: Subclasses should be substitutable for their base classes without altering program correctness.
Bad Example
class Rectangle:
def __init__(self, width: int, height: int):
self._width = width
self._height = height
def set_width(self, width: int):
self._width = width
def set_height(self, height: int):
self._height = height
def area(self) -> int:
return self._width * self._height
class Square(Rectangle):
def set_width(self, width: int):
self._width = width
self._height = width # Breaks LSP!
def set_height(self, height: int):
self._width = height # Breaks LSP!
self._height = height
def calculate_area(rect: Rectangle):
rect.set_width(5)
rect.set_height(4)
assert rect.area() == 20 # Fails for Square!
Square changes behavior inherited from Rectangle, breaking substitutability.
Good Example
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float:
pass
class Rectangle(Shape):
def __init__(self, width: float, height: float):
self.width = width
self.height = height
def area(self) -> float:
return self.width * self.height
class Square(Shape):
def __init__(self, side: float):
self.side = side
def area(self) -> float:
return self.side ** 2
def print_area(shape: Shape):
print(f"Area: {shape.area()}") # Works for any shape
# Both work correctly
print_area(Rectangle(5, 4)) # Area: 20
print_area(Square(5)) # Area: 25
I — Interface Segregation Principle (ISP)
Definition: Clients should not be forced to depend on interfaces they don't use.
Bad Example
from abc import ABC, abstractmethod
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("Working")
def eat(self):
print("Eating")
def sleep(self):
print("Sleeping")
class Robot(Worker):
def work(self):
print("Working")
def eat(self):
pass # Robots don't eat—forced to implement
def sleep(self):
pass # Robots don't sleep—forced to implement
Good Example
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):
print("Human working")
def eat(self):
print("Human eating")
def sleep(self):
print("Human sleeping")
class Robot(Workable):
def work(self):
print("Robot working")
# Factory that only needs workers
def assign_work(workers: list[Workable]):
for worker in workers:
worker.work()
# Works with both
assign_work([Human(), Robot()])
Clients depend only on the interfaces they actually need.
D — Dependency Inversion Principle (DIP)
Definition: High-level modules should not depend on low-level modules. Both should depend on abstractions.
Bad Example
class MySQLDatabase:
def connect(self):
return mysql.connect("localhost", "user", "pass")
def query(self, sql: str):
return self.connect().execute(sql)
class UserService:
def __init__(self):
self.db = MySQLDatabase() # Direct dependency on MySQL
def get_user(self, user_id: int):
return self.db.query(f"SELECT * FROM users WHERE id = {user_id}")
UserService is tightly coupled to MySQL—can't easily switch databases.
Good Example
from abc import ABC, abstractmethod
class Database(ABC):
"""Abstraction that both high and low-level modules depend on."""
@abstractmethod
def query(self, sql: str):
pass
@abstractmethod
def execute(self, sql: str, params: tuple):
pass
class MySQLDatabase(Database):
def __init__(self, host: str, user: str, password: str):
self.connection = mysql.connect(host, user, password)
def query(self, sql: str):
return self.connection.execute(sql)
def execute(self, sql: str, params: tuple):
return self.connection.execute(sql, params)
class PostgreSQLDatabase(Database):
def __init__(self, host: str, user: str, password: str):
self.connection = psycopg2.connect(host=host, user=user, password=password)
def query(self, sql: str):
return self.connection.cursor().execute(sql)
def execute(self, sql: str, params: tuple):
return self.connection.cursor().execute(sql, params)
class UserService:
def __init__(self, database: Database): # Depends on abstraction
self.db = database
def get_user(self, user_id: int):
return self.db.query(f"SELECT * FROM users WHERE id = {user_id}")
# Easy to switch implementations
mysql_db = MySQLDatabase("localhost", "user", "pass")
user_service = UserService(mysql_db)
# Or use PostgreSQL without changing UserService
postgres_db = PostgreSQLDatabase("localhost", "user", "pass")
user_service = UserService(postgres_db)
# Or mock for testing
class MockDatabase(Database):
def query(self, sql: str):
return [{"id": 1, "name": "Test User"}]
def execute(self, sql: str, params: tuple):
pass
test_service = UserService(MockDatabase())
Complete Example: Order Processing System
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import List
# Domain models (SRP)
@dataclass
class Product:
id: str
name: str
price: float
@dataclass
class Order:
id: str
products: List[Product]
@property
def total(self) -> float:
return sum(p.price for p in self.products)
# Abstractions (DIP)
class OrderRepository(ABC):
@abstractmethod
def save(self, order: Order) -> None:
pass
@abstractmethod
def find(self, order_id: str) -> Order:
pass
class NotificationService(ABC):
@abstractmethod
def notify(self, message: str, recipient: str) -> None:
pass
class PaymentGateway(ABC):
@abstractmethod
def charge(self, amount: float, payment_details: dict) -> bool:
pass
# Implementations (OCP - can add new implementations)
class DatabaseOrderRepository(OrderRepository):
def save(self, order: Order) -> None:
db.execute("INSERT INTO orders...")
def find(self, order_id: str) -> Order:
return db.query("SELECT * FROM orders WHERE id = ?", order_id)
class EmailNotificationService(NotificationService):
def notify(self, message: str, recipient: str) -> None:
smtp.send(recipient, "Order Update", message)
class SMSNotificationService(NotificationService):
def notify(self, message: str, recipient: str) -> None:
twilio.send(recipient, message)
class StripePaymentGateway(PaymentGateway):
def charge(self, amount: float, payment_details: dict) -> bool:
return stripe.charge(amount, payment_details['card_token'])
# High-level service (DIP - depends on abstractions)
class OrderService:
def __init__(
self,
repository: OrderRepository,
notification: NotificationService,
payment: PaymentGateway
):
self.repository = repository
self.notification = notification
self.payment = payment
def place_order(self, order: Order, payment_details: dict, customer_email: str) -> bool:
if self.payment.charge(order.total, payment_details):
self.repository.save(order)
self.notification.notify(
f"Order {order.id} confirmed! Total: ${order.total}",
customer_email
)
return True
return False
# Dependency injection (composition root)
def create_order_service() -> OrderService:
return OrderService(
repository=DatabaseOrderRepository(),
notification=EmailNotificationService(),
payment=StripePaymentGateway()
)
# Usage
service = create_order_service()
order = Order("123", [Product("1", "Widget", 29.99)])
service.place_order(order, {"card_token": "tok_xxx"}, "[email protected]")
Key Takeaways
- SRP: One class, one responsibility
- OCP: Extend through abstraction, not modification
- LSP: Subclasses must honor parent contracts
- ISP: Small, focused interfaces over large ones
- DIP: Depend on abstractions, inject dependencies
SOLID principles aren't rigid rules—they're guidelines that improve code quality when applied thoughtfully. Start by identifying code smells, then refactor toward these principles incrementally.