SOLID Principles

The SOLID principles are a set of design principles aimed at making software more understandable, flexible, and maintainable. These principles were introduced by Robert C. Martin and are often applied in object-oriented programming. Here’s an explanation of each principle in the context of PHP. The SOLID acronym stands for:

  • Single Responsibility Principle (SRP)
  • Open/Closed Principle (OCP)
  • Liskov Substitution Principle (LSP)
  • Interface Segregation Principle (ISP)
  • Dependency Inversion Principle (DIP)

we’ll have a brief explanation of each principle along with at least 2 examples each and will also explain bad and good approaches so that it makes more clear to understand to avoid bad practices. To explain these principles I am using PHP languages but you can relate it to any object oriented programming language in which you prefer to work on. Let’s deep dive to understand one by one all principles.

1. Single Responsibility Principle (SRP):

This principle states that a class should have only one reason to change, meaning it should have a single responsibility. In other words, a class should have only one job or task.

Example 1: User Management

Bad Example (Violation of SRP):

class User {
    public function create($data) {
        // Create user in the database
    }

    public function sendWelcomeEmail($email) {
        // Send welcome email to the user
    }

    public function logActivity($activity) {
        // Log user activity
    }
}

In this example, the User class has multiple responsibilities: creating a user, sending a welcome email, and logging user activity. This violates the SRP because each of these actions could change for different reasons.

Good Example (Adhering to SRP):

class UserCreator {
    public function create($data) {
        // Create user in the database
    }
}

class EmailSender {
    public function sendWelcomeEmail($email) {
        // Send welcome email to the user
    }
}

class ActivityLogger {
    public function logActivity($activity) {
        // Log user activity
    }
}

Here, each class has a single responsibility: UserCreator handles user creation, EmailSender handles sending emails, and ActivityLogger handles logging activity. Each class can be modified independently.

Example 2: Order Processing

Bad Example (Violation of SRP):

class Order {
    public function placeOrder($orderDetails) {
        // Process the order
    }

    public function calculateTotal($items) {
        // Calculate the total price of the items
    }

    public function sendConfirmationEmail($email) {
        // Send order confirmation email
    }
}

This Order class handles order processing, calculating totals, and sending confirmation emails, violating SRP.

Good Example (Adhering to SRP):

class OrderProcessor {
    public function placeOrder($orderDetails) {
        // Process the order
    }
}

class TotalCalculator {
    public function calculateTotal($items) {
        // Calculate the total price of the items
    }
}

class OrderEmailSender {
    public function sendConfirmationEmail($email) {
        // Send order confirmation email
    }
}

In this improved example, the responsibilities are divided into three separate classes. Each class has a single responsibility, making the system more modular and easier to maintain.

2. Open/Closed Principle (OCP)

The Open/Closed Principle (OCP) states that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. This means that you should be able to add new functionality to a class without changing its existing code. Here are some detailed examples to illustrate this principle:

Example 1: Payment Processing

Bad Example (Violation of OCP):

class PaymentProcessor {
    public function processPayment($paymentMethod, $amount) {
        if ($paymentMethod == 'credit_card') {
            // Process credit card payment
        } elseif ($paymentMethod == 'paypal') {
            // Process PayPal payment
        } else {
            throw new Exception('Unsupported payment method');
        }
    }
}

In this example, every time a new payment method needs to be supported, the PaymentProcessor class must be modified.

Good Example (Adhering to OCP):

interface PaymentMethod {
    public function pay($amount);
}

class CreditCardPayment implements PaymentMethod {
    public function pay($amount) {
        // Process credit card payment
    }
}

class PayPalPayment implements PaymentMethod {
    public function pay($amount) {
        // Process PayPal payment
    }
}

class PaymentProcessor {
    private $paymentMethod;

    public function __construct(PaymentMethod $paymentMethod) {
        $this->paymentMethod = $paymentMethod;
    }

    public function processPayment($amount) {
        $this->paymentMethod->pay($amount);
    }
}

With this design, adding a new payment method involves creating a new class that implements the PaymentMethod interface without modifying the PaymentProcessor class.

Example 2: Report Generation

Bad Example (Violation of OCP):

class ReportGenerator {
    public function generateReport($type, $data) {
        if ($type == 'pdf') {
            // Generate PDF report
        } elseif ($type == 'html') {
            // Generate HTML report
        } else {
            throw new Exception('Unsupported report type');
        }
    }
}

This implementation requires modifying the ReportGenerator class each time a new report type is added.

Good Example (Adhering to OCP):

