SOLID Principles: Part 3, Liskov Substitution

Part 3 of SOLID Principles: Writing Maintainable Object-Oriented Code

#OOP #PHP #Symfony

The Liskov Substitution Principle (LSP) is the third principle in the SOLID design principles series.
LSP focuses on the idea that any instance of a parent class should be able to be replaced with an instance of any of its child classes without causing any unexpected or erroneous behavior.

image for SOLID Principles: Part 3, Liskov Substitution

Introduction to LSP

Liskov Substitution Principle states that:

"objects of a superclass shall be replaceable with objects of its subclasses without breaking the application."

When program behavior changes unexpectedly by replacing a superclass object with a subclass object, it means that the LSP has been violated.
The LSP applies to the inheritance relationship between supertypes and subtypes, whether through class extension or interface implementation.
The supertype methods define a contract that all subtypes should adhere to.

The good news is that, with modern PHP versions, if you type hint your methods arguments and return types you should have fewer risks of breaking LSP, except in the case of throwing exceptions.

Project Setup

As with the other articles in this SOLID series, we will use a simple blog demo application built with Symfony. It is recommended to read these articles in order as each of them is starting where the previous one finished.

You can find the code for the example in this article in this Github repository. We will start on the "open_closed_right" branch which is where we left off in the previous article about Open-Closed Principle.

To install the application, follow the steps in the README file. For more details on this application please have a look at the first article of this series.

Breaking the LSP

As stated in the introduction, you have few risks of breaking LSP if you type hint your methods arguments and return types.
Therefore, to keep things simple, our example of breaking LSP wil rely on an interface method for which we "forgot" to add a return type.

We have articles in our blog applications. We would like to use a service to automatically calculate a rating for these articles.

First we create an ArticleRaterInterface interface, which will define the signature for a "rate" method.

App\Contract\ArticleRaterInterface

namespace App\Contract;

use App\Entity\Article;

interface ArticleRaterInterface
{
    public function rate(Article $article);
}

Then we create a class NumericRater that implements this interface.
Let's say that this numeric rater, through some magical AI calculations 😉, is able to give us the rating of an Article object.

App\Service\Rating\NumericRater

namespace App\Service\Rating;

use App\Contract\ArticleRaterInterface;
use App\Entity\Article;

class NumericRater implements ArticleRaterInterface
{

    public function rate(Article $article)
    {
        return rand(1, 5);
    }
}

We create an ArticleRater service in which we inject an ArticleRaterInterface dependency, and we decide to return a string of stars according to the calculated rating.

App\Service\Rating\ArticleRater

namespace App\Service\Rating;

use App\Contract\ArticleRaterInterface;
use App\Entity\Article;

class ArticleRater
{
    public function __construct(
        private readonly ArticleRaterInterface $rater
    )
    {
    }

    public function rate(Article $article): string
    {
        return str_repeat('⭐', $this->rater->rate($article));
    }
}

We have to add an alias for ArticleRaterInterface in our services.yaml config file.
In fact we do not need it for now because if only one service is discovered that implements an interface, configuring the alias is not mandatory and Symfony will automatically create one. But we know that we will probably have another service that implements this interface later.

config/services.yaml

services:

    App\Contract\ArticleRaterInterface: '@App\Service\Rating\NumericRater'

Then in our ArticleController "show" method we use this new ArticleRater service to calculate the rating of our article.

App\Controller\ArticleController

#[Route(path: '/{slug}', name: 'show', methods: ['GET'])]
public function show(ArticleRater $articleRater, Article $article) : Response
{
    $starsRating = $articleRater->rate($article);
    return $this->render('article/show.html.twig', [
        'article' => $article,
        'starsRating' => $starsRating,
    ]);
}

Then in our twig template we use this new starsRating variable.

templates/article/show.html.twig

{% extends 'base.html.twig' %}

{% block title %}My Blog - {{ article.title }}{% endblock %}

{% block body %}
    <div class="w-full bg-cover p-16 mb-6" 
         style="background-image: url('{{ article.image }}'); background-position: center; height: 400px">
        <h1 class="text-6xl font-bold mt-6 mb-6 text-center">{{ article.title }}</h1>
    </div>
    <div class="mb-6">
        <p>Rating: {{ starsRating }}</p>
    </div>
    <div>{{ article.content | nl2br }}</div>
{% endblock %}

And voila!

Screenshot of a blog article with a stars rating

But some times later as we expected we have to change to a new rating calculation service.
This new service is an even more evolved AI 😂 that is capable of giving us a text rating!!!

To use this new rating calculation service we create a TextRater class and we make it implement the same ArticleRaterInterface interface.

App\Service\Rating\TextRater

namespace App\Service\Rating;

use App\Contract\ArticleRaterInterface;
use App\Entity\Article;

class TextRater implements ArticleRaterInterface
{

    public function rate(Article $article)
    {
        $ratings = ['Poor', 'Average', 'Great'];
        return $ratings[array_rand($ratings)];
    }
}

Then to use this new service instead of the NumericRater we just have to update our services.yaml config file

config/services.yaml

services:

    App\Contract\ArticleRaterInterface: '@App\Service\Rating\TextRater'

You can find the whole code on the liskov_wrong branch.

We refresh our article page and... "str_repeat(): Argument #2 ($times) must be of type int, string given".

We just broke our app and the LSP.

Screenshot of a broken blog application with an error message

Refactoring to Follow the LSP

Both of our rating services implement the same interface so they should be substitable.
Why do we have this error message?
The problem in this example is quite easy to find: the "rate" methods of our rating services do not return the same type.
NumericRater is returning an int while TextRate is returning a string, and the str_repeat function is not too happy with a string as its second argument.

If we had added the return type in our interface we would have seen the problem coming earlier, so let's start by doing that.

App\Contract\ArticleRaterInterface

namespace App\Contract;

use App\Entity\Article;

interface ArticleRaterInterface
{
    public function rate(Article $article): int;
}

We now have to update our concrete implementations accordingly.

Not much changes for NumericRater.

App\Service\Rating\NumericRater

namespace App\Service\Rating;

use App\Contract\ArticleRaterInterface;
use App\Entity\Article;

class NumericRater implements ArticleRaterInterface
{

    public function rate(Article $article): int
    {
        return rand(1, 5);
    }
}

More changes are needed for TextRater so that its "rate" method returns an int instead of a string.
In a real world example we would probably not be able to modify it directly but maybe use a decorator or an adpater.
To keep things simple I will just add a "convertTextRatingToNumeric" method in this class.

App\Service\Rating\TextRater

namespace App\Service\Rating;

use App\Contract\ArticleRaterInterface;
use App\Entity\Article;

class TextRater implements ArticleRaterInterface
{

    public function rate(Article $article): int
    {
        $ratings = ['Poor', 'Average', 'Great'];
        return $this->convertTextRatingToNumeric($ratings[array_rand($ratings)]);
    }

    private function convertTextRatingToNumeric(string $rating): int
    {
        return match ($rating) {
            'Poor' => 1,
            'Great' => 5,
            default => 3,
        };
    }
}

If we refresh our article page, our app works again!

You can find the whole code for this version on the liskov_right branch.

In this article, we demonstrated how LSP can be violated by forgetting to add a return type to an interface method.
As stated earlier this is a simple example and you will probably not have the same issue, but I hope it helps understand LSP.

See you in the next article which will be about the Interface Segregation Principle.


Last updated 1 year ago