Understanding SOLID Principles with Practical Low-Level Coding Examples
Understanding SOLID Principles with Practical Low-Level Coding Examples
Introduction
You're staring at a class that does everything: validates input, queries databases, sends emails, formats responses, and logs errors. Adding a simple feature requires changing five different methods. Tests break in unexpected places. Your pull request grows from 10 lines to 200. Sound familiar?
This is the maintenance nightmare that SOLID principles were designed to prevent. Formalized by Robert C. Martin in the early 2000s, these five object-oriented design principles provide specific, actionable patterns for writing maintainable code. But here's the critical caveat that many developers miss: SOLID principles are guidelines, not absolute rules. Rigid adherence leads to over-engineering, creating unnecessary abstractions that obscure rather than clarify.
This article explores each SOLID principle through low-level code examples, showing both the technical patterns and the practical judgment required to apply them effectively. We'll examine when to use these principles, when to hold back, and how to balance architectural purity with pragmatic software delivery.
What Are SOLID Principles and Why They Matter
SOLID is an acronym representing five design principles:
- Single Responsibility Principle (SRP)
- Open/Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
The core concept underlying all five principles is reducing "reasons to change." In software maintenance, each responsibility in a class represents a potential reason to modify that class. By isolating responsibilities, you create systems where changes remain localized rather than cascading through your codebase.
Research across multiple programming languages demonstrates three primary benefits:
- Maintainability: Code changes isolated to single classes
- Testability: Smaller, focused classes easier to unit test
- Extensibility: New features added without modifying existing code
However, these benefits come with trade-offs. Additional abstractions increase initial complexity and require upfront design time. The skill lies in recognizing which parts of your system warrant SOLID principles and which benefit from simpler approaches.
Single Responsibility Principle (SRP)
The Principle
"A class should have one, and only one, reason to change."
SRP doesn't mean a class should do only one thing—it means a class should have one reason to change. Multiple methods are fine if they all support the same responsibility.
The Problem: Multiple Responsibilities
Consider this class handling user registration:
class UserRegistration:
def register_user(self, email, password):
# Responsibility 1: Input validation
if not self._is_valid_email(email):
raise ValueError("Invalid email")
if len(password) < 8:
raise ValueError("Password too short")
# Responsibility 2: Password encryption
hashed = self._hash_password(password)
# Responsibility 3: Database persistence
connection = self._get_db_connection()
cursor = connection.cursor()
cursor.execute(
"INSERT INTO users (email, password) VALUES (?, ?)",
(email, hashed)
)
connection.commit()
# Responsibility 4: Email notification
self._send_welcome_email(email)
# Responsibility 5: Logging
self._log_registration(email)
This class has five reasons to change:
- Validation rules change
- Password hashing algorithm changes
- Database schema or ORM changes
- Email service or template changes
- Logging format or destination changes
The Solution: Separated Responsibilities
Following the pattern documented in technical implementations, we decompose into specialized classes:
class EmailValidator:
def validate(self, email):
# Single responsibility: email validation logic
import re
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(pattern, email):
raise ValueError("Invalid email format")
class PasswordValidator:
def validate(self, password):
# Single responsibility: password validation rules
if len(password) < 8:
raise ValueError("Password must be at least 8 characters")
if not any(c.isupper() for c in password):
raise ValueError("Password must contain uppercase letter")
class PasswordHasher:
def hash(self, password):
# Single responsibility: password encryption
import hashlib
return hashlib.sha256(password.encode()).hexdigest()
class UserRepository:
def __init__(self, connection):
self.connection = connection
def save(self, email, hashed_password):
# Single responsibility: user persistence
cursor = self.connection.cursor()
cursor.execute(
"INSERT INTO users (email, password) VALUES (?, ?)",
(email, hashed_password)
)
self.connection.commit()
class WelcomeEmailService:
def send(self, email):
# Single responsibility: welcome email delivery
# Email sending logic here
pass
class RegistrationLogger:
def log(self, email):
# Single responsibility: registration event logging
print(f"User registered: {email}")
class UserRegistrationService:
def __init__(self, email_validator, password_validator,
password_hasher, user_repository,
email_service, logger):
self.email_validator = email_validator
self.password_validator = password_validator
self.password_hasher = password_hasher
self.user_repository = user_repository
self.email_service = email_service
self.logger = logger
def register(self, email, password):
# Orchestrates the registration process
self.email_validator.validate(email)
self.password_validator.validate(password)
hashed = self.password_hasher.hash(password)
self.user_repository.save(email, hashed)
self.email_service.send(email)
self.logger.log(email)
Now each class has exactly one reason to change. When password requirements change, you modify only PasswordValidator. When switching email providers, you modify only WelcomeEmailService.
Practical Considerations
When to apply SRP:
- Classes with multiple distinct responsibilities
- Code that changes frequently for different reasons
- Components requiring independent testing
When to hold back:
- Simple scripts or utility functions
- Tightly coupled operations that always change together
- When abstractions add more complexity than they remove
Open/Closed Principle (OCP)
The Principle
"Software entities should be open for extension, but closed for modification."
You should be able to add new functionality without changing existing code. This is achieved through abstraction—using interfaces or abstract classes that allow new implementations without modifying the base code.
The Problem: Modification for Extension
Here's a shape calculator requiring modification for each new shape:
class AreaCalculator:
def calculate_area(self, shape):
if shape['type'] == 'rectangle':
return shape['width'] * shape['height']
elif shape['type'] == 'circle':
return 3.14159 * shape['radius'] ** 2
elif shape['type'] == 'triangle':
return 0.5 * shape['base'] * shape['height']
# Adding a new shape requires modifying this method
Every new shape type requires opening this class and adding another conditional branch. This violates OCP because the class isn't closed for modification.
The Solution: Extension Through Abstraction
Following the implementation structure documented in research, we use abstraction:
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
import math
return math.pi * self.radius ** 2
class Triangle(Shape):
def __init__(self, base, height):
self.base = base
self.height = height
def area(self):
return 0.5 * self.base * self.height
class AreaCalculator:
def sum(self, shapes):
# Closed for modification - no changes needed for new shapes
return sum(shape.area() for shape in shapes)
Now adding a new shape requires only creating a new class:
class Pentagon(Shape):
def __init__(self, side, apothem):
self.side = side
self.apothem = apothem
def area(self):
perimeter = 5 * self.side
return 0.5 * perimeter * self.apothem
The AreaCalculator class never needs modification—it's closed for modification but open for extension through new Shape implementations.
Real-World Application: Plugin Systems
(Note: This is a constructed example for illustration)
OCP shines in plugin architectures:
class DataExporter(ABC):
@abstractmethod
def export(self, data):
pass
class CSVExporter(DataExporter):
def export(self, data):
# CSV export logic
return ','.join(str(d) for d in data)
class JSONExporter(DataExporter):
def export(self, data):
import json
return json.dumps(data)
class ReportGenerator:
def __init__(self, exporter: DataExporter):
self.exporter = exporter
def generate(self, data):
processed = self._process_data(data)
return self.exporter.export(processed)
New export formats (XML, PDF, Excel) are added without touching ReportGenerator.
Practical Considerations
When to apply OCP:
- Anticipating multiple variations of an algorithm
- Building extensible frameworks or libraries
- Code with a history of frequent modification for similar changes
When to hold back:
- Requirements are stable and unlikely to change
- The abstraction cost exceeds the extension benefit
- You're prematurely optimizing for hypothetical future needs
Liskov Substitution Principle (LSP)
The Principle
"Objects of a superclass should be replaceable with objects of its subclasses without affecting program correctness."
LSP ensures that inheritance hierarchies are logically sound. A subclass must honor the behavioral contract established by its parent class.
Understanding Behavioral Contracts
LSP operates at two levels:
- Signature compatibility: Method parameters and return types
- Behavioral compatibility: Semantic expectations and invariants
Many developers focus only on signature compatibility, but behavioral compatibility is where LSP violations typically occur.
The Problem: Behavioral Contract Violation
Consider this classic violation documented in research:
class Vehicle:
def start_engine(self):
# Start the engine
print("Engine started")
class Car(Vehicle):
def start_engine(self):
print("Car engine started")
class Bicycle(Vehicle):
def start_engine(self):
# Bicycles don't have engines!
raise NotImplementedError("Bicycles don't have engines")
This violates LSP because code expecting a Vehicle will break with a Bicycle:
def prepare_for_journey(vehicle: Vehicle):
vehicle.start_engine() # Crashes if vehicle is a Bicycle
print("Ready to go")
car = Car()
prepare_for_journey(car) # Works
bike = Bicycle()
prepare_for_journey(bike) # Raises exception - LSP violation
The behavioral contract of Vehicle.start_engine() implies it will successfully start. Bicycle breaks this contract.
The Solution: Correct Abstraction
class Vehicle:
def start(self):
# Generic start method
pass
class MotorizedVehicle(Vehicle):
def start(self):
self.start_engine()
def start_engine(self):
print("Engine started")
class Car(MotorizedVehicle):
def start_engine(self):
print("Car engine started")
class Bicycle(Vehicle):
def start(self):
print("Ready to pedal")
def prepare_for_journey(vehicle: Vehicle):
vehicle.start() # Works for all Vehicle subtypes
print("Ready to go")
Now both Car and Bicycle correctly implement the Vehicle contract.
Method Signature Variance
LSP also requires careful handling of method signatures. The design rule documented in research states: derived classes must accept same or broader parameter types and return same or narrower return types.
class NotificationService:
def send(self, message: str) -> bool:
# Returns True if sent successfully
pass
class EmailNotificationService(NotificationService):
# Valid: accepts same parameter type, returns same type
def send(self, message: str) -> bool:
# Email sending logic
return True
class SMSNotificationService(NotificationService):
# LSP violation: requires additional parameter
def send(self, message: str, phone_number: str) -> bool:
# SMS sending logic
return True
The SMSNotificationService violates LSP because it cannot substitute for NotificationService without additional information.
Practical Considerations
When to apply LSP:
- Designing inheritance hierarchies
- Creating polymorphic interfaces
- Building frameworks where substitutability is critical
When LSP violations indicate design problems:
- Subclasses throwing exceptions for inherited methods
- Subclasses returning null or empty results for required operations
- Need for type-checking before calling methods
Dependency Inversion Principle (DIP)
The Principle
As documented in research, DIP has two parts:
- "High-level modules should not import anything from low-level modules. Both should depend on abstractions."
- "Abstractions should not depend on details. Details should depend on abstractions."
DIP inverts the traditional dependency flow where high-level business logic depends directly on low-level implementation details.
Understanding Abstraction Ownership
A critical aspect often overlooked: ownership of abstractions belongs to the high-level layer. The high-level module defines the interface it needs, and low-level modules implement that interface. This is the "inversion" in Dependency Inversion.
The Problem: Direct Dependencies
class MySQLDatabase:
def connect(self):
print("Connecting to MySQL")
def query(self, sql):
print(f"Executing: {sql}")
return [{"id": 1, "name": "John"}]
class UserService:
def __init__(self):
# High-level module depends on low-level implementation
self.database = MySQLDatabase()
def get_user(self, user_id):
self.database.connect()
return self.database.query(f"SELECT * FROM users WHERE id = {user_id}")
Problems:
UserServicecannot use PostgreSQL without modification- Testing requires a real MySQL database
- Changes to
MySQLDatabaseaffectUserService
The Solution: Dependency Inversion
from abc import ABC, abstractmethod
# High-level module defines the abstraction it needs
class DatabaseInterface(ABC):
@abstractmethod
def fetch_user(self, user_id):
pass
# High-level module depends only on abstraction
class UserService:
def __init__(self, database: DatabaseInterface):
self.database = database
def get_user(self, user_id):
return self.database.fetch_user(user_id)
# Low-level modules implement the abstraction
class MySQLDatabase(DatabaseInterface):
def fetch_user(self, user_id):
# MySQL-specific implementation
print(f"MySQL: Fetching user {user_id}")
return {"id": user_id, "name": "John"}
class PostgreSQLDatabase(DatabaseInterface):
def fetch_user(self, user_id):
# PostgreSQL-specific implementation
print(f"PostgreSQL: Fetching user {user_id}")
return {"id": user_id, "name": "John"}
class MockDatabase(DatabaseInterface):
def fetch_user(self, user_id):
# Test implementation
return {"id": user_id, "name": "Test User"}
# Usage with dependency injection
mysql_db = MySQLDatabase()
user_service = UserService(mysql_db)
user_service.get_user(1)
# Easy to swap implementations
postgres_db = PostgreSQLDatabase()
user_service = UserService(postgres_db)
user_service.get_user(1)
# Easy to test
mock_db = MockDatabase()
test_service = UserService(mock_db)
The dependency flow is inverted:
- Before:
UserService→MySQLDatabase - After:
UserService→DatabaseInterface←MySQLDatabase
Both high-level and low-level modules depend on the abstraction.
Dependency Injection Patterns
DIP is typically implemented through dependency injection. Three common patterns:
Constructor Injection (shown above):
class OrderProcessor:
def __init__(self, payment_gateway: PaymentGateway,
inventory: InventoryService):
self.payment_gateway = payment_gateway
self.inventory = inventory
Setter Injection:
class ReportGenerator:
def set_data_source(self, source: DataSource):
self.data_source = source
Interface Injection (less common in Python):
class ConfigurableService:
def configure(self, config: Configuration):
self.config = config
Practical Considerations
When to apply DIP:
- Integrating external services (databases, APIs, file systems)
- Code requiring extensive unit testing
- Components likely to have multiple implementations
When to hold back:
- Simple, stable dependencies unlikely to change
- Internal utilities with no alternative implementations
- When abstraction overhead exceeds flexibility benefits
Applying SOLID Principles in Real-World Low-Level Design
Case Study: Order Processing System
(Note: This is a constructed example for illustration)
Let's apply multiple SOLID principles to a realistic scenario:
Requirements:
- Process customer orders
- Validate order details
- Check inventory
- Process payment
- Send confirmation email
- Log transactions
Initial Design (violating SOLID):
class OrderProcessor:
def process_order(self, order_data):
# Validation
if not order_data.get('email'):
raise ValueError("Email required")
if order_data['total'] <= 0:
raise ValueError("Invalid total")
# Inventory check
connection = self._connect_to_inventory_db()
cursor = connection.cursor()
cursor.execute("SELECT stock FROM inventory WHERE product_id = ?",
(order_data['product_id'],))
stock = cursor.fetchone()[0]
if stock < order_data['quantity']:
raise ValueError("Insufficient stock")
# Payment processing
if order_data['payment_method'] == 'credit_card':
# Credit card logic
pass
elif order_data['payment_method'] == 'paypal':
# PayPal logic
pass
# Email notification
# Email sending code
# Logging
print(f"Order processed: {order_data['order_id']}")
SOLID-Compliant Refactor:
# SRP: Separate validation
class OrderValidator:
def validate(self, order):
if not order.email:
raise ValueError("Email required")
if order.total <= 0:
raise ValueError("Invalid total")
# SRP: Separate inventory checking
class InventoryService:
def __init__(self, repository):
self.repository = repository
def check_availability(self, product_id, quantity):
stock = self.repository.get_stock(product_id)
return stock >= quantity
# OCP + DIP: Payment abstraction
class PaymentGateway(ABC):
@abstractmethod
def charge(self, amount, payment_details):
pass
class CreditCardGateway(PaymentGateway):
def charge(self, amount, payment_details):
# Credit card processing
return {"status": "success", "transaction_id": "cc_123"}
class PayPalGateway(PaymentGateway):
def charge(self, amount, payment_details):
# PayPal processing
return {"status": "success", "transaction_id": "pp_456"}
# SRP: Separate notification
class NotificationService:
def send_order_confirmation(self, email, order):
# Email sending logic
pass
# SRP: Separate logging
class TransactionLogger:
def log_order(self, order_id, status):
print(f"Order {order_id}: {status}")
# High-level orchestration
class OrderProcessor:
def __init__(self, validator: OrderValidator,
inventory: InventoryService,
payment_gateway: PaymentGateway,
notifier: NotificationService,
logger: TransactionLogger):
self.validator = validator
self.inventory = inventory
self.payment_gateway = payment_gateway
self.notifier = notifier
self.logger = logger
def process(self, order):
self.validator.validate(order)
if not self.inventory.check_availability(
order.product_id, order.quantity
):
raise ValueError("Insufficient stock")
result = self.payment_gateway.charge(
order.total, order.payment_details
)
if result['status'] == 'success':
self.notifier.send_order_confirmation(order.email, order)
self.logger.log_order(order.id, 'completed')
return result
Benefits achieved:
- SRP: Each class has one responsibility
- OCP: New payment methods added without modifying
OrderProcessor - DIP: High-level
OrderProcessordepends on abstractions - Testability: Each component can be tested independently with mocks
Common Mistakes and Misconceptions
Mistake 1: Over-Engineering Simple Code
The most common mistake is applying SOLID principles where they add unnecessary complexity. Not every class needs an interface. Not every method needs extraction.
Bad:
# Overkill for a simple utility
class StringReverser(ABC):
@abstractmethod
def reverse(self, text):
pass
class ConcreteStringReverser(StringReverser):
def reverse(self, text):
return text[::-1]
Good:
# Simple function is fine
def reverse_string(text):
return text[::-1]
Mistake 2: Premature Abstraction
Creating abstractions before you have multiple concrete implementations often leads to wrong abstractions.
Rule of thumb: Wait until you have at least two implementations before creating an abstraction. The patterns will be clearer.
Mistake 3: Confusing SRP with "Do One Thing"
SRP is about having one reason to change, not doing one thing. A class can have multiple methods if they all support the same responsibility.
Valid SRP:
class UserAuthentication:
def authenticate(self, username, password):
pass
def validate_password_strength(self, password):
pass
def hash_password(self, password):
pass
def verify_password(self, password, hash):
pass
All methods relate to authentication—one responsibility, one reason to change.
Mistake 4: LSP Violations Through Weakening Postconditions
Subclasses that return less information than the parent violate LSP:
class UserRepository:
def find_by_id(self, user_id) -> User:
# Always returns a User or raises exception
pass
class CachedUserRepository(UserRepository):
def find_by_id(self, user_id) -> User:
# Returns None if not in cache - LSP violation
return self.cache.get(user_id)