interface ReportFormatter {
    public function format($data);
}

class PDFReportFormatter implements ReportFormatter {
    public function format($data) {
        // Format data as PDF
    }
}

class HTMLReportFormatter implements ReportFormatter {
    public function format($data) {
        // Format data as HTML
    }
}

class ReportGenerator {
    private $formatter;

    public function __construct(ReportFormatter $formatter) {
        $this->formatter = $formatter;
    }

    public function generateReport($data) {
        return $this->formatter->format($data);
    }
}

In this design, adding a new report type only requires creating a new class that implements the ReportFormatter interface, leaving the ReportGenerator class unchanged.

3. Liskov Substitution Principle (LSP)

The Liskov Substitution Principle (LSP) is one of the SOLID principles that states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. In other words, if class B is a subclass of class A, then objects of class A should be able to be replaced with objects of class B without altering the desirable properties of the program.

Key Points of LSP

  1. Subtypes must be substitutable for their base types: Derived classes must be usable through the base class interface without the need for the user to know the difference.
  2. Behavioral consistency: Subtypes must adhere to the behavior expected of the base type. If a subclass introduces unexpected behavior, it violates LSP.

Let’s look at some examples to illustrate this principle:

Example 1: Shape and Area Calculation

Bad Example (Violation of LSP):

class Rectangle {
    protected $width;
    protected $height;

    public function setWidth($width) {
        $this->width = $width;
    }

    public function setHeight($height) {
        $this->height = $height;
    }

    public function getArea() {
        return $this->width * $this->height;
    }
}

class Square extends Rectangle {
    public function setWidth($width) {
        $this->width = $width;
        $this->height = $width; // Ensures both width and height are the same
    }

    public function setHeight($height) {
        $this->height = $height;
        $this->width = $height; // Ensures both width and height are the same
    }
}

function calculateRectangleArea(Rectangle $rectangle) {
    $rectangle->setWidth(5);
    $rectangle->setHeight(10);
    return $rectangle->getArea(); // Expected 50 for Rectangle, but fails for Square
}

$rect = new Rectangle();
echo calculateRectangleArea($rect); // Outputs 50

$square = new Square();
echo calculateRectangleArea($square); // Outputs 100, which is incorrect for a rectangle context

Why This Violates LSP

The violation occurs because:

  1. Behavioral Change: The Square class changes the behavior of the methods setWidth and setHeight such that they do not adhere to the expectations set by the Rectangle class.
  2. Substitutability: According to LSP, objects of Square should be substitutable for Rectangle without altering the correctness of the program. In this case, substituting Rectangle with Square in the calculateRectangleArea function alters the expected behavior.

Correct Approach (Adhering to LSP)

To adhere to LSP, we should not use inheritance inappropriately. Instead, we can use interfaces or abstract classes to ensure correct behavior.

Refactored Example:

interface Shape {
    public function getArea();
}

class Rectangle implements Shape {
    protected $width;
    protected $height;

    public function __construct($width, $height) {
        $this->width = $width;
        $this->height = $height;
    }

    public function getArea() {
        return $this->width * $this->height;
    }
}

class Square implements Shape {
    protected $side;

    public function __construct($side) {
        $this->side = $side;
    }

    public function getArea() {
        return $this->side * $this->side;
    }
}

function calculateArea(Shape $shape) {
    return $shape->getArea();
}

$rect = new Rectangle(5, 10);
echo calculateArea($rect); // Outputs 50

$square = new Square(5);
echo calculateArea($square); // Outputs 25

In this refactored example:

  • Rectangle and Square both implement the Shape interface.
  • Each class has its own implementation of the getArea method.
  • The calculateArea function works correctly with both Rectangle and Square without altering behavior or expectations.

This design adheres to LSP by ensuring that Square and Rectangle can be used interchangeably without unexpected behavior changes.

Example 2: Bird Example

Bad Example (Violation of LSP):

class Bird {
    public function fly() {
        // Fly logic
    }
}

class Duck extends Bird {
    public function fly() {
        // Duck flying logic
    }
}

class Penguin extends Bird {
    public function fly() {
        throw new Exception("Penguins can't fly!");
    }
}

function letBirdFly(Bird $bird) {
    $bird->fly();
}

$duck = new Duck();
letBirdFly($duck); // Works fine

$penguin = new Penguin();
letBirdFly($penguin); // Throws exception, violating LSP

The Penguin class violates LSP because it changes the expected behavior of the fly method from the Bird class.

Good Example (Adhering to LSP):

