SOLID Principles
Table of Contents
- Single Responsibility Principle (SRP)
- Open/Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
- Code Smells and How SOLID Fixes Them
- Design Patterns Related to SOLID
- Real-World Applications Using SOLID
SOLID Principles
SOLID is an acronym for five design principles intended to make software designs more understandable, flexible, and maintainable. Introduced by Robert C. Martin (Uncle Bob), these principles have become fundamental guidelines for professional software developers.
The SOLID principles particularly useful in object-oriented programming and serve as guidelines for creating robust software architectures. This document will explore each of the SOLID principles in detail, providing insights into their significance and practical applications.
Principle | Short Name | Definition |
---|---|---|
S | Single Responsibility | A class should have only one reason to change — one job only. |
O | Open/Closed | Code should be open for extension but closed for modification. |
L | Liskov Substitution | Subclasses should be replaceable for their parent class without issues. |
I | Interface Segregation | Use many small, specific interfaces instead of one large one. |
D | Dependency Inversion | Depend on abstractions, not concrete implementations. |
1. Single Responsibility Principle
Definition
A class should have one, and only one, reason to change.
The Single Responsibility Principle (SRP) states that a class should have only one job or responsibility. Another way to express this is that a class should have only one reason to change.
Real-World Example
Consider a restaurant kitchen:
- The chef focuses only on cooking
- The server focuses only on taking orders and serving
- The cashier focuses only on handling payments
Each role has a specific responsibility, making the restaurant run more efficiently.
Code Example
Violation:
// A class handling multiple responsibilities
public class Employee {
private String name;
private String id;
public void calculatePay() {
// Calculate employee's pay
}
public void saveToDatabase() {
// Save employee to database
}
public void generateReport() {
// Generate employee report
}
}
Following SRP:
// Employee only contains data
public class Employee {
private String name;
private String id;
public String getName() { return name; }
public String getId() { return id; }
}
// Separate classes for separate responsibilities
public class PayCalculator {
public void calculatePay(Employee employee) {
// Calculate employee's pay
}
}
public class EmployeeRepository {
public void save(Employee employee) {
// Save employee to database
}
}
public class ReportGenerator {
public void generateReport(Employee employee) {
// Generate employee report
}
}
Benefits
- Improved code organization: Each class has a clear purpose, making the codebase easier to navigate.
- Better testability: Testing becomes simpler when classes have a single focus.
- Reduced coupling: Classes with focused responsibilities are less likely to be affected by changes elsewhere.
- Enhanced code reusability: Single-responsibility classes are more likely to be reusable in other contexts.
- Easier maintenance: When issues arise, locating and fixing them is simpler.
Common Mistakes/Violations
- God classes: Large classes that handle numerous unrelated responsibilities.
- Mixed concerns: Combining business logic with infrastructure concerns like database operations or UI rendering.
- Feature creep: Gradually adding more functionality to a class over time until it loses focus.
- Utility classes with unrelated methods: Creating "catch-all" utility classes.
- Changes affecting multiple classes: If a single change in requirements affects multiple classes, it may indicate SRP violations.
2. Open/Closed Principle
Definition
Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.
The Open/Closed Principle (OCP) means that you should be able to extend the behavior of a system without modifying its existing code.
Real-World Example
Consider a smartphone with apps:
- The phone's operating system provides an interface (API) for apps
- New apps can be installed to add functionality
- The core operating system remains unchanged
- The phone gains new capabilities without modifying its base code
Code Example
Violation:
public class Rectangle {
public double width;
public double height;
}
public class Circle {
public double radius;
}
public class AreaCalculator {
public double calculateArea(Object shape) {
if (shape instanceof Rectangle) {
Rectangle rectangle = (Rectangle) shape;
return rectangle.width * rectangle.height;
} else if (shape instanceof Circle) {
Circle circle = (Circle) shape;
return Math.PI * circle.radius * circle.radius;
}
throw new IllegalArgumentException("Unsupported shape");
}
}
In this case, adding a new shape would require modifying the AreaCalculator
class.
Following OCP:
public interface Shape {
double calculateArea();
}
public class Rectangle implements Shape {
private double width;
private double height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double calculateArea() {
return width * height;
}
}
public class Circle implements Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}
public class Triangle implements Shape {
private double base;
private double height;
public Triangle(double base, double height) {
this.base = base;
this.height = height;
}
@Override
public double calculateArea() {
return 0.5 * base * height;
}
}
// AreaCalculator now works with any Shape without modification
public class AreaCalculator {
public double calculateArea(Shape shape) {
return shape.calculateArea();
}
}
Benefits
- Flexibility: New functionality can be added without disrupting existing code.
- Reduced regression risk: Existing functionality remains untouched when adding new features.
- Better code organization: Extension points become clearly defined.
- Enhanced productivity: Development teams can work on different extensions simultaneously.
- Improved stability: Core code that's already tested and working remains unchanged.
Common Mistakes/Violations
- Switch/if-else chains based on type: Code that switches behavior based on object type often violates OCP.
- Direct class instantiation: Creating concrete instances rather than working with abstractions.
- Tight coupling: Classes dependent on implementation details rather than abstractions.
- Exposing implementation details: Public APIs that reveal how something is implemented rather than what it does.
- Subclassing for feature variations: Extending classes to add variation instead of using composition.
3. Liskov Substitution Principle
Definition
Objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.
The Liskov Substitution Principle (LSP) states that subclasses should extend the base class's capabilities without changing its behavior.
Real-World Example
Consider a car rental service:
- If a customer reserves a compact car, they'd accept a mid-size or luxury car at the same price (an upgrade)
- The customer expects any car to have basic functionalities (steering, braking, acceleration)
- Substituting one car with another doesn't break the customer's expectations
Code Example
Violation:
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;
}
}
// Square inherits from Rectangle but changes behavior
public class Square extends Rectangle {
@Override
public void setWidth(int width) {
super.setWidth(width);
super.setHeight(width); // Square enforces equal sides
}
@Override
public void setHeight(int height) {
super.setHeight(height);
super.setWidth(height); // Square enforces equal sides
}
}
// This code will fail with Squares
public void testRectangle(Rectangle r) {
r.setWidth(5);
r.setHeight(4);
// For a Rectangle, area should be 20
// For a Square, area would be 16 (as height setting changed width too)
assert r.getArea() == 20; // This will fail for Square
}
Following LSP:
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 void setWidth(int width) {
this.width = width;
}
public void setHeight(int height) {
this.height = height;
}
@Override
public int getArea() {
return width * height;
}
}
public class Square implements Shape {
private int side;
public Square(int side) {
this.side = side;
}
public void setSide(int side) {
this.side = side;
}
@Override
public int getArea() {
return side * side;
}
}
Benefits
- Type safety: Avoid unexpected behavior when using polymorphism.
- Improved reusability: Code that works with base classes can work correctly with derived classes.
- Better abstraction: Forces proper modeling of the "is-a" relationship.
- Enhanced testability: Subtypes can be tested against the same test cases as their parent types.
- Reduced coupling: Code depends on behavior not implementation.
Common Mistakes/Violations
- Strengthening preconditions: Derived classes that are more restrictive about their inputs.
- Weakening postconditions: Derived classes that promise less than their base class.
- Exception modification: Throwing exceptions not expected from the base class.
- Violating invariants: Changing properties that should remain unchanged.
- The square/rectangle problem: Forcing inheritance relationships that don't model substitutability correctly.
4. Interface Segregation Principle
Definition
Clients should not be forced to depend on interfaces they don't use.
The Interface Segregation Principle (ISP) encourages creating smaller, more specific interfaces rather than large, general-purpose ones.
Real-World Example
Consider a universal remote control:
- A remote with 100 buttons for all possible devices is overwhelming
- Most users only need a few buttons for their specific device
- Better to have specific remotes (TV remote, DVD remote) or programmable remotes
Code Example
Violation:
// A "fat" interface that forces implementing classes to provide
// methods they might not need
public interface Worker {
void work();
void eat();
void sleep();
}
// A robot can work but doesn't eat or sleep
public class Robot implements Worker {
@Override
public void work() {
// Robot working
}
@Override
public void eat() {
// Robot doesn't eat, but must implement this
throw new UnsupportedOperationException();
}
@Override
public void sleep() {
// Robot doesn't sleep, but must implement this
throw new UnsupportedOperationException();
}
}
Following ISP:
public interface Workable {
void work();
}
public interface Eatable {
void eat();
}
public interface Sleepable {
void sleep();
}
// Robot only implements what it needs
public class Robot implements Workable {
@Override
public void work() {
// Robot working
}
}
// Human implements all interfaces
public class Human implements Workable, Eatable, Sleepable {
@Override
public void work() {
// Human working
}
@Override
public void eat() {
// Human eating
}
@Override
public void sleep() {
// Human sleeping
}
}
Benefits
- Reduced coupling: Clients only depend on what they actually use.
- Focused interfaces: Each interface serves a specific purpose.
- Improved readability: Interfaces communicate their intent more clearly.
- Better adaptability: Easier to adapt to changing requirements when interfaces are specific.
- Avoid "dummy" implementations: No more empty or throw-exception implementations for unused methods.
Common Mistakes/Violations
- "Kitchen sink" interfaces: Interfaces with many unrelated methods.
- Interfaces that serve multiple distinct client types: Different clients needing different subsets of methods.
- "Header interfaces": Interfaces extracted from concrete classes without considering client usage patterns.
- Forcing implementation of irrelevant methods: Making implementing classes provide methods they don't need.
- Single interface for all services: Creating one large service interface instead of multiple focused ones.
5. Dependency Inversion Principle
Definition
High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.
The Dependency Inversion Principle (DIP) suggests that modules should depend on abstractions rather than concrete implementations, allowing for flexibility and decoupling.
Real-World Example
Consider electrical appliances and power outlets:
- Appliances don't connect directly to the power grid
- They connect to standardized outlets (abstractions)
- Different appliances can use the same outlet type
- Different power sources can provide the same outlet format
Code Example
Violation:
// Lower-level module
public class MySQLDatabase {
public void save(String data) {
// Code to save data to MySQL
}
}
// Higher-level module depending directly on lower-level module
public class UserService {
private MySQLDatabase database;
public UserService() {
this.database = new MySQLDatabase();
}
public void registerUser(String userData) {
// Some business logic
database.save(userData);
}
}
Following DIP:
// Abstract interface
public interface Database {
void save(String data);
}
// Lower-level module implementing the abstraction
public class MySQLDatabase implements Database {
@Override
public void save(String data) {
// Code to save data to MySQL
}
}
public class MongoDatabase implements Database {
@Override
public void save(String data) {
// Code to save data to MongoDB
}
}
// Higher-level module depending on abstraction
public class UserService {
private Database database;
// Dependency injection via constructor
public UserService(Database database) {
this.database = database;
}
public void registerUser(String userData) {
// Some business logic
database.save(userData);
}
}
Benefits
- Decoupling: High-level modules aren't affected by changes in low-level modules.
- Flexibility: Implementations can be swapped easily.
- Testability: Dependencies can be mocked or stubbed for testing.
- Parallel development: Teams can work on different layers simultaneously.
- Better reusability: Both high and low-level components can be reused independently.
Common Mistakes/Violations
- Direct instantiation of dependencies: Using
new
to create dependencies directly instead of injecting them. - Concrete class references: Referencing concrete classes instead of interfaces.
- Flow of control matching source code dependency: Having the dependency direction match the control flow.
- Missing abstraction layer: Direct dependencies between modules without an abstraction layer.
- "Leaky" abstractions: Abstractions that expose implementation details.
6. Code Smells and How SOLID Fixes Them
1. Rigidity
Smell: Software is difficult to change because every change affects too many parts of the system. SOLID Solution:
- SRP: Isolates changes to single-responsibility classes
- OCP: Allows extending functionality without modifying existing code
- DIP: Decouples high-level modules from low-level details
2. Fragility
Smell: Changes in one part of the system break seemingly unrelated parts. SOLID Solution:
- SRP: Localizes changes to focused components
- LSP: Ensures substitutable components don't break expectations
- ISP: Minimizes dependencies between components
3. Immobility
Smell: Components are hard to reuse because they're too entangled with the current system. SOLID Solution:
- SRP: Creates focused, reusable components
- ISP: Defines clean, targeted interfaces
- DIP: Dependencies on abstractions make components portable
4. Viscosity
Smell: Doing things the "right way" is harder than taking shortcuts. SOLID Solution:
- OCP: Creates clear extension points
- DIP: Makes proper dependency management easier through abstractions
5. Needless Complexity
Smell: Overengineered solutions that are more complex than necessary. SOLID Solution:
- SRP: Focuses on simplicity and clear responsibilities
- ISP: Creates minimal interfaces focused on client needs
6. Needless Repetition
Smell: Copy-pasted code or repeated implementations of similar functionality. SOLID Solution:
- OCP: Encourages reuse through extension
- DIP: Enables sharing implementations through common abstractions
7. Opacity
Smell: Code that is hard to understand or reason about. SOLID Solution:
- SRP: Creates simple, focused classes with clear purposes
- LSP: Makes behavior predictable and consistent
- ISP: Creates clear, purpose-driven interfaces
7. Design Patterns Related to SOLID
Many design patterns naturally implement SOLID principles:
1. Strategy Pattern
Related Principles: OCP, DIP Example:
// Abstractions
interface SortStrategy {
void sort(int[] array);
}
// Implementations
class QuickSort implements SortStrategy {
@Override
public void sort(int[] array) {
// Quick sort implementation
}
}
class MergeSort implements SortStrategy {
@Override
public void sort(int[] array) {
// Merge sort implementation
}
}
// Context
class Sorter {
private SortStrategy strategy;
public Sorter(SortStrategy strategy) {
this.strategy = strategy;
}
public void setStrategy(SortStrategy strategy) {
this.strategy = strategy;
}
public void sortArray(int[] array) {
strategy.sort(array);
}
}
2. Factory Method Pattern
Related Principles: DIP, OCP Example:
interface Product {
void operation();
}
abstract class Creator {
public void anOperation() {
Product p = createProduct();
p.operation();
}
protected abstract Product createProduct();
}
class ConcreteCreatorA extends Creator {
@Override
protected Product createProduct() {
return new ConcreteProductA();
}
}
class ConcreteProductA implements Product {
@Override
public void operation() {
// Implementation
}
}
3. Observer Pattern
Related Principles: SRP, OCP Example:
interface Observer {
void update(String message);
}
class Subject {
private List<Observer> observers = new ArrayList<>();
public void addObserver(Observer observer) {
observers.add(observer);
}
public void removeObserver(Observer observer) {
observers.remove(observer);
}
public void notifyObservers(String message) {
for(Observer observer : observers) {
observer.update(message);
}
}
}
class EmailNotifier implements Observer {
@Override
public void update(String message) {
// Send email notification
}
}
class SMSNotifier implements Observer {
@Override
public void update(String message) {
// Send SMS notification
}
}
4. Decorator Pattern
Related Principles: OCP, SRP Example:
interface Component {
String operation();
}
class ConcreteComponent implements Component {
@Override
public String operation() {
return "Basic functionality";
}
}
abstract class Decorator implements Component {
protected Component component;
public Decorator(Component component) {
this.component = component;
}
@Override
public String operation() {
return component.operation();
}
}
class ConcreteDecoratorA extends Decorator {
public ConcreteDecoratorA(Component component) {
super(component);
}
@Override
public String operation() {
return "Decorator A(" + super.operation() + ")";
}
}
5. Dependency Injection
Related Principles: DIP Example:
class Service {
private final Repository repository;
private final Logger logger;
// Constructor injection
public Service(Repository repository, Logger logger) {
this.repository = repository;
this.logger = logger;
}
public void doSomething() {
logger.log("Starting operation");
repository.getData();
logger.log("Operation complete");
}
}
8. Real-World Applications Using SOLID
1. Spring Framework
Spring heavily relies on the Dependency Inversion Principle through its IoC (Inversion of Control) container. It manages object creation and dependency injection, allowing developers to focus on business logic rather than object instantiation.
@Service
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
// Spring automatically injects dependencies
public UserService(UserRepository userRepository, EmailService emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
public void registerUser(User user) {
userRepository.save(user);
emailService.sendWelcomeEmail(user);
}
}
2. Android Architecture Components
Google's recommended Android architecture (ViewModel, LiveData, Room) follows SOLID principles:
- SRP: Different components for UI (Activities/Fragments), business logic (ViewModel), and data (Repository)
- OCP: Observers pattern via LiveData allows extending functionality without modifying core components
- DIP: Dependencies on interfaces rather than concrete implementations
// Repository follows DIP by depending on DAO interface
class UserRepository(private val userDao: UserDao) {
fun getUsers(): LiveData<List<User>> {
return userDao.getAllUsers()
}
}
// ViewModel uses repository through constructor injection
class UserViewModel(private val repository: UserRepository) : ViewModel() {
val users: LiveData<List<User>> = repository.getUsers()
}
3. ASP.NET Core
Microsoft's web framework extensively uses SOLID:
- DIP: Uses dependency injection throughout the framework
- ISP: Segregated interfaces (ILogger, IConfiguration, etc.)
- OCP: Middleware pipeline pattern for extending behavior
public class Startup {
public void ConfigureServices(IServiceCollection services) {
// Register dependencies
services.AddScoped<IUserRepository, SqlUserRepository>();
services.AddScoped<IUserService, UserService>();
}
}
public class UserController : ControllerBase {
private readonly IUserService _userService;
// DI container injects the dependency
public UserController(IUserService userService) {
_userService = userService;
}
[HttpGet]
public async Task<ActionResult<IEnumerable<User>>> GetUsers() {
return await _userService.GetAllUsers();
}
}
4. Laravel (PHP Framework)
Laravel implements SOLID in various ways:
- DIP: Service container for dependency injection
- SRP: MVC architecture with specific responsibilities
- OCP: Service providers and middleware for extending functionality
// Service provider registers interface-implementation binding
class AppServiceProvider extends ServiceProvider {
public function register() {
$this->app->bind(
'App\Contracts\PaymentGateway',
'App\Services\StripePaymentGateway'
);
}
}
// Controller receives concrete implementation via abstraction
class OrderController extends Controller {
private $paymentGateway;
public function __construct(PaymentGateway $paymentGateway) {
$this->paymentGateway = $paymentGateway;
}
public function store(Request $request) {
// Process order
$this->paymentGateway->charge($request->amount);
}
}
Conclusion
SOLID principles provide a robust foundation for creating maintainable, flexible, and testable software. By following these principles, developers can:
- Create code that's easier to understand and maintain
- Build systems that can adapt to changing requirements
- Write components that can be reused across projects
- Design architectures that scale with business growth
- Reduce technical debt and development costs over time
While SOLID principles may require more upfront design effort, the long-term benefits far outweigh the initial investment. As software systems grow increasingly complex, these principles become even more crucial for managing that complexity effectively.
Remember that SOLID principles are guidelines, not strict rules. Use them thoughtfully based on your specific context and requirements. Sometimes pragmatic compromises are necessary, but understanding these principles helps you make informed decisions about those trade-offs.