Object-oriented design can be challenging. How do you know if your code is well-structured? The SOLID principles provide a framework for writing maintainable, flexible, and scalable software. Let's explore each principle with practical examples.
SOLID is an acronym representing five fundamental principles of object-oriented design, introduced by Robert C. Martin (Uncle Bob):
These principles help you create code that is easier to understand, maintain, and extend.
Definition: A class should have one, and only one, reason to change.
Consider a User
class that does too much:
public class User {
private String name;
private String email;
// User data management
public void save() {
// Database logic here
}
// Email functionality
public void sendEmail(String message) {
// Email sending logic here
}
// Report generation
public void generateReport() {
// Report generation logic here
}
}
Problem: This class has three reasons to change:
Split responsibilities into focused classes:
// User data only
public class User {
private String name;
private String email;
// Getters and setters only
}
// Database operations
public class UserRepository {
public void save(User user) {
// Database logic here
}
}
// Email operations
public class EmailService {
public void sendEmail(User user, String message) {
// Email sending logic here
}
}
// Report operations
public class UserReportGenerator {
public void generateReport(User user) {
// Report generation logic here
}
}
Benefits:
Definition: Software entities should be open for extension but closed for modification.
A payment processor that requires modification for each new payment method:
class PaymentProcessor:
def process_payment(self, amount, payment_type):
if payment_type == "credit_card":
# Process credit card
print(f"Processing ${amount} via credit card")
elif payment_type == "paypal":
# Process PayPal
print(f"Processing ${amount} via PayPal")
elif payment_type == "crypto":
# Process cryptocurrency
print(f"Processing ${amount} via crypto")
# Adding new payment methods requires modifying this class!
Problem: Every time you add a new payment method, you must modify the PaymentProcessor
class, risking bugs in existing functionality.
Use abstraction and polymorphism:
from abc import ABC, abstractmethod
# Abstract base class
class PaymentMethod(ABC):
@abstractmethod
def process(self, amount):
pass
# Concrete implementations
class CreditCardPayment(PaymentMethod):
def process(self, amount):
print(f"Processing ${amount} via credit card")
class PayPalPayment(PaymentMethod):
def process(self, amount):
print(f"Processing ${amount} via PayPal")
class CryptoPayment(PaymentMethod):
def process(self, amount):
print(f"Processing ${amount} via crypto")
# Payment processor - closed for modification
class PaymentProcessor:
def process_payment(self, payment_method: PaymentMethod, amount):
payment_method.process(amount)
# Usage
processor = PaymentProcessor()
processor.process_payment(CreditCardPayment(), 100)
processor.process_payment(PayPalPayment(), 200)
Benefits:
Definition: Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.
A violation of LSP with the classic Rectangle/Square example:
public class Rectangle {
protected int width;
protected int height;
public void setWidth(int width) {
this.width = width;
}
public void setHeight(int height) {
this.height = height;
}
public int getArea() {
return width * height;
}
}
public class Square extends Rectangle {
@Override
public void setWidth(int width) {
this.width = width;
this.height = width; // Force square constraint
}
@Override
public void setHeight(int height) {
this.width = height; // Force square constraint
this.height = height;
}
}
// This breaks LSP!
public void testArea(Rectangle rect) {
rect.setWidth(5);
rect.setHeight(4);
// Expected: 20, but Square gives 16!
assert rect.getArea() == 20; // Fails for Square
}
Problem: A Square
cannot truly substitute a Rectangle
because it violates the expected behavior.
Use composition or redesign the hierarchy:
public interface Shape {
int getArea();
}
public class Rectangle implements Shape {
private int width;
private int height;
public Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
public int getArea() {
return width * height;
}
}
public class Square implements Shape {
private int side;
public Square(int side) {
this.side = side;
}
public int getArea() {
return side * side;
}
}
Benefits:
Definition: No client should be forced to depend on methods it does not use.
A "fat" interface that forces implementations to include unnecessary methods:
class Worker:
def work(self):
pass
def eat(self):
pass
def sleep(self):
pass
class HumanWorker(Worker):
def work(self):
print("Human working")
def eat(self):
print("Human eating")
def sleep(self):
print("Human sleeping")
class RobotWorker(Worker):
def work(self):
print("Robot working")
def eat(self):
# Robots don't eat! But forced to implement this
raise NotImplementedError("Robots don't eat")
def sleep(self):
# Robots don't sleep! But forced to implement this
raise NotImplementedError("Robots don't sleep")
Problem: The RobotWorker
is forced to implement methods that don't make sense for its nature.
Create smaller, focused interfaces:
class Workable:
def work(self):
pass
class Eatable:
def eat(self):
pass
class Sleepable:
def sleep(self):
pass
class HumanWorker(Workable, Eatable, Sleepable):
def work(self):
print("Human working")
def eat(self):
print("Human eating")
def sleep(self):
print("Human sleeping")
class RobotWorker(Workable):
def work(self):
print("Robot working")
# No need to implement eat() or sleep()
Benefits:
Definition: High-level modules should not depend on low-level modules. Both should depend on abstractions.
Direct dependency on concrete implementations:
// Low-level module
public class MySQLDatabase {
public void save(String data) {
System.out.println("Saving to MySQL: " + data);
}
}
// High-level module directly depends on low-level module
public class UserService {
private MySQLDatabase database = new MySQLDatabase();
public void saveUser(String userData) {
database.save(userData);
}
}
Problem:
UserService
is tightly coupled to MySQLDatabase
UserService
Depend on abstractions (interfaces):
// Abstraction
public interface Database {
void save(String data);
}
// Low-level modules implement the abstraction
public class MySQLDatabase implements Database {
public void save(String data) {
System.out.println("Saving to MySQL: " + data);
}
}
public class PostgreSQLDatabase implements Database {
public void save(String data) {
System.out.println("Saving to PostgreSQL: " + data);
}
}
// High-level module depends on abstraction
public class UserService {
private Database database;
// Dependency injection via constructor
public UserService(Database database) {
this.database = database;
}
public void saveUser(String userData) {
database.save(userData);
}
}
// Usage
Database db = new MySQLDatabase();
UserService service = new UserService(db);
service.saveUser("John Doe");
// Easy to switch implementations
Database postgresDb = new PostgreSQLDatabase();
UserService postgresService = new UserService(postgresDb);
Benefits:
When designing a new class, ask: "What is this class's single responsibility?" If you struggle to answer in one sentence, consider splitting it.
Define interfaces before implementations. This naturally leads to better OCP and DIP adherence.
Many LSP violations can be avoided by preferring composition to inheritance.
When designing interfaces, start minimal and extend as needed (ISP). It's easier to add methods later than to remove them.
Writing tests first often naturally leads to SOLID-compliant designs because testable code tends to follow these principles.
Let's see how SOLID principles work together in a realistic scenario:
# SRP: Each class has one responsibility
class Order:
def __init__(self, items, customer):
self.items = items
self.customer = customer
self.total = sum(item.price for item in items)
# OCP: Open for extension (new payment methods)
class PaymentProcessor(ABC):
@abstractmethod
def process(self, amount):
pass
class StripePayment(PaymentProcessor):
def process(self, amount):
print(f"Processing ${amount} via Stripe")
# ISP: Separate interfaces for different capabilities
class Notifiable(ABC):
@abstractmethod
def notify(self, message):
pass
class Trackable(ABC):
@abstractmethod
def track(self):
pass
# DIP: Depend on abstractions
class OrderService:
def __init__(self, payment_processor: PaymentProcessor,
notifier: Notifiable):
self.payment_processor = payment_processor
self.notifier = notifier
def process_order(self, order: Order):
self.payment_processor.process(order.total)
self.notifier.notify(f"Order processed for {order.customer}")
# LSP: All payment processors can substitute each other
class PayPalPayment(PaymentProcessor):
def process(self, amount):
print(f"Processing ${amount} via PayPal")
Don't apply SOLID principles prematurely. Start simple and refactor when complexity grows.
Don't obsess over perfect adherence. SOLID principles are guidelines, not laws.
Some principles may conflict in specific scenarios. Use judgment to find the right balance.
The SOLID principles provide a proven framework for creating maintainable, flexible software. They work together to create systems that are:
Start applying these principles incrementally. Even small improvements in code structure compound over time, leading to significantly better software quality.
Remember: Good design is about managing dependencies and responsibilities. SOLID principles give you the tools to do both effectively.
Ready to apply these principles in your projects? Start with one class that feels complex and ask: "Which SOLID principle would make this better?"