abstract class Bird {
    abstract public function move();
}

class FlyingBird extends Bird {
    public function move() {
        $this->fly();
    }

    protected function fly() {
        // Fly logic
    }
}

class NonFlyingBird extends Bird {
    public function move() {
        $this->walk();
    }

    protected function walk() {
        // Walk logic
    }
}

class Duck extends FlyingBird {
    protected function fly() {
        // Duck flying logic
    }
}

class Penguin extends NonFlyingBird {
    protected function walk() {
        // Penguin walking logic
    }
}

function letBirdMove(Bird $bird) {
    $bird->move();
}

$duck = new Duck();
letBirdMove($duck); // Works fine

$penguin = new Penguin();
letBirdMove($penguin); // Works fine, no exception

In this example, Bird is split into FlyingBird and NonFlyingBird classes, ensuring that subclasses adhere to expected behaviors. letBirdMove works correctly with both Duck and Penguin, adhering to LSP.

Summary

The Liskov Substitution Principle ensures that derived classes extend the base class without altering its behavior. This is achieved by:

  1. Ensuring that subclasses adhere to the contracts defined by their base classes.
  2. Designing class hierarchies where subclasses can be used interchangeably with their base classes.
  3. Avoiding changes in the expected behavior of methods when overriding them in subclasses.

By following LSP, you can create more reliable and maintainable code, allowing subclasses to be used seamlessly in place of their parent classes without causing unexpected behavior or errors.

4. Interface Segregation Principle (ISP)

The Interface Segregation Principle (ISP) is one of the SOLID principles that states that no client should be forced to depend on methods it does not use. In other words, it’s better to have many small, specific interfaces than a single, large, general-purpose interface. This helps to keep the system flexible and easier to maintain.

Key Points of ISP

  1. Specific Interfaces: Create interfaces that are specific to a particular client’s needs, rather than a general-purpose interface that tries to cover all possible use cases.
  2. Avoid Fat Interfaces: A “fat” interface is one that has too many methods. Clients that implement such interfaces are forced to provide implementations for methods they don’t need.
  3. Client-Oriented Interfaces: Design interfaces with the client’s specific requirements in mind, ensuring that each client interacts only with the methods it needs.

Example 1: Printer Interface

Bad Example (Violation of ISP):

interface Printer {
    public function printDocument($document);
    public function scanDocument($document);
    public function faxDocument($document);
}

class AllInOnePrinter implements Printer {
    public function printDocument($document) {
        // Print document
    }

    public function scanDocument($document) {
        // Scan document
    }

    public function faxDocument($document) {
        // Fax document
    }
}

class SimplePrinter implements Printer {
    public function printDocument($document) {
        // Print document
    }

    public function scanDocument($document) {
        // This printer doesn't support scanning
        throw new Exception("Not supported");
    }

    public function faxDocument($document) {
        // This printer doesn't support faxing
        throw new Exception("Not supported");
    }
}

In this example, SimplePrinter is forced to implement methods for scanning and faxing, even though it does not support these functionalities. This violates ISP.

Good Example (Adhering to ISP):

interface Printer {
    public function printDocument($document);
}

interface Scanner {
    public function scanDocument($document);
}

interface Fax {
    public function faxDocument($document);
}

class AllInOnePrinter implements Printer, Scanner, Fax {
    public function printDocument($document) {
        // Print document
    }

    public function scanDocument($document) {
        // Scan document
    }

    public function faxDocument($document) {
        // Fax document
    }
}

class SimplePrinter implements Printer {
    public function printDocument($document) {
        // Print document
    }
}

Here, Printer, Scanner, and Fax interfaces are separated, so SimplePrinter only implements the Printer interface, adhering to ISP.

Example 2: Vehicle Interface

Bad Example (Violation of ISP):

interface Vehicle {
    public function drive();
    public function fly();
}

class Car implements Vehicle {
    public function drive() {
        // Car driving logic
    }

    public function fly() {
        // Cars don't fly
        throw new Exception("Cars can't fly");
    }
}

class Airplane implements Vehicle {
    public function drive() {
        // Airplane driving logic (taxiing)
    }

    public function fly() {
        // Airplane flying logic
    }
}

In this example, Car is forced to implement the fly method, even though it doesn’t need it, violating ISP.

Good Example (Adhering to ISP):

interface Drivable {
    public function drive();
}

interface Flyable {
    public function fly();
}

class Car implements Drivable {
    public function drive() {
        // Car driving logic
    }
}

class Airplane implements Drivable, Flyable {
    public function drive() {
        // Airplane driving logic (taxiing)
    }

