SOLID Principles

Published on

Apr 13, 2023

If you're a software developer, you've probably heard of the SOLID principles. They're a set of guidelines for writing maintainable and scalable code that was introduced by Robert C. Martin (also known as "Uncle Bob"). The SOLID acronym stands for five principles: Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion.

While the SOLID principles were first introduced over two decades ago, they're still relevant today and are considered as an essential foundation for writing quality software. In this blog article, we'll take a deep dive into each of the SOLID principles, explain what they mean, and provide examples of how you can apply them in your code. Whether you're new to software development or a seasoned pro, understanding and following the SOLID principles can help you write better, more maintainable code.

We'll explore each principle in the context of PHP code examples. We'll show how you can apply SOLID principles to create more organized, scalable, and testable code. By the end of this article, you'll have a solid understanding of the SOLID principles and how they can help you in your day-to-day work.

Single responsibility

There should never be more than one reason for a class to change.

To make things clearer, we used to say that if you are not able to describe what a class does within a simple sentence, it probably means that this class holds too many responsibilities.

Let's consider the following CustomerHelper class that defines 3 methods:

class CustomerHelper
{
    private Database $db;

    public function getAllCustomers(): array
    {
        return $this->db->findAllCustomers()->toArray();
    }

    public function getCustomerById(string $id): Customer
    {
        return $this->db->findCustomerById($id);
    }

    public function exportCustomerData(): string
    {
        $customers = $this->getAllCustomers();
        $csv = '';
        
        foreach ($customers as $customer) {
            $csv .= $customer->id . ',' . $customer->name . ',' . $customer->email . PHP_EOL;
        }

        return $csv;
    }
}
  • getAllCustomers() : Fetches all customers from the database.
  • getCustomer() : Fetches a single customer from the database.
  • exportCustomerData() : Fetches all customers from the database and store the result in a CSV file.

A description of this class would be:

"This class is responsible of fetching users from the database and also exporting fetched datas into a CSV file".

Even if we managed to describe the class within a single sentence, it still sounds a little bit too complex because it literally does 2 different things.

Now let's split this class in two:

class CustomerProvider
{
    private Database $db;

    public function getAllCustomers(): array
    {
        return $this->db->findAllCustomers()->toArray();
    }

    public function getCustomerById(string $id): Customer
    {
        return $this->db->findCustomerById($id);
    }
}
class CustomerExporter
{
    public function export(array $customers): string
    {
        $csv = '';
        
        foreach ($customers as $customer) {
            $csv .= $customer->id . ',' . $customer->name . ',' . $customer->email . PHP_EOL;
        }

        return $csv;
    }
}

Open-closed principle

Software entities should be open for extension, but closed for modification

Let's imagine the following class:

final class TaxCalculator
{
    public function calculateTax(
        float $amount,
        string $countryCode
    ): float {
        if ($countryCode === 'US') {
            return $amount * 0.19;
        }

        if ($countryCode === 'UK') {
            return $amount * 0.2;
        }

        if ($countryCode === 'FR') {
            return $amount * 0.08;
        }

        throw new Exception('Unknown country code');
    }

    public function calculateTotalAmount(
        float $amount,
        string $countryCode
    ): float {
        return $amount + $this->calculateTax($amount, $countryCode);
    }
}

The TaxCalculator class has a method calculateTax() expecting an amount and a country to calculate the applicable tax amount. The problem is that you would have to change the calculateTax() method directly anytime you will have to support more countries.

This implementation comes with two consequences:

  1. When you update the calculateTax() method to support new countries you have a chance to introduce some regressions for any other countries. Moreover this class would quickly become very difficult to maintain as the amount of supported countries grows. (💡This is the closed for modification part)
  2. If this class comes from a third party package or even if you just don't have the possibility to edit this file for whatever reason, it becomes impossible for you to extends the behavior of this class. (💡This the open for extension part)

Now let's consider the following abstract class:

abstract class TaxCalculator
{
    abstract public function calculateTax(float $amount): float;

    public function calculateTotalAmount(float $amount): float {
        return $amount + $this->calculateTax($amount);
    }
}

And these implementation:

class UKTaxCalculator extends TaxCalculator
{
    public function calculateTax(float $amount): float
    {
        return $amount * 0.18;
    }
}
class FRTaxCalculator extends TaxCalculator
{
    public function calculateTax(float $amount): float
    {
        return $amount * 0.2;
    }
}

With this approach, you are free to add any other country by simply create a new implementation of any country you need to support. Moreover, adding new country does not force you to modify any other exiting implementation.

Liskov substitution principle

Functions that use references to base classes must be able to use objects of derived classes without knowing it.

This principle is in my opinion the most difficult to understand. So let's take a concrete example to illustrate this.

Imagine the following Rectangle class:

class Rectangle
{
    public function __construct(
        protected float $width,
        protected float $height,
    ) {
    }

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

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

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

This is pretty simple right? A Rectangle is composed of two side height and width and you can get the area from these two values.

Now let's consider the following Square class, which basically is a Rectangle with extra constraints. There is nothing wrong extending the Rectangle class to implement the Square, isn't it ?

class Square extends Rectangle
{
    public function __construct(float $size) {
        parent::__construct($size, $size);
    }

    public function setWidth(float $width): void
    {
        $this->width = $width;
        $this->heigth = $width;
    }

