SOLID Principles: Part 5, Dependency Inversion

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

#OOP #PHP #Symfony

Welcome to the fifth and final article in the SOLID series.
In this article, we will explore the Dependency Inversion Principle (DIP), which emphasizes the importance of designing modules that depend on abstractions rather than concrete implementations.

image for SOLID Principles: Part 5, Dependency Inversion

Introduction to DIP

Dependency Inversion Principle is probably for me the hardest one to understand of the SOLID principles.
It has a two part definition. One:

"High-level modules should not import anything from low-level modules. Both should depend on abstractions (e.g., interfaces)."

And Two:

"Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions."

This definition is...😱

Let's try to rephrase this with words we can understand. It roughly means that our classes dependencies should be interfaces instead of concrete implementations and that these interfaces should be designed by the class that uses them, not by the classes that will implement them.

Project Setup

For the last time in this SOLID series, we will use our simple blog application built with Symfony.
By now I hope that you read the previous articles of this series. If not, go read them now!
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 "interface_segregation_right" branch which is where we left off in the previous article about Interface Segregation 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 DIP

In our very simple application we allow article creations and updates without doing any real validation.

Let's say that we want to add some automated moderation on the text for the title and content of our articles.
We will be creating a custom validation constraint that will use a service to send a request to ChatGPT moderation API and check if the result are flagged as violating OpenAI's usage policies, which will consider to be equivalent to breaking our blog moderation policy.

We will need an API key to authenticate, we can generate one in our OpenAI account. Once created, we set this API key to a CHATGPT_API_KEY environment variable (this is not something we want to commit).

Let's start by creating the service that will be responsible to send the moderation request to the API.

App\Service\Moderation\ChatGPTModerator

namespace App\Service\Moderation;

use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

class ChatGPTModerator
{
    public function __construct(
        private readonly HttpClientInterface $httpClient,
        private readonly string $apiKey
    )
    {
    }

    /**
     * @throws TransportExceptionInterface
     * @throws ServerExceptionInterface
     * @throws RedirectionExceptionInterface
     * @throws ClientExceptionInterface
     */
    public function moderate(string $text): array
    {
        $response = $this->httpClient->request('POST', 'https://api.openai.com/v1/moderations', [
            'auth_bearer' => $this->apiKey,
            'json' => ['input' => $text],
        ]);

        return json_decode($response->getContent())->results;
    }
}

The response content that we will get will look something like that:

{
    "id": "modr-70H3Cp0IaszpDtGoKEK341nxzURyw",
    "model": "text-moderation-004",
    "results": [
        {
            "flagged": false,
            "categories": {
                "sexual": false,
                "hate": false,
                "violence": false,
                "self-harm": false,
                "sexual/minors": false,
                "hate/threatening": false,
                "violence/graphic": false
            },
            "category_scores": {
                "sexual": 2.9158951292629354e-05,
                "hate": 1.8515093813675776e-07,
                "violence": 6.855625400703502e-08,
                "self-harm": 3.903326106780014e-09,
                "sexual/minors": 3.193517272848112e-08,
                "hate/threatening": 6.451685539976548e-12,
                "violence/graphic": 7.3293811020391786e-09
            }
        }
    ]
}

Our ChatGPTModerator service will return us the "results" property of this response.

We must add some condiguration in our services.yaml file to pass our API key to this service.

config/services.yaml

parameters:
    chatgpt_api_key: '%env(CHATGPT_API_KEY)%'
services:
    ...
    App\Service\Moderation\ChatGPTModerator:
        arguments:
            $apiKey: '%chatgpt_api_key%'

In order to create the custom validation constraint we will need Symfony's Validator component.
Let's require this component.

composer require symfony/validator

Then we will create our custom constraint.

App\Service\Validation\IsSafeText

namespace App\Service\Validation;

use Symfony\Component\Validator\Constraint;

#[\Attribute]
class IsSafeText extends Constraint
{
    public string $message = 'This text does not comply with our moderation policy';
}

The "#[\Attribute]" attribute will allow us to use this constraint as an attribute on the properties of the object that we want to validate.

The constraint will be validated by the IsSafeTextValidator class. This class will take an instance of our ChatGPTModerator service as a dependency. We need to access the "flagged" property which is in the first element of the results array. If this property is true, it means that ChatGPT as detected some content that did not comply with its moderation rules. In this case we add a Violation.

App\Service\Validation\IsSafeTextValidator

namespace App\Service\Validation;

use App\Service\Moderation\ChatGPTModerator;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;

class IsSafeTextValidator extends ConstraintValidator
{
    public function __construct(private readonly ChatGPTModerator $moderator)
    {
    }