    public function fly() {
        // Airplane flying logic
    }
}

Here, Drivable and Flyable interfaces are separated, so Car only implements the Drivable interface, adhering to ISP.

Summary

The Interface Segregation Principle helps to create more modular and manageable code by ensuring that clients are only exposed to the methods they need. By splitting large, general-purpose interfaces into smaller, specific ones, you can ensure that classes only implement what they actually use, leading to better code organization and fewer chances of errors. This also makes it easier to extend and maintain the code over time.

5. Dependency Inversion Principle (DIP)

The Dependency Inversion Principle (DIP) is the fifth of the SOLID principles and it states:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
  2. Abstractions should not depend on details. Details should depend on abstractions.

This principle aims to decouple software modules, making the design more flexible and resilient to change. By relying on abstractions (such as interfaces or abstract classes) rather than concrete implementations, you can achieve a more modular and maintainable codebase.

Key Points of DIP

  1. Depend on Abstractions: High-level and low-level modules should depend on abstractions (interfaces or abstract classes) rather than concrete classes.
  2. Inversion of Control: The control over the flow of the program is inverted by using dependency injection, which injects dependencies from the outside rather than creating them inside the modules.

Example 1: Notification System

Bad Example (Violation of DIP):

class EmailService {
    public function sendEmail($message) {
        // Send email
    }
}

class Notification {
    private $emailService;

    public function __construct() {
        $this->emailService = new EmailService();
    }

    public function send($message) {
        $this->emailService->sendEmail($message);
    }
}

In this example, the Notification class depends directly on the EmailService class, which is a low-level module. This violates DIP because the high-level module (Notification) depends on a low-level module (EmailService).

Good Example (Adhering to DIP):

interface MessageService {
    public function sendMessage($message);
}

class EmailService implements MessageService {
    public function sendMessage($message) {
        // Send email
    }
}

class SMSService implements MessageService {
    public function sendMessage($message) {
        // Send SMS
    }
}

class Notification {
    private $messageService;

    public function __construct(MessageService $messageService) {
        $this->messageService = $messageService;
    }

    public function send($message) {
        $this->messageService->sendMessage($message);
    }
}

// Usage
$emailService = new EmailService();
$notification = new Notification($emailService);
$notification->send("Hello via Email!");

$smsService = new SMSService();
$notification = new Notification($smsService);
$notification->send("Hello via SMS!");

In this example, the Notification class depends on the MessageService interface, an abstraction. Both EmailService and SMSService implement the MessageService interface. This adheres to DIP as high-level and low-level modules depend on abstractions.

Example 2: Data Persistence

Bad Example (Violation of DIP):

class MySQLConnection {
    public function connect() {
        // Connect to MySQL database
    }
}

class PasswordReminder {
    private $dbConnection;

    public function __construct() {
        $this->dbConnection = new MySQLConnection();
    }

    public function remind() {
        $this->dbConnection->connect();
        // Do something to remind password
    }
}

Here, the PasswordReminder class is tightly coupled with the MySQLConnection class, which violates DIP.

Good Example (Adhering to DIP):

interface DBConnection {
    public function connect();
}

class MySQLConnection implements DBConnection {
    public function connect() {
        // Connect to MySQL database
    }
}

class PostgreSQLConnection implements DBConnection {
    public function connect() {
        // Connect to PostgreSQL database
    }
}

class PasswordReminder {
    private $dbConnection;

    public function __construct(DBConnection $dbConnection) {
        $this->dbConnection = $dbConnection;
    }

    public function remind() {
        $this->dbConnection->connect();
        // Do something to remind password
    }
}

// Usage
$mySQLConnection = new MySQLConnection();
$passwordReminder = new PasswordReminder($mySQLConnection);
$passwordReminder->remind();

$postgreSQLConnection = new PostgreSQLConnection();
$passwordReminder = new PasswordReminder($postgreSQLConnection);
$passwordReminder->remind();

In this improved example, the PasswordReminder class depends on the DBConnection interface. The specific database connection classes (MySQLConnection, PostgreSQLConnection) implement this interface. This setup adheres to DIP.

Summary

The Dependency Inversion Principle promotes flexibility and maintainability by ensuring that high-level modules are not tightly coupled with low-level modules. By depending on abstractions rather than concrete implementations, you can easily swap out dependencies, making your code more modular and easier to test. Dependency injection is a common technique used to adhere to this principle, where dependencies are injected into a class from the outside rather than being created inside the class.

Leave a Reply