    public function setHeight(float $height): void
    {
        $this->height = $height;
        $this->width = $height;
    }
}

The only difference between this and Rectangle is that when you modify the width, the height is also updated.

Somewhere else in the code, we have this resizeAndGetArea function expecting a Rectangle  and both width and height values.

function resizeAndGetArea(
    Rectangle $rect, 
    float $width, 
    float $height
): float {
    $rect->setWidth($width);
    $rect->setHeight($height);
    
    return $rect->getArea();
}

You are perfectly allowed to use this function like the following:

$square = new Square(5.0);

$area = resizeAndGetArea($square, 2.0, 4.0);

Assert::that($area)->eq(8.0);

💥 In this exemple, $area would be equal to 16.0 and not 8.0!

This is the reason why using a Square object in place of Rectangle totally breaks the Liskov substitution principle. A Square does not behave like a Rectangle does in term of resizing.

Interface segregation principle

Clients should not be forced to depend upon interfaces that they do not use.

This one is more easy to figure out! Take the following interface as example:

interface Vehicle {
    public function startEngine(): void;
    public function stopEngine(): void;
    public function accelarate(int $mph): void;
    public function brake(int $strength): void;
    public function getMph(): int;
    public function takeOff(): void;
    public function land(): void;
    public function goUp(int $feet): void;
    public function goDown(int $feet): void;
}

And these two implementations:

class Plane implements Vehicle {
    public function startEngine(): void { }
    public function stopEngine(): void { }
    public function accelarate(int $mph): void { }
    public function brake(int $strength): void { }
    public function getMph(): int { return $this->mph; }
    public function takeOff(): void { }
    public function land(): void { }
    public function goUp(int $feet): void { }
    public function goDown(int $feet): void { }
}
class Car implements Vehicle {
    public function startEngine(): void { }
    public function stopEngine(): void { }
    public function accelarate(int $mph): void { }
    public function brake(int $strength): void { }
    public function getMph(): int { return $this->mph; }

    // These methods should throw an exception
    public function takeOff(): void { }
    public function land(): void { }
    public function goUp(int $feet): void { }
    public function goDown(int $feet): void { }
}

The Plane class extends Vehicle and is able to implement every methods because a plane can both fly in the air and move on the ground.

It is another story for the Car class that obviously cannot fly (flying cars are not been invented yet as far as I know). So now every methods about flying have to be implemented even if the car cannot actually fly.

In such case, we should split the Vehicle interface to be more flexible:

interface Vehicule
{
    public function accelarate(int $mph): void;
    public function brake(int $strength): void;
    public function getMph(): int;
}
interface HasEngine {
    public function startEngine(): void;
    public function stopEngine(): void;
}
interface Aircraft extends Vehicule {
    public function takeOff(): void;
    public function land(): void;
    public function goUp(int $feet): void;
    public function goDown(int $feet): void;
}

And now our Plane and Car classes can implement only the interfaces that make sense for them:

class Plane implements Aircraft, HasEngine
{
    public function startEngine(): void { }
    public function stopEngine(): void { }
    public function accelarate(int $mph): void { }
    public function brake(int $strength): void { }
    public function getMph(): int { return $this->mph; }
    public function takeOff(): void { }
    public function land(): void { }
    public function goUp(int $feet): void { }
    public function goDown(int $feet): void { }
}
class Car implements Vehicule, HasEngine
{
    public function startEngine(): void { }
    public function stopEngine(): void { }
    public function accelarate(int $mph): void { }
    public function brake(int $strength): void { }
    public function getMph(): int { return $this->mph; }
}

Dependency injection principle

Depend upon abstractions, NOT concretions.

Consider you have a UserManager class (creating a user, managing their passwords, etc.). After every user management task you wish to send a notification to the user.

class UserManager
{
    public function __construct(
        private EmailNotifier $notifier
    ) {
    }

    public function updatePassword(User $user, string $password): void
    {
        // update the user's password

        $this->notifier->notify(
            $user->getId(), 
            'Your password has been updated.'
        );
    }
}

This works perfectly fine but now imagine that you have to write a unit test for this class. Should an email be sent anytime your test case is ran?  Probably not!

A better solution would be to have a DummyNotifier class that would just log something in a file for instance (or even simply ignore the notification).

The main problem is that the UserManager directly depends on EmailNotifier at the moment. Therefore it is not possible to inject this DummyNotifier in UserManager.

Dependency inversion to the rescue!

Let's define the following interface:

interface Notifier
{
    public function notify(string $userId, string $message): void;
}

And now the UserManager can rely upon this new interface:

class UserManager
{
    public function __construct(
        private Notifier $notifier
    ) {
    }

    public function updatePassword(User $user, string $password): void
    {
        // update the user's password

        $this->notifier->notify(
            $user->getId(), 
            'Your password has been updated.'
        );
    }
}

Last but not least, both EmailNotifier and DummyNotifier should implement the Notifier interface:

class EmailNotifier implements Notifier
{
    public function notify(
        string $userId,
        string $message
    ): void {
        // send an email to the user
    }
}
class DummyNotifier implements Notifier
{
    public function notify(
        string $userId,
        string $message
    ): void {
        // do some dummy stuff
    }
}

With this approach you can either pass the SMSNotifier or DummyNotifier to the UserManager class depending of the context.

Conclusion

In conclusion, the SOLID principles provide a set of guidelines for writing high-quality and maintainable software. By adhering to these principles, developers can create software that is flexible, extensible, and easy to modify. While applying them may require more upfront effort, the long-term benefits are significant, and they can result in software that is more reliable, scalable, and adaptable to changing requirements. Therefore, it is essential for developers to learn and apply these principles in their software development practices to ensure that their code is of high quality and can withstand the test of time.

Did this article help? Please leave us a comment or send us a KUDO-Tweet 🐦

Written by

Antoine Lelaisant
Antoine Lelaisant

Caen

Front and backend developer with a pref for mobile apps. Loves to share his XP with clients & KNPeers during our trainings.

Comments