    /**
     * @throws TransportExceptionInterface
     * @throws ServerExceptionInterface
     * @throws RedirectionExceptionInterface
     * @throws ClientExceptionInterface
     */
    public function validate(mixed $value, Constraint $constraint)
    {
        if (!$constraint instanceof IsSafeText) {
            throw new UnexpectedTypeException($constraint, IsSafeText::class);
        }

        if (null === $value || '' === $value) {
            return;
        }

        if (!is_string($value)) {
            throw new UnexpectedValueException($value, 'string');
        }

        moderatedResults  = $this->moderator->moderate($value);
        if ($moderatedResults[0]->flagged) {
            $this->context->buildViolation($constraint->message)
                ->setParameter('{{ string }}', $value)
                ->addViolation();
        }
    }
}

And lastly, for our validation to be applied on our article data transfer object when creating or editing, we add the corresponding constraint attribute on the "title" and "content" properties of the ArticleDto class.

App\DTO\ArticleDto

namespace App\DTO;

use App\Service\Validation\IsSafeText;

class ArticleDto
{
    public function __construct(
        #[IsSafeText]
        public ?string $title = null,
        public ?string $slug = null,
        #[IsSafeText]
        public ?string $content = null,
        public ?string $image = null,
    )
    {
    }
}

You can find the code for this version on the dependency_inversion_wrong branch.

If we try to submit an article form with some unwanted content or title, we get a validation error:

Screenshot of an article creation form with some validation error

Refactoring to Follow the DIP

Our code works but our IsSafeTextValidator is tightly coupled with the implementation of our moderator service. We did not follow the Dependency Inversion Principle.

So let's have a look at the changes we should make to follow the DIP definition.

The first part of its definition tells us that our IsSafeTextValidator should depend on a interface instead of an implementation, therefore we should create a ModeratorInterface interface and make our ChatGPTModerator implement it.

Our first, lazy, reflex would be to copy the signature of the "moderate" method of the already existing ChatGPTModerator.
But the second part of the DIP definition tells us that concrete implementations should depend on abstractions, i.e. our interface should be designed by the class that will use it, not the class that will implement it.

The class that will use the ModeratorInterface interface is our IsSafeTextValidator class. For now it was getting a "$moderatedResults" array from our ChatGPTModerator service, and it had to access its first key and then the "flagged" property.
Our ChatGPTModerator is only interested in receiving a boolean value that indicates if the text is flagged as a moderation violation or not, therefore we should design our ModeratorInterface accordingly. The name of the method "moderate" is vague and does not clearly tell us if the meaning of the boolean returned, so let's change it to something like "isModerationViolation";

App\Contract\ModeratorInterface

namespace App\Contract;

interface ModeratorInterface
{
    public function isModerationViolation(string $text): bool;
}

And then we have our ChatGPTModerator service implement it.

App\Service\Moderation\ChatGPTModerator

namespace App\Service\Moderation;

use App\Contract\ModeratorInterface;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

class ChatGPTModerator implements ModeratorInterface
{
    public function __construct(
        private readonly HttpClientInterface $httpClient,
        private readonly string $apiKey
    )
    {
    }

    /**
     * @throws TransportExceptionInterface
     * @throws ServerExceptionInterface
     * @throws RedirectionExceptionInterface
     * @throws ClientExceptionInterface
     */
    public function isModerationViolation(string $text): bool
    {
        $response = $this->httpClient->request('POST', 'https://api.openai.com/v1/moderations', [
            'auth_bearer' => $this->apiKey,
            'json' => ['input' => $text],
        ]);

        $moderatedResults = json_decode($response->getContent())->results;
        return $moderatedResults[0]->flagged;
    }
}

Then we can inject the ModeratorInterface interface as a dependency in our IsSafeTextValidator instead of depending on a concrete implementation, and use this interface's "isModerationViolation" method.

App\Service\Validation\IsSafeTextValidator

namespace App\Service\Validation;

use App\Contract\ModeratorInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;

class IsSafeTextValidator extends ConstraintValidator
{
    public function __construct(private readonly ModeratorInterface $moderator)
    {
    }

    /**
     * @throws ServerExceptionInterface
     * @throws RedirectionExceptionInterface
     * @throws ClientExceptionInterface
     */
    public function validate(mixed $value, Constraint $constraint)
    {
        if (!$constraint instanceof IsSafeText) {
            throw new UnexpectedTypeException($constraint, IsSafeText::class);
        }

        if (null === $value || '' === $value) {
            return;
        }

        if (!is_string($value)) {
            throw new UnexpectedValueException($value, 'string');
        }

        if ($this->moderator->isModerationViolation($value)) {
            $this->context->buildViolation($constraint->message)
                ->setParameter('{{ string }}', $value)
                ->addViolation();
        }
    }
}

And that's it we have now decoupled our IsSafeTextValidator class from ChatGPTModerator. In the future we will be able to replace this moderator service by another one as long as we make it implement the ModeratorInterface interface.

You can find the code for this final version on the dependency_inversion_right branch.

And that concludes this article on the Dependency Inversion Principle, as well as our SOLID series.
It's probably a bit hard to always keep in mind these principles in our daily developer lives and they may not always be the way to go for your specific application but I still think it that they are interesting to study at least.

All along this series I tried to keep the explanations and examples as simple as possible. I hope it was interesting and useful.

Thank you for reading this!


Last updated 1 year ago