Inversion de dépendance en Laravel

Publié le 22 avril 2024 par Mathieu De Gracia
Couverture de l'article Inversion de dépendance en Laravel

De nos jours, il est fort probable que vous utilisiez des packages ou des services tiers pour accomplir des tâches génériques.

Grace à composer, l'ajout d'une nouvelle dépendance à nos projets est devenu quelque chose de banal, le milliard de téléchargements de Spatie devrait suffire à vous en convaincre !

Le plus souvent, la mise en place d'un package est toujours exaltante, nous découvrons des fonctionnalités et des nouvelles possibilités pour notre application ... cependant, cet enthousiasme ne dure jamais éternellement.

Nous avons tous déjà été confrontés à un package désormais obsolète ou nécessitant d'être mis à jour, totalement imbriqué dans notre code au point de nécessiter d'importantes refactorisations.

Notre relation envers les packages est bien souvent à sens unique et source de frustrations ... cela devrait nous interroger, après tout, ne sont ils pas là pour nous aider ?

Voyons dans cet article comment nous pourrions intégrer le package FeedIO, facilitant la récupération de flux RSS, dans une application Laravel à travers 3 architectures différentes !

  1. Une approche simpliste
  2. Encapsulation dans un service
  3. Inversion de dépendance

Une approche simpliste

Une premiere approche minimaliste consistera à effectuer toutes les différentes actions dans le même composant sans aucune séparation claire des responsabilités.

Dans ce schéma, notre commande FetchPostsUsingFeed manipule une instance du package FeedIo lui permettant d'obtenir des feeds sous la forme d'instances de Item provenant également du package.

Dans cette situation, notre logique métier devient totalement tributaire du package et la dépendance se fait de l'extérieur (le package) vers l'intérieur de notre application (la commande).

1<?php
2 
3namespace App\Application\Console\Commands;
4 
5use App\Application\Models\Post;
6use FeedIo\FeedIo;
7use Illuminate\Console\Command;
8 
9class FetchPostsUsingFeed extends Command
10{
11 protected $signature = 'feed:fetch-posts';
12 
13 public function handle(FeedIo $feedIo)
14 {
15 $result = $feedIo->read('https://laravel-france.com/rss');
16 
17 $items = $result->getFeed();
18 
19 foreach ($items as $item) {
20 
21 Post::updateOrCreate(
22 [
23 'url' => $item->getLink(),
24 ],
25 [
26 'title' => $item->getTitle(),
27 ],
28 );
29 }
30 }
31}

Dans un premier temps, cette approche est rapide et peu coûteuse ... mais rend notre code fortement dépendant du package FeedIo.

La classe FetchPostsUsingFeed détient une logique métier de notre application, celle de récupérer les éléments d'un flux RSS et de les sauvegarder. Le moyen permettant d'accéder aux flux RSS n'est qu'un détail de notre logique, il n'a que peu d'importance.

Notre commande, en instanciant directement la classe FeedIo et en manipulant les valeurs qu'elle retourne, a connaissance du package et se retrouve étroitement liée à ce dernier : notre code devient couplé.

Cette absence de séparation des responsabilités pourrait s'avérer problématique pour la pérennité de notre classe et pourrait rendre contraignante toute possibilité de faire évoluer notre logique dans le futur.

Encapsulation dans un service

Un moyen rapide de reprendre d'avantage de contrôle sur les dépendances de notre application consistera à encapsuler le package FeedIo dans un service, par exemple, une classe dénommée Reader :

Le service Reader aura une double responsabilité, dans un premier temps, il devra encapsuler et manipuler une instance du package FeedIo, offrant une première barrière entre notre logique métier et cette implémentation.

Dans un second temps, le service devra préparer dans une structure de données les feeds à renvoyer vers notre commande sous forme de tableau.

1<?php
2 
3namespace App\Application\Services;
4 
5use FeedIo\Feed\Item;
6use FeedIo\FeedIo;
7 
8class Reader
9{
10 public function __construct(
11 protected FeedIo $feedIo,
12 ){}
13 
14 public function fetch(string $url): array
15 {
16 $result = $this->feedIo->read($url);
17 
18 $items = [];
19 
20 foreach($result->getFeed() as $item) {
21 
22 $items[] = [
23 'title' => $item->getTitle(),
24 'url' => $item->getLink(),
25 ];
26 }
27 
28 return $items;
29 }
30}

Désormais, notre commande n'a plus qu'à utiliser ce nouveau service :

