SOLID Software Design Principles for Writing Maintainable Code
-
Understand the five SOLID principles for better software design
-
Write clean, maintainable, and scalable code with best practices
-
Improve collaboration and long-term project quality
Last Update: 17 Nov 2024

The SOLID principles were introduced by Robert C. Martin who also known as Uncle Bob and represent a set of five design principles that help software developers to write better, maintainable and extendable object-oriented code.
The SOLID stands for:
S - Single Responsibility Principle (SRP)
O - Open/Closed Principle (OCP)
L - Liskov Substitution Principle (LSP)
I - Interface Segregation Principle (ISP)
D - Dependency Inversion Principle (DIP)
Each principle addresses a specific concern in software design, and when applied correctly, they collectively guide us towards creating a systems that are easier to maintain, extend, and test.
Single Responsibility Principle (SRP)
The Single Responsibility Principle states that a class should have only one purpose to serve which means we can't define a class that do multiple jobs and when it needs to be update or change there will be one only reason behind this.
If a class serves multiple jobs then it becomes more dificult to maintian and if we change one aspect of that class, it may effect or break other functionality.
Lets see the bellow code:
class UserManager {
private $dbConnection;
public function __construct($dbConnection) {
$this->dbConnection = $dbConnection;
}
// Handles user creation and saving user to the database
public function createUser($name, $email) {
// Business logic for creating a user
if (empty($name) || empty($email)) {
throw new Exception("Name and email are required");
}
// Saving user data to the database
$query = "INSERT INTO users (name, email) VALUES ('$name', '$email')";
if ($this->dbConnection->query($query) === TRUE) {
echo "New user created successfully";
} else {
echo "Error: " . $this->dbConnection->error;
}
}
// Fetching user data
public function getUser($id) {
$query = "SELECT * FROM users WHERE id = '$id'";
$result = $this->dbConnection->query($query);
return $result->fetch_assoc();
}
}
The code above doesn't comply with the Single Responsibility Principle (SRP) because it serves multiple purposes. The UserManager class is responsible for both handling user data logic and database connection, as well as fetching data from it.
In order to modify user data logic, we must also modify database-related code. This may have an effect on other code that is inheriting from this codebase. It could become more complicated than it should be.
Let's refactor this code with Single Responsibility Principle
// UserValidator: Responsible for validating user data
class UserValidator {
public function validate($name, $email) {
if (empty($name) || empty($email)) {
throw new Exception("Name and email are required");
}
}
}
// UserRepository: Responsible for interacting with the database
class UserRepository {
private $dbConnection;
public function __construct($dbConnection) {
$this->dbConnection = $dbConnection;
}
// Saves the user to the database
public function save($name, $email) {
$query = "INSERT INTO users (name, email) VALUES ('$name', '$email')";
if ($this->dbConnection->query($query) !== TRUE) {
throw new Exception("Error: " . $this->dbConnection->error);
}
}
// Retrieves user data from the database
public function getById($id) {
$query = "SELECT * FROM users WHERE id = '$id'";
$result = $this->dbConnection->query($query);
return $result->fetch_assoc();
}
}
// UserManager: Responsible for user-related business logic (no persistence here)
class UserManager {
private $validator;
private $repository;
public function __construct(UserValidator $validator, UserRepository $repository) {
$this->validator = $validator;
$this->repository = $repository;
}
// Handles user creation
public function createUser($name, $email) {
// Validate user data
$this->validator->validate($name, $email);
// Save user to the database
$this->repository->save($name, $email);
echo "New user created successfully";
}
// Fetching user data
public function getUser($id) {
return $this->repository->getById($id);
}
}
What makes the refactored code comply with SRP standards.
What Makes it SRP standards:
- UserValidator's responsibility is limited to validating user data (business logic).
- Database interactions (persistence logic) are the sole responsibility of UserRepository.
- The UserManager handles user-related business logic without having to deal with validation or persistence.
What is the benigits of having Single Responsibility:
- It is easier to maintain and extend when each class has only one responsibility.
- The UserValidator class will be the only one affected by any changes to the validation logic.
- The UserRepository class is the only thing that needs to be modified if the database interaction needs to be changed (e.g., switching to a different database or changing the schema).
Easy To Test Every Functionality:
Unit testing becomes easier because every class has a single responsibility. The validation logic, repository methods, and user management logic can be tested separately.
Flexibility & Maintainable:
The UserRepository or UserValidator can be swapped out with different implementations without any impact on the other parts of the code. As an example, you have the option to substitute the UserRepository with one that utilizes an API instead of a database.
Open/Closed Principle (OCP)
The Open/Closed Principle (OCP) allows a class to be extended without changing its source code. This principle encourages developers to design a class in such a way that, we can extend its behavior without changing the class itself, which allow us to maintain and reduce the risk of introducing new bugs in existing codes.
This principle can be achieved using inheritance and polymorphism in a straightforward manner. So that new features can be added via subclasses rather than modifying the existing codebase.
Why do we need the Open/Closed Principle (OCP)?
Here are some key points that we can achieve using OCP:
- It creates a scope to extend class behiviour without modifing existing codes
- New functionality can be added without the risk of breaking existing functionality as the system grows.
- Extending existing classes instead of rewriting or modifying the core logic makes it easier for the system to adapt to new requirements.
Example of Open/Closed Principle (OCP)
Let's consider that we are constructing a payment processing system. Initially, we offer two payment options: CreditCardPayment and PayPalPayment. Our aim is to create a system that can easily accommodate new payment methods without having to modify the current code.
Let's write some codes:
class PaymentProcessor {
public function processPayment($paymentMethod) {
if ($paymentMethod instanceof CreditCardPayment) {
$this->processCreditCard($paymentMethod);
} elseif ($paymentMethod instanceof PayPalPayment) {
$this->processPayPal($paymentMethod);
}
}
private function processCreditCard(CreditCardPayment $payment) {
// Credit Card payment processing logic
echo "Processing Credit Card payment of amount: " . $payment->getAmount() . "\n";
}
private function processPayPal(PayPalPayment $payment) {
// PayPal payment processing logic
echo "Processing PayPal payment of amount: " . $payment->getAmount() . "\n";
}
}
class CreditCardPayment {
private $amount;
public function __construct($amount) {
$this->amount = $amount;
}
public function getAmount() {
return $this->amount;
}
}
class PayPalPayment {
private $amount;
public function __construct($amount) {
$this->amount = $amount;
}
public function getAmount() {
return $this->amount;
}
}
What's wrong in this code above?
- The above codes will work absolutely fine until we intend to add new payment methods like Stripe payment gateway or bank transfer. Because it requires to modify the PaymentProcessor class and add an additional condition to processPayment.
- Adding more payment methods to our system could result in a more complex maintenance process.
Let's Refactor the above code:
// Step 1: Define a PaymentMethod interface that all payment methods must implement.
interface PaymentMethod {
public function process();
public function getAmount();
}
// Step 2: Implement concrete payment methods.
class CreditCardPayment implements PaymentMethod {
private $amount;
public function __construct($amount) {
$this->amount = $amount;
}
public function process() {
echo "Processing Credit Card payment of amount: " . $this->getAmount() . "\n";
}
public function getAmount() {
return $this->amount;
}
}
class PayPalPayment implements PaymentMethod {
private $amount;
public function __construct($amount) {
$this->amount = $amount;
}
public function process() {
echo "Processing PayPal payment of amount: " . $this->getAmount() . "\n";
}
public function getAmount() {
return $this->amount;
}
}
// Step 3: Create new payment types without modifying existing classes.
class BankTransferPayment implements PaymentMethod {
private $amount;
public function __construct($amount) {
$this->amount = $amount;
}
public function process() {
echo "Processing Bank Transfer payment of amount: " . $this->getAmount() . "\n";
}
public function getAmount() {
return $this->amount;
}
}
// Step 4: Refactor PaymentProcessor class to handle any type of payment.
class PaymentProcessor {
public function processPayment(PaymentMethod $paymentMethod) {
// No need to modify the code to add new payment types.
$paymentMethod->process();
}
}
Key Changes:
-
PaymentMethod Interface: We've created an interface
PaymentMethod
that defines two methods:
-
process()
: Each payment type must implement how it processes the payment.getAmount()
: Each payment type must return the amount to be processed.
-
Concrete Payment Classes: Each concrete payment method, such as
CreditCardPayment
,PayPalPayment
, andBankTransferPayment
, now implements thePaymentMethod
interface and defines its ownprocess
andgetAmount
methods. -
PaymentProcessor Class: The
PaymentProcessor
no longer needs to know about specific payment methods. It can accept anyPaymentMethod
and delegate the processing to the payment class itself.
Adding a New Payment Method (No Modification Needed)
Let's add new payment method called Stripe Payment Gateway:
class StripePaymentGateway implements PaymentMethod {
private $amount;
public function __construct($amount) {
$this->amount = $amount;
}
public function process() {
echo "Processing StripePaymentGateway payment of amount: " . $this->getAmount() . "\n";
}
public function getAmount() {
return $this->amount;
}
}
We were able to add a new payment method without having to modify PaymentProcessor or existing classes by creating a new class called 'StripePaymentGateway' that implements the PaymentMethod interface. We have made well-maintained codes with this simple improvement, which enables us to add more features, such as payment methods.
How the Refactored Code Meets Open/Closed Principle (OCP)
-
Open for Extension: We can add new payment methods (e.g., GooglePayment, ApplePayPayment, etc.) without modifying the existing code in
PaymentProcessor
. Each new payment method simply needs to implement thePaymentMethod
interface and provide the appropriateprocess()
andgetAmount()
methods. -
Closed for Modification: The
PaymentProcessor
class is closed for modification. We don't need to change it to add new payment methods, making it more stable and less error-prone as the system evolves. -
Code Scalability: The system is now scalable, and adding new functionality (i.e., payment methods) is as simple as adding a new class that implements the
PaymentMethod
interface, ensuring that we don’t have to change the core business logic.
Liskov Substitution Principle (LSP)
The Liskov Substitution Principle (LSP) is a principle that states that objects in a superclass must be replaceable with those in its subclass without breaking the system. Which means that the object of a subclass should work in the same way as the object of its superclass.
Let's say A is a supper class and B is a subclass of A, So we should be able use an object of class B wherever we would use an object of class A without breaking the system or it's behaviour.
Example of Liskov Substitution Principle (LSP)
Let's asume, we have notification system and this system uses multiple channels like email, SMS, and push notifications to send notifications.
// Base class for notifications
class Notification {
public function send() {
echo "Sending generic notification\n";
}
}
// EmailNotification class
class EmailNotification extends Notification {
public function send() {
echo "Sending email notification\n";
}
public function setEmailAddress($email) {
echo "Setting email address to: $email\n";
}
}
// SMSNotification class
class SMSNotification extends Notification {
public function send() {
echo "Sending SMS notification\n";
}
public function setPhoneNumber($phone) {
echo "Setting phone number to: $phone\n";
}
}
// Function to send any notification
function sendNotification(Notification $notification) {
$notification->send();
}
function configureEmailNotification(EmailNotification $emailNotification) {
$emailNotification->setEmailAddress("user@example.com");
}
$notification = new Notification();
$sendEmail = new EmailNotification();
$sendSMS = new SMSNotification();
// Testing notifications
sendNotification($notification); // Sending generic notification
sendNotification($sendEmail); // Sending email notification
sendNotification($sendSMS); // Sending SMS notification
// The following line will throw an error since sendNotification expects Notification and configureEmailNotification specifically needs EmailNotification
configureEmailNotification($sendEmail); // Setting email address to: user@example.com
Here the base class Notification and a few subclasses (EmailNotification, SMSNotification) are present in this case, but one subclass is not following the expected behavior.
What's wrong in this code above?
Substitution Issue:
- The function
sendNotification()
is expecting aNotification
object. It works fine forEmailNotification
andSMSNotification
because both classes inherit fromNotification
. However,configureEmailNotification()
only works withEmailNotification
, not withSMSNotification
or the baseNotification
. - This violates the Liskov Substitution Principle (LSP) because, ideally, a
Notification
object should be able to handle both email and SMS notifications in the same way. However, the SMS notification doesn't have the methodsetEmailAddress()
, making it impossible to substitute the two classes without introducing issues.
Breaking Expectations:
- The
sendNotification()
method should be able to work with any subclass ofNotification
without expecting specific behavior (like methods for setting email or phone numbers).
Let's have it refactored:
// Base class for notifications
abstract class Notification {
abstract public function send();
}
// Interface for notifications with specific settings (email or phone)
interface NotificationWithSettings {
public function setSetting($value);
}
// EmailNotification class
class EmailNotification extends Notification implements NotificationWithSettings {
private $email;
public function send() {
echo "Sending email notification\n";
}
public function setSetting($email) {
$this->email = $email;
echo "Setting email address to: $email\n";
}
}
// SMSNotification class
class SMSNotification extends Notification implements NotificationWithSettings {
private $phone;
public function send() {
echo "Sending SMS notification\n";
}
public function setSetting($phone) {
$this->phone = $phone;
echo "Setting phone number to: $phone\n";
}
}
// PushNotification class (new notification type)
class PushNotification extends Notification {
public function send() {
echo "Sending push notification\n";
}
}
// Function to send any notification
function sendNotification(Notification $notification) {
$notification->send();
}
// Function to configure any notification with specific settings
function configureNotification(NotificationWithSettings $notification) {
$notification->setSetting("user@example.com");
}
$notification = new Notification();
$sendEmail = new EmailNotification();
$sendSMS = new SMSNotification();
$sendPush = new PushNotification();
// Testing notifications
sendNotification($sendEmail); // Sending email notification
sendNotification($sendSMS); // Sending SMS notification
sendNotification($sendPush); // Sending push notification
// Now both EmailNotification and SMSNotification can be configured
configureNotification($sendEmail); // Setting email address to: user@example.com
configureNotification($sendSMS); // Setting phone number to: user@example.com
Key Changes:
- NotificationWithSettings Interface: Defines a common method
setSetting()
, which bothEmailNotification
andSMSNotification
implement. This enables generic handling of settings, making it possible to configure both types in the same way. - Generalized
configureNotification()
Function: TheconfigureNotification()
function now works with any object implementing theNotificationWithSettings
interface, rather than being specific to a subclass likeEmailNotification
. This makes the system more flexible and adheres to LSP.
How the Refactored Code Meets Liskov Substitution Principle (LSP)
Polymorphism and Interfaces:
- By introducing the
NotificationWithSettings
interface, we can define specific behavior (like setting an email address or a phone number) in a way that is polymorphic. BothEmailNotification
andSMSNotification
implement this interface, but thePushNotification
does not need to (since it doesn't need any specific setting). - This means that
sendNotification()
can still handle any type ofNotification
without issues, and the newconfigureNotification()
function works with anyNotificationWithSettings
object, allowing theEmailNotification
andSMSNotification
to both work correctly.
Adherence to LSP:
- Now, each subclass of
Notification
can be substituted forNotification
without breaking the system. Whether it's anEmailNotification
,SMSNotification
, orPushNotification
, the code expects the same behavior for sending notifications, and only those that require settings will implement theNotificationWithSettings
interface. - The system behaves correctly when substituting subclasses of
Notification
in different contexts.
Extensibility:
- If we wanted to add a new type of notification (e.g., a
TelegramNotification
), we could implement thesend()
method and, if needed, theNotificationWithSettings
interface to handle its settings, all while keeping the system flexible and compliant with LSP.
Interface Segregation Principle (ISP)
Interface Segregation Principle (ISP) suggest that an interface should have only the methods that are relevant to the implementing class. So that only necessary methods can be implemented and unwanted implementation or empty implementation can be avoided.
Why do we need Interface Segregation Principle (ISP)
Here are the key points of why Interface Segregation Principle (ISP) is important:
- Avoid Forced Dependencies: Prevents classes from implementing methods they don’t need.
- Increases Maintainability: Changes to one method in an interface don’t affect unrelated classes.
- Improves Flexibility and Extensibility: Classes can implement only the relevant interfaces, making it easy to add new functionality.
- Promotes Code Reusability: Smaller, focused interfaces are more reusable across different contexts.
- Reduces Code Complexity: Smaller, focused interfaces lead to simpler, more maintainable code.
- Facilitates Better Testing: Testing is easier when classes only implement necessary methods.
- Supports Clearer Intentions: Makes the purpose of a class clearer based on the interfaces it implements.
Example of Interface Segregation Principle (ISP)
Let's write a program to create a multi-functional printing machin which allow us to print, scan and fax.
<?php
// Interface that violates ISP (too broad)
interface Machine {
public function print();
public function scan();
public function fax();
}
// MultiFunctionPrinter implements all methods
class MultiFunctionPrinter implements Machine {
public function print() {
echo "Printing document\n";
}
public function scan() {
echo "Scanning document\n";
}
public function fax() {
echo "Sending fax\n";
}
}
// SimplePrinter implements all methods, but it doesn't need scan and fax
class SimplePrinter implements Machine {
public function print() {
echo "Printing document\n";
}
// This method is forced upon the class, even though it's unnecessary
public function scan() {
throw new Exception("SimplePrinter cannot scan");
}
// This method is forced upon the class, even though it's unnecessary
public function fax() {
throw new Exception("SimplePrinter cannot fax");
}
}
$printer = new SimplePrinter();
$printer->print(); // Works fine
$printer->scan(); // Will throw an exception
?>
What is the problem in this program?
SimplePrinter
class is forced to implementscan()
andfax()
, even though these methods are irrelevant to it.- This violates the Interface Segregation Principle because
SimplePrinter
doesn't need to implement methods it won't use.
Let's refactor this program to solve this problem.
<?php
// Specific interfaces for each functionality
interface Printer {
public function print();
}
interface Scanner {
public function scan();
}
interface Faxer {
public function fax();
}
// MultiFunctionPrinter implements all three interfaces
class MultiFunctionPrinter implements Printer, Scanner, Faxer {
public function print() {
echo "Printing document\n";
}
public function scan() {
echo "Scanning document\n";
}
public function fax() {
echo "Sending fax\n";
}
}
// SimplePrinter only implements the Printer interface
class SimplePrinter implements Printer {
public function print() {
echo "Printing document\n";
}
}
// Example usage
$multiFunctionPrinter = new MultiFunctionPrinter();
$multiFunctionPrinter->print(); // Printing document
$multiFunctionPrinter->scan(); // Scanning document
$multiFunctionPrinter->fax(); // Sending fax
$simplePrinter = new SimplePrinter();
$simplePrinter->print(); // Printing document
// $simplePrinter->scan(); // Not implemented, so it won't exist
// $simplePrinter->fax(); // Not implemented, so it won't exist
?>
How this refactored code meets Interface Segregation Principle (ISP)
Avoided Unwanted Implementation:
- Smaller Interfaces: The single large interface
Machine
is split into three smaller, focused interfaces:Printer
,Scanner
, andFaxer
. - SimplePrinter now only implements
Printer
, which is all it needs. It doesn't need to implementscan()
orfax()
methods, and therefore we avoid unnecessary code or exceptions. - MultiFunctionPrinter implements all three interfaces because it is a multifunction printer and needs the ability to print, scan, and fax.
Benefits:
- Single Responsibility: Each class only implements the functionality it needs.
- Flexibility: New classes can implement only the interfaces they need without being forced to implement unnecessary methods.
- Cleaner Code: We avoid throwing exceptions or implementing methods that do nothing (e.g.,
scan()
andfax()
inSimplePrinter
).
Dependency Inversion Principle (DIP)
Dependency Inversion Principle (DIP) advises that dependencies between classes should be injected through abstractions (such as interfaces or abstract classes) instead of directly through concrete implementations. This leads to systems being more flexible, testable, and decoupled.
Why is Dependency Inversion Principle (DIP) so important?
- Decouples high-level and low-level modules: It is not recommended for high-level modules (which define core business logic) to depend on low-level modules (which provide implementation details). Rather than relying on abstractions, both should depend on them.
- Promotes flexibility and extensibility: Using abstractions to inject dependencies simplifies the process of changing, extending, or replacing low-level module implementation without affecting high-level modules.
- Improves maintainability: Makes the system more modular and easier to maintain over time.
- Facilitates testing: It is easier to substitute dependencies with mock objects or stubs during unit testing.
Example of Dependency Inversion Principle (DIP)
Let's write a program to process a payment:
<?php
// Low-level module
class PaymentProcessor {
public function processPayment($amount) {
echo "Processing payment of $amount\n";
}
}
// High-level module
class OrderService {
private $paymentProcessor;
public function __construct() {
// Direct dependency on PaymentProcessor
$this->paymentProcessor = new PaymentProcessor();
}
public function placeOrder($amount) {
// High-level module depending on a low-level module
echo "Placing order...\n";
$this->paymentProcessor->processPayment($amount);
}
}
// Usage
$orderService = new OrderService();
$orderService->placeOrder(100);
?>
The OrderService (high-level module) is directly dependent on a PaymentProcessor (low-level module) in this example. Modifying the OrderService every time would be necessary if we wanted to switch to a different payment processor (e.g., PayPal or Stripe).
Problem we may face in the future:
- Tight coupling:
OrderService
is tightly coupled toPaymentProcessor
, meaning it can't easily switch to a different payment processing implementation without modification. - Hard to extend: If we want to add another payment processor (e.g., Stripe), we'd have to modify the
OrderService
class.
Let's have it refactored:
<?php
// Abstraction: PaymentGateway interface
interface PaymentGateway {
public function processPayment($amount);
}
// Low-level module: PayPalPaymentProcessor implements the PaymentGateway interface
class PayPalPaymentProcessor implements PaymentGateway {
public function processPayment($amount) {
echo "Processing PayPal payment of $amount\n";
}
}
// Low-level module: StripePaymentProcessor implements the PaymentGateway interface
class StripePaymentProcessor implements PaymentGateway {
public function processPayment($amount) {
echo "Processing Stripe payment of $amount\n";
}
}
// High-level module: OrderService depends on PaymentGateway abstraction
class OrderService {
private $paymentGateway;
// Dependency injection via constructor
public function __construct(PaymentGateway $paymentGateway) {
$this->paymentGateway = $paymentGateway;
}
public function placeOrder($amount) {
echo "Placing order...\n";
$this->paymentGateway->processPayment($amount);
}
}
// Usage
$paypalProcessor = new PayPalPaymentProcessor();
$orderService = new OrderService($paypalProcessor); // Inject PayPalPaymentProcessor
$orderService->placeOrder(100);
echo "\n";
$stripeProcessor = new StripePaymentProcessor();
$orderService = new OrderService($stripeProcessor); // Inject StripePaymentProcessor
$orderService->placeOrder(200);
?>
How this refactored code meets Dependency Inversion Principle (DIP)?
- Abstraction (PaymentGateway):
PaymentGateway
is an interface that defines the contract for any payment processor (e.g., PayPal, Stripe). - Low-level modules (PayPal, Stripe): Concrete implementations of
PaymentGateway
, providing specific behavior for processing payments. - High-level module (OrderService): Now depends on the abstraction (
PaymentGateway
) and is not tightly coupled to any specific implementation of the payment processor. - Dependency Injection: The
PaymentGateway
is injected intoOrderService
via the constructor. This allows for easy swapping of payment processors without modifyingOrderService
.
Benefits of DIP:
- Loose Coupling: High-level modules don't depend on low-level modules, but both depend on abstractions. This makes the system more modular.
- Flexibility and Extensibility: You can easily introduce new low-level modules (e.g., different payment processors) without modifying the high-level module (
OrderService
). - Easier Maintenance: When changes are needed (e.g., a new payment processor), you only modify or add the low-level module without touching the high-level business logic.
- Testability: The high-level module (
OrderService
) can be tested independently by injecting mock implementations of thePaymentGateway
interface during unit tests.
Frequently Asked Questions
Trendingblogs
Get the best of our content straight to your inbox!
By submitting, you agree to our privacy policy.