Architecture : De l'intérêt d'encapsuler vos dépendances externes

tl;dr : nous allons parler et illustrer l'intérêt de créer quelques objets PHP autour de certains composants externes, tout en restant sur l'écosystème Symfony, dans le but de réduire le travail lors des mises à jour du Framework.

Attention : cet article fait partie d'une série consacrée à l'architecture, les autres articles sont disponibles via cette recherche.

Préambule

J'ai vu passer un tweet de Grégoire hier concernant le changelog Symfony 6.3 à venir. En le parcourant, j'ai pu lire ceci :

Bundle/TwigBundle

Deprecate the Twig_Environment autowiring alias, use Twig\Environment instead

Ça m'a fait sourire parce que j'ai repensé à ce changement (le deprecate existe depuis très longtemps, Twig v2.7, juillet 2019).

Mais j'avais quand même ce sourire au coin qui dit “tiens, certaines personnes vont devoir faire avec cette dépréciation dans tout leur projet alors que moi, non”. Alors l'occasion fait le larron, petit article.

Pourquoi encapsuler ses dépendances externes ?

Si vous êtes tout neuf, ou toute neuve, sur des projets Symfony, vous ne savez peut être pas que le Framework a, et va, encore évoluer. Il va évoluer en termes de choix techniques, d'architecture, d'implémentation, de composants, etc. Il va même arriver certaines fois où l'écosystème Symfony va livrer un nouveau composant qui pourrait remplacer une dépendance tierce. C'est normal.

Les grandes questions sont donc : comment faire pour se prémunir de tout ça ? Comment éviter d'avoir du collatéral en cas de changement ? Comment passer moins de temps sur les migrations de code ? etc.

L'une des réponses : l'encapsulation.

Dans des architectures DDD, hexagonale, clean, “défensive” ou d'ailleurs, tout simplement en POO, on va vous inciter à vous protéger de vos dépendances externes et d'en restreindre l'accès.

Cette protection n'est pas une protection pour faire bien sur le papier et se gargariser. C'est une protection vis-à-vis des tiers, c'est même une méthode heuristique de gestion de la dette technique (©️ Julien Topçu).

Comment ?

Pour chaque dépendance externe, nous allons créer un dossier (et donc un espace de noms) avec à minima une interface et une implémentation de cette interface. Nous utiliserons nos interfaces dans nos injections de dépendances, l'autowiring de Symfony fera très bien son travail et notre code échangera avec nos méthodes au travers de contrats.

C'est vraiment la méthode de base à suivre et vous verrez que généralement, l'implémentation concrète est vraiment simple, voire ridicule, au point de déclencher des débats et claquer les plus belles poses de Yamcha aka “la grosse PLS” chez certaines personnes parce que tu comprends Alexandre, ça coute rien à changer, bim bam boum, rechercher/remplacer. Oui. Sauf que quand j'arrive sur des projets, le constat est simple personne, ou presque, ne le fait.

Alors je sors ma réponse simple et empirique, parce que comme on le sait tous et toutes, c'est toujours chez les autres et jamais chez nous :

Je traine depuis maintenant 9 ans certaines implémentations, je les ai fait évoluer rapidement, dans tous les projets, au travers de 5 versions majeures de Symfony. On est la pour coder. C'est un faux-débat.

Cas concrets

Quelques cas concrets pour illustrer le propos maintenant. Nous allons parler d'UUID, de Slug et Template. J'utilise pour ma part des architectures inspirées de DDD, Hexagonal et Clean Architecture, mais ce n'est pas un prérequis. Faites ce que vous voulez de vos espaces de noms, ce n'est pas ce point qui est le plus important alors n'en faites surtout pas un prérequis.

UUID

J'ai toujours utilisé le composant UUID de Ben Ramsey, mais Symfony a sorti son propre composant UID. J'étais plutôt dans la team “pourquoi ?” et puis j'ai rationalisé.

J'utilise tout le temps et par défaut des UUID v4.

<?php

declare(strict_types=1);

namespace App\Shared\Infrastructure\Generator;

interface GeneratorInterface
{
    public static function generate(): string;
}

Le diff de l'implémentation (hors use) a donc été le suivant :

- return Uuid::uuid4()->toString();
+ return Uuid::v4()->toRfc4122();

Comme vous l'aurez compris, mon code utilise quant à lui toujours la méthode UuidGenerator::generate(). Le jour où je voudrais changer de point de vue sur la version de l'UUID, ça se passera ici et uniquement ici.

Slug

Créer un slug est un besoin récurrent, ne serait-ce que pour du SEO. Là aussi et pendant longtemps, j'ai utilisé la librairie cocur\slugify et Symfony a apporté un nouveau composant String.

L'interface est la suivante

<?php

declare(strict_types=1);

namespace App\Shared\Infrastructure\Slugger;

interface SluggerInterface
{
    public static function slugify(string $string, string $separator = '-'): string;
}

Le diff :

- return (new Slugify())->slugify($string, $separator);
+ return (new AsciiSlugger())
+             ->slug($string, $separator)
+             ->toString();

Problème, contrairement à la librairie initialement utilisée, le composant ne touche pas à la casse du texte qu'on lui envoie. Il faut penser à lui dire de tout mettre en minuscule.

Comment est-ce que je l'ai vu ? Avec le test unitaire, très simple à écrire, qui va avec l'implémentation. Du coup je me suis fait avoir… Le temps de lancer le test pour vérifier le changement.

<?php

declare(strict_types=1);

namespace spec\App\Shared\Infrastructure\Slugger;

use App\Shared\Infrastructure\Slugger\Slugger;
use App\Shared\Infrastructure\Slugger\SluggerInterface;
use PhpSpec\ObjectBehavior;

class SluggerSpec extends ObjectBehavior
{
    function it_is_initializable()
    {
        $this->shouldHaveType(Slugger::class);
        $this->shouldBeAnInstanceOf(SluggerInterface::class);
    }

    function it_should_slugify_a_simple_sentence()
    {
        $this::slugify('A simple sentence')->shouldReturn('a-simple-sentence');
        $this::slugify('A Simple Sentence', '_')->shouldReturn('a_simple_sentence');
    }
}

J'avais oublié le lower, le texte retourné était A-simple-sentence/A_Simple_Sentence.

Le diff final :

return (new AsciiSlugger())
            ->slug($string, $separator)
+             ->lower()
            ->toString();

Je n'ai pas activé le support des emojis, nous verrons si cela arrive lors des projets et on verra ça au cas par cas.

Template

Dernier exemple pour la route, le fameux Twig. Oui, je fais du Symfony et encapsule du Twig dans un objet Template. Pourquoi ? Réponse dans la modification du changelog précédemment cité, mais, petite pensée pour Twig v1, v2 et enfin v3. Ca ne rajeunit pas ce genre de constats.

<?php

declare(strict_types=1);

namespace App\Shared\Infrastructure\Templating;

interface TemplatingInterface
{
    /**
     * @param array<array-key, mixed> $parameters
     */
    public function render(string $name, array $parameters = []): string;
}

Et son implémentation (qui n'a pas bougé depuis un moment maintenant) :

<?php

declare(strict_types=1);

namespace App\Shared\Infrastructure\Templating;

use Twig\Environment;

final class Twig implements TemplatingInterface
{
    public function __construct(private readonly Environment $twig)
    {
    }

    /**
     * @param array<array-key, mixed> $parameters
     */
    public function render(string $name, array $parameters = []): string
    {
        return $this->twig->render($name, $parameters);
    }
}

Oui je pourrais me payer le luxe de changer de moteur de template, d'en avoir plusieurs en parallèle et je ne le ferais probablement pas mais j'espère que vous avez compris que le point n'est pas la.

Pour terminer

Je fais la même chose pour d'autres composants et librairies tels que le Logger, le Mailer, des bus du composant Messenger, l'upload de fichiers et j'en passe. Comme vous le voyez, le cout technique est ridicule et il est en plus largement compensé lors que l'on veux changer notre façon de travailler avec du code tiers car le changement ne s'opère qu'à un seul endroit. Ce point d'architecture est donc dans ma case du “ça ne coute rien, ça couvre le cas où, c'est du quick win”.

Pour terminer, je pense également au composant Clock, composant officiellement introduit avec Symfony 6.2. Là encore, sourire au coin, un peu mesquin car je ne me souviens pas du nombre exact de discussions où l'on a pu m'expliquer que “la maitrise du temps” était une fois de plus un délire inutile associé au DDD. Ca ne m'empêchait pas d'encapsuler un DateTimeImmutable.

J'ai supprimé mon implémentation, l'ai remplacée par le composant Symfony, ai vérifié mes tests et… ça juste marche.

Cout technique de l'opération, 5 minutes.