1<?php
2 
3namespace App\Application\Console\Commands;
4 
5use App\Application\Models\Post;
6use App\Application\Services\Reader;
7use Illuminate\Console\Command;
8 
9class FetchPostsUsingFeed extends Command
10{
11 protected $signature = 'feed:fetch-posts';
12 
13 public function handle(Reader $reader)
14 {
15 $items = $reader->read('https://laravel-france.com/rss');
16 
17 foreach ($items as $item) {
18 
19 Post::updateOrCreate(
20 [
21 'url' => $item['url'],
22 ],
23 [
24 'title' => $item['title'],
25 ],
26 );
27 }
28 }
29}

Cette approche est plus saine que la précédente car vous protégerez d'avantage le cœur de votre application, FetchPostsUsingFeed, du monde extérieur en passant par un service Reader faisant office de passerelle avec le package.

Cette solution possède néanmoins deux défauts : bien que Reader soit un service que nous détenons, notre logique métier a toujours connaissance de cette implémentation qui reste un détail dans son fonctionnement.

De plus, notre service Reader prépare et renvoie une simple structure de données à travers un tableau, offrant un contrôle quasi-nul sur la forme et la pertinence des données.

Un problème plus insidieux est également présent : la création de cette structure de données est toujours de la responsabilité de la classe Reader ... provoquant un couplage entre la commande et le service.

Ainsi, la commande est toujours dépendante d'une donnée structurée par le service, une modification de cette dernière se répercutera dans notre commande et pourrait provoquer des régressions dans notre logiques métier : nous perdons le contrôle sur le flux de dépendances.

FeedIo se répercute toujours sournoisement dans notre commande

Dans une approche totalement découplée, il sera nécessaire de dissocier l'implémentation de notre cas d'utilisation et de conserver un contrôle total sur les données transitant vers notre commande à l'aide d'interfaces et de DTO.

Inversion de dépendance

Une application se compose généralement de plusieurs couches, ou "layers".

Dans nos précédents exemples, l'intégralité du code était regroupée dans un unique layer dénommé "Application".

Dans cette troisième et dernière approche, nous allons découpler le code en utilisant plusieurs interfaces ainsi qu'un DTO afin de séparer logiquement notre logique métier du package.

Nous introduirons également un nouveau layer, via un namespace, pour séparer physiquement nos cas d'utilisation (Application) des détails d'implémentation que nous nommerons : "Infrastructure".

Commençons par créer deux nouvelles interfaces, notre couche d'application sera dépendante, et uniquement dépendante, de ses propres interfaces qu'elle aura elle-même déterminées.

Notre première interface sera destinée au service Reader :

1namespace App\Application\Interfaces\Services;
2 
3use App\Application\Interfaces\DataTransferObjects\ItemInterface;
4 
5interface ReaderInterface
6{
7 /**
8 * @return array<ItemInterface>
9 */
10 public function fetch(string $url): array;
11}

Notre seconde interface permettra, quant à elle, de définir la structure des données que nous implémenterons dans le DTO :

1namespace App\Application\Interfaces\DataTransferObjects;
2 
3interface ItemInterface
4{
5 public function getTitle(): string;
6 public function getUrl(): string;
7}

Cette séparation physique à l'aide des interfaces est la base de l'inversion de dépendance : notre layer Application est dépendant de lui seul et n'a aucune connaissance du monde extérieur.

Les interfaces agissent ici comme un pattern d'anticorruption en établissant une frontière franche entre nos deux couches.

Maintenant que nos interfaces sont disponibles, nous pouvons les implémenter.

Commençons par créer le DTO ReaderItem afin de remplacer la structure de données de notre précédente approche :

1namespace App\Infrastructure\DataTransferObjects;
2 
3use App\Application\Interfaces\DataTransferObjects\ItemInterface;
4 
5readonly class ReaderItem implements ItemInterface
6{
7 public function __construct(
8 private string $url,
9 private string $title,
10 ) {}
11 
12 public function getUrl(): string
13 {
14 return $this->url;
15 }
16 
17 public function getTitle(): string
18 {
19 return $this->title;
20 }
21}

Dans un second temps, déplaçons et renommons notre service Reader dans la couche Infrastructure et implémentons l'interface ReaderInterface.

Assurons nous également de retourner un tableau contenant des instances de notre DTO précédemment créé, ReaderItem :

1namespace App\Infrastructure\Services;
2 
3use FeedIo\FeedIo;
4use App\Infrastructure\DataTransferObjects\ReaderItem;
5use App\Application\Interfaces\Services\ReaderInterface;
6use App\Application\Interfaces\DataTransferObjects\ItemInterface;
7 
8class FeedIoReader implements ReaderInterface
9{
10 public function __construct(
11 protected FeedIo $feedIo,
12 ) {}
13 
14 /**
15 * @return array<ItemInterface>
16 */
17 public function fetch(string $url): array
18 {
19 $result = $this->feedIo->read($url);
20 
21 $items = [];
22 
23 foreach ($result->getFeed() as $item) {
24 
25 $items[] = new ReaderItem(
26 title: $item->getTitle(),
27 url: $item->getLink(),
28 );
29 }
30 
31 return $items;
32 }
33}

