SOLID principles make your code easier to maintain and adapt. These five object-oriented design practices help you write cleaner, more reliable code.
Let's explore each principle with practical examples for building maintainable applications - from single codebases to distributed systems.
What are SOLID principles in software development?
Writing maintainable code is challenging. SOLID principles give you practical guidelines to write better code. Robert C. Martin created these principles to solve common issues like rigid code, cascading bugs, and complex systems.
Each SOLID principle targets a specific coding challenge:
S: Single responsibility principle (SRP): simplify code maintenance
The Single Responsibility Principle keeps your code maintainable. Each class does one specific task - no more, no less.
Think of it this way: Keep user authentication separate from logging or notifications. When a class tries to do multiple things, testing and updating become more complex.
SRP Class Relationships
The diagram uses composition to show how UserRegistration works with other classes. Each hollow diamond arrow means UserRegistration references a class but doesn't control its lifecycle.
Diagram legend:
- means public methods/properties
- means private methods/properties
- Arrows show class dependencies
- Boxes show class names and their members
Here's how separating code responsibilities creates cleaner, more maintainable applications:
// This example demonstrates the Single Responsibility Principle (SRP)
// Each class has one specific job, making the code more maintainable and testable
// UserDataFormatter: Responsible for converting user data into a consistent format
class UserDataFormatter {
public function formatUserData($user) {
// Extracts and formats essential user information
return [
'name' => $user->getFullName(), // Get user's full name
'email' => $user->getEmail(), // Get user's email
'joined' => $user->getJoinDate()->format('Y-m-d') // Format join date
];
}
}
// UserDataValidator: Responsible for ensuring data meets requirements
class UserDataValidator {
public function validateUserData($userData) {
// Performs two validation checks:
// 1. Verifies email is in valid format
// 2. Ensures name field is not empty
return filter_var($userData['email'], FILTER_VALIDATE_EMAIL)
&& !empty($userData['name']);
}
}
// UserDataPersistence: Responsible for database operations
class UserDataPersistence {
private $database;
// Initialize with database connection
public function __construct($database) {
$this->database = $database;
}
// Handles saving user data to the database
public function saveUser($userData) {
// Performs the actual database insert operation
return $this->database->insert('users', $userData);
}
}
// UserRegistration: Orchestrates the registration process
// Acts as a facade, coordinating between other specialized classes
class UserRegistration {
private $formatter;
private $validator;
private $persistence;
// Constructor injection of dependencies
public function __construct(
UserDataFormatter $formatter,
UserDataValidator $validator,
UserDataPersistence $persistence
) {
$this->formatter = $formatter;
$this->validator = $validator;
$this->persistence = $persistence;
}
// Main registration method that coordinates the entire process
public function registerUser($user) {
// Step 1: Format the user data
$userData = $this->formatter->formatUserData($user);
// Step 2: Validate the formatted data
if ($this->validator->validateUserData($userData)) {
// Step 3: If validation passes, save to database
return $this->persistence->saveUser($userData);
}
// If validation fails, throw an exception
throw new ValidationException('Invalid user data');
}
}
// EXAMPLE USAGE DEMONSTRATION
// Below shows how all components work together
// Create a mock database for demonstration
$database = new class {
public function insert($table, $data) {
// Simulates database insertion and returns success
echo "Inserted into $table: " . json_encode($data) . "\\n";
return true;
}
};
try {
// Step 1: Initialize all required components
$formatter = new UserDataFormatter();
$validator = new UserDataValidator();
$persistence = new UserDataPersistence($database);
// Step 2: Create the main UserRegistration service
$userRegistration = new UserRegistration($formatter, $validator, $persistence);
// Step 3: Create a mock user object for testing
$user = new class {
public function getFullName() {
return "Jane Doe";
}
public function getEmail() {
return "jane@example.com";
}
public function getJoinDate() {
return new DateTime();
}
};
// Step 4: Attempt to register the user
$userRegistration->registerUser($user);
echo "User successfully registered!";
} catch (ValidationException $e) {
// Handle any validation errors that occur
echo "Failed to register user: " . $e->getMessage();
}
// Custom exception class for validation-specific errors
class ValidationException extends Exception {}
Each class has a single, focused job. The formatter formats data. The validator checks data. The persistence layer saves data. The registration class coordinates these pieces cleanly. This structure makes testing and maintenance straightforward.
This clean design aligns with the Open-Closed principle. Want to add new formatting rules or validation checks? Create new classes that follow the existing pattern. Your code stays intact while functionality grows.
O: Open-closed principle (OCP): write extendable and scalable code
The Open-Closed Principle (OCP) helps you add features without modifying existing code. Your codebase stays stable as it grows. Instead of changing existing code, extend functionality through new classes and interfaces.
L: Liskov substitution principle (LSP): avoid code breakage in OOP
The Liskov Substitution Principle (LSP) means child classes must work anywhere their parent classes do. If they don't, your code will break in unexpected ways.
Here's a common LSP violation and how to fix it:
// ❌ Violates LSP: This violates Liskov Substitution Principle because
// a subclass (Penguin) changes the expected behavior of its parent class (Bird)
class Bird {
public function fly() {
// Logic for flying
}
}
class Penguin extends Bird {
public function fly() {
// This breaks the contract established by the parent class
throw new Exception("Penguins can't fly!");
}
}
// ✅ Refactored: Better design using composition over inheritance
// This approach separates flying capability from bird classification
interface Flyable {
public function fly();
}
// Only birds that can actually fly implement the Flyable interface
class Sparrow implements Flyable {
public function fly() {
// Flying birds implement their specific flying behavior
}
}
// Penguin doesn't implement Flyable, avoiding the forced inheritance problem
class Penguin {
// Penguins have their own behaviors without being forced to implement fly()
}
I: Interface segregation principle (ISP): design clean and focused interfaces
The Interface Segregation Principle helps you write cleaner code by keeping interfaces small and focused. This way, classes only implement methods they actually use.
Let's look at how to split up bloated interfaces into smaller, focused ones.
// ❌ Violates ISP: Interface forces classes to implement methods they don't need
interface Animal {
public function fly(); // Problem: Fish shouldn't need this
public function swim(); // Problem: Birds shouldn't need this
}
class Fish implements Animal {
public function swim() {
// Swimming logic
}
public function fly() {
// Forced to implement irrelevant method
throw new Exception("Fish cannot fly");
}
}
// ✅ Refactored: Following ISP with focused, specific interfaces
// Each class only implements the methods it actually needs
interface Flyable {
public function fly(); // Interface for flying creatures
}
interface Swimmable {
public function swim(); // Interface for swimming creatures
}
class Fish implements Swimmable {
public function swim() {
// Fish only implements what it can actually do
// This follows ISP by not forcing unnecessary methods
}
}
class Bird implements Flyable {
public function fly() {
// Birds only implement flying behavior
// No unused methods are forced upon the class
}
}
D: Dependency inversion principle (DIP): improve code flexibility with abstractions
The Dependency Inversion Principle helps you write better code by using interfaces instead of direct implementations. Your high level modules work with abstractions not specifics. This lets you swap components without breaking your system.
Here's a practical example:
// ❌ Violates DIP: High-level module depends on low-level module
class EmailNotifier {
public function send($message) {
// Logic for sending email
}
}
class Notification {
private $emailNotifier;
public function __construct() {
$this->emailNotifier = new EmailNotifier();
}
public function notify($message) {
$this->emailNotifier->send($message);
}
}
// ✅ Refactored: Depend on abstractions, not concrete implementations
interface Notifier {
public function send($message);
}
class EmailNotifier implements Notifier {
public function send($message) {
// Logic for sending email
}
}
class SMSNotifier implements Notifier {
public function send($message) {
// Logic for sending SMS
}
}
class Notification {
private $notifier;
public function __construct(Notifier $notifier) {
$this->notifier = $notifier;
}
public function notify($message) {
$this->notifier->send($message);
}
}
// This design allows swapping EmailNotifier with SMSNotifier without modifying Notification
SOLID principles are simple tools that work together. When combined correctly, they help you write clean code that's easier to maintain and debug.
Here's a clear breakdown of what each principle does:
Principle | Summary | Key Benefit |
Single Responsibility (SRP) | A class should have only one reason to change | Reduces maintenance complexity |
Open-Closed (OCP) | Software entities should be open for extension but closed for modification | Enables scalable feature addition |
Liskov Substitution (LSP) | Derived classes substitutable for their base classes | Ensures behavioral consistency |
Interface Segregation (ISP) | Clients should not be forced to depend on interfaces they don't use | Minimizes coupling between components |
Dependency Inversion (DIP) | Depend on abstractions, not concrete implementations | Improves system flexibility |
Interface Segregation and Dependency Inversion help build adaptable systems. Interface Segregation lets each microservice expose only essential functions. Dependency Inversion enables component swapping without core code changes.
Dependency Inversion works especially well for plugin systems. Using interfaces instead of hardcoded dependencies makes components interchangeable, enabling easy integration of new tools.
For example, a CMS can switch between SQL, NoSQL, or files for storage. Interface Segregation keeps the connections clean and minimal. Together, these principles create systems that adapt to new requirements.
How SOLID principles make software design maintainable and scalable
Writing maintainable code is challenging. Quick fixes during deadlines create technical debt that's hard to fix later. SOLID principles offer a solution.
These principles help teams build stable, flexible code that's easier to understand and change. The result: fewer bugs and faster development cycles.
Clear code structure enables better teamwork. When each component has a specific purpose, testing and debugging become straightforward.
SOLID supports growth. With the Open-Closed Principle, you can extend functionality without modifying existing code - essential for evolving projects.
As requirements evolve, SOLID principles ensure your code can adapt. Add features or modify functionality without major refactoring.
While shortcuts may seem efficient now they create long-term problems: slower development, more bugs and developer frustration. SOLID principles lead to cleaner, more maintainable code.
Robert C. Martin puts it clearly: "Good architecture makes the system easy to understand, easy to develop, easy to maintain, and easy to deploy. The ultimate goal is to minimize the lifetime cost of the system and to maximize programmer productivity." SOLID principles make this possible.
3. Breaking Down the SOLID Principles (With Examples)
Single Responsibility Principle (SRP)
Each class should do one thing only.
Consider a Report class that both formats data and writes to a database. This creates problems - when you update formatting or switch databases, you risk breaking both features.
The solution? Split it into ReportFormatter and ReportWriter. Each class has one clear purpose. Want to change report formatting? Update ReportFormatter. Need to switch databases? Modify ReportWriter.
This separation also simplifies testing since you can verify formatting and database operations separately.
Here's a code example:
// ❌ Violates SRP: One class handling multiple responsibilities
class Report {
public function formatData($data) {
// Format data as JSON
// This violates SRP by mixing formatting and storage concerns
}
public function writeToDatabase($data) {
// Write data to database
// This creates tight coupling between data formatting and storage
}
}
// ✅ Refactored: Following SRP with clear separation of concerns
class ReportFormatter {
public function formatData($data) {
// Format data as JSON
// This class has a single responsibility: data formatting
}
}
class ReportWriter {
public function writeToDatabase($data) {
// Write data to database
// This class has a single responsibility: data persistence
// Decoupling allows for easier testing and maintenance
}
}
- Key takeaway: SRP makes code simpler by giving each component a clear, single purpose. This reduces maintenance costs and makes changes easier.
Common mistakes to avoid when applying SOLID principles
Even experienced developers can struggle with SOLID principles. Here are common mistakes to avoid:
Interface overload
Don't create too many small interfaces. Group related functions together logically. For example, a permissions system works better with a single UserPermissions interface rather than separate interfaces for reading, writing, and role management.
Misusing helper classes
Avoid dumping multiple tasks into "helper" classes. Break them into focused components instead. Example: Replace a DataProcessorHelper with separate DataValidator, DataFormatter, and Logger classes.
Unnecessary abstractions
Create interfaces only when they add value. A simple StringFormatter class doesn't need an interface - it adds complexity without benefit.
Keep balance
Don't overuse any single principle. Too much separation creates unnecessary complexity. Too little makes code rigid. Let SOLID principles guide your design decisions without constraining them.
Following these guidelines helps create maintainable, scalable code.
Why SOLID principles matter for clean, modular software design
Writing maintainable code is essential. SOLID principles make your code easier to change and scale. Here's how:
Your code needs to adapt as your project grows. With the Open-Closed and Dependency Inversion Principles, you can add features without breaking existing functionality.
For example: Say you're building an e-commerce site that handles tax calculations. Using these principles, you can add new tax rules for different countries without changing your core checkout code.
Let's see this in practice:
// Example demonstrating violation of Open-Closed Principle (OCP)
// ❌ Bad Practice: This implementation requires modifying existing code to add new shapes
class Shape {
// This method uses conditional logic that must be modified for each new shape type
// Breaking OCP as the class must be modified to extend functionality
public function calculateArea($type, $dimensions) {
if ($type === 'circle') {
return pi() * pow($dimensions['radius'], 2);
} elseif ($type === 'square') {
return pow($dimensions['side'], 2);
}
}
}
// Example demonstrating proper implementation following OCP
// ✅ Good Practice: Using polymorphism to allow extension without modification
// Abstract base class defines the interface that all shapes must implement
// This creates a contract that concrete classes must fulfill
abstract class Shape {
// Abstract method forces all child classes to implement their own area calculation
abstract public function calculateArea();
}
// Concrete implementation for Circle
// Each shape is responsible for its own area calculation
class Circle extends Shape {
private $radius;
// Constructor initializes the circle with its radius
public function __construct($radius) {
$this->radius = $radius;
}
// Specific implementation for calculating circle area
// π * r²
public function calculateArea() {
return pi() * pow($this->radius, 2);
}
}
// Concrete implementation for Square
// New shapes can be added by creating new classes without modifying existing code
class Square extends Shape {
private $side;
// Constructor initializes the square with its side length
public function __construct($side) {
$this->side = $side;
}
// Specific implementation for calculating square area
// side * side
public function calculateArea() {
return pow($this->side, 2);
}
}
// To add a new shape, simply create a new class that extends Shape
// This follows OCP as existing code remains unchanged
- Modular by Design - SOLID creates reusable components. A well-structured authentication module works in multiple projects without changes.
- Team Focus - Clear boundaries help teams work independently. New developers start contributing faster with focused, single-purpose components.
- Cloud-Ready Architecture - Small, focused components deploy and scale easily in cloud environments and microservices.
How to use SOLID principles for microservices and cloud-ready systems
Upsun provides the tools you need to write better code, whether you're building monoliths or microservices.
Modular Testing
- Test modules in isolated containers
- Check changes safely in preview environments
Scale Without Friction
- Focus on code while infrastructure scales automatically
- Use Git workflows that support clean, extensible changes
Code Structure
- Profile and break down monolithic code efficiently
Dependencies
- Keep implementations and abstractions synchronized through version control
Putting SOLID Principles to Work
Here's how to apply SOLID principles effectively:
- Break large classes into focused components: Each component should do one specific task. This improves testing and maintenance.
- Make incremental improvements: Refactor your code gradually. Avoid complete rewrites.
- Test continuously: Run tests after each change to catch issues early.
Following these practices leads to more maintainable and adaptable code - essential for growing projects.