Pour finir, dans le cadre de Laravel, il sera nécessaire, depuis le AppServiceProvider, de lier le ReaderInterface à son implementation afin de l'injecter automatiquement dans votre code :

1namespace App\Providers;
2 
3use App\Infrastructure\Services\FeedIoReader;
4use App\Application\Interfaces\Services\ReaderInterface;
5 
6class AppServiceProvider extends ServiceProvider
7{
8 public $bindings = [
9 ReaderInterface::class => FeedIoReader::class,
10 ];
11 
12 // [...]
13}

Désormais, notre commande FetchPostsUsingFeed, présente dans le layer Application, peut utiliser les différentes interfaces que nous venons d'implémenter :

1namespace App\Application\Console\Commands;
2 
3use Illuminate\Console\Command;
4use App\Application\Models\Post;
5use App\Application\Interfaces\Services\ReaderInterface;
6use App\Application\Interfaces\DataTransferObjects\ItemInterface;
7 
8class FetchPostsUsingFeed extends Command
9{
10 protected $signature = 'app:fetch-posts-using-feed';
11 
12 public function handle(ReaderInterface $reader)
13 {
14 $items = $reader->fetch('https://laravel-france.com/rss');
15 
16 foreach ($items as $item) {
17 $this->updateOrCreatePost($item);
18 }
19 }
20 
21 private function updateOrCreatePost(ItemInterface $item): void
22 {
23 Post::updateOrCreate(
24 [
25 'url' => $item->getUrl(),
26 ],
27 [
28 'title' => $item->getTitle(),
29 ],
30 );
31 }
32}

Avec ce découplage, notre logique métier est devenue totalement indépendante du package, elle n'a plus connaissance de ses détails d'implémentation et a repris le contrôle sur le flux des dépendances.

Comme vous pouvez le voir, toutes les dépendances entre les deux couches sont désormais unidirectionnelles : elles viennent de l'extérieur (Infrastructure) et pointent vers l'intérieur (Application).

Cette dernière approche nécessite de nombreuses classes qui pourraient paraître superflues ou redondantes, ces dernières sont pourtant essentielles pour permettre d'inverser le flux de dépendance de l'extérieur vers l'intérieur.

Cette proposition est également un moyen de préparer l'avenir, votre logique métier et vos détails d'implémentation évoluent à des rythmes différents, dans quelques mois, il sera probablement nécessaire de mettre à jour le package et de supporter des évolutions ou des changements importants de ce dernier.

Votre logique métier, elle, évoluera généralement à un rythme plus lent et mettra du temps à changer, qu'importe la solution technique que vous allez implementer, vous voudrez toujours récupérer et sauvegarder des feeds pendant plusieurs années.

My general strategy is to go from concrete to abstract ~ Kent Beck

Cette différence de rythme devrait, à elle seule, vous pousser à bien étudier la séparation nécessaire entre votre logique métier et les détails d'implémentation de votre application.

Conclusion

Toutes les architectures que nous vous avons présentées dans cet article sont viables et fonctionnelles, leur pertinence dépendra uniquement du besoin en qualité et en flexibilité de votre application.

N'adoptez pas une approche simplement parce qu'elle vous semble attrayante, mais parce qu'elle est nécessaire.

Une inversion totale des dépendances est certes plus coûteuse en temps et nécessite davantage de classes, mais elle assure de protéger le cœur de votre application des péripéties du monde extérieur.

Ce coeur, votre logique métier, est la véritable richesse de votre application.

L'élément clé d'une application ne réside pas dans ses implémentations, les cas d'utilisation d'une application sont l'élément central. ~ Robert C. Martin

Le niveau de qualité à apporter devrait toujours être jugé en fonction de la criticité de votre application : doit-elle accueillir une grande richesse fonctionnelle ? Est-elle destinée à vivre plusieurs années et à accueillir régulièrement des changements ?

Il sera nécessaire de pouvoir répondre à ces différentes questions afin de déterminer correctement le niveau de qualité à atteindre et la bonne séparation à viser.

Source : https://github.com/laravel-fr/support-dependency-inversion-principle
Mathieu De Gracia avatar
Mathieu De Gracia
Des fois, mon chat code à ma place 🐱

A lire

Autres articles de la même catégorie