Vous souhaitez nous soutenir ? Devenez sponsor de l'association sur notre page Github
Opinions

Clean Architecture et Laravel

Mathieu De Gracia avatar
Publié le 25 avril 2025
Couverture de l'article Clean Architecture et Laravel

L'histoire de cet article commence fin 2023.

Je suis lead développeur depuis maintenant sept ans à travers trois entreprises différentes. J'ai eu l'occasion de côtoyer une bonne trentaine d'applications PHP en production, principalement en Laravel, de toute taille et envergure, allant de quelques milliers de lignes à plusieurs centaines de milliers.

Toutes ces applications partageaient un point commun : leur maintenabilité nous posait problème. Et ce, même pour les plus récentes, âgées seulement de quelques années et déjà sources de frustration pour la plupart.

La spaghettisation du code, des métriques de qualité en berne, des temps de développement anormalement longs et laborieux, des régressions en cascade et, le pire de tout, une crainte croissante dans notre capacité à maintenir certains composants sans provoquer de nouvelles anomalies.

Ces applications avaient une caractéristique commune, elles se contentaient de suivre l'organisation par défaut des dossiers de Laravel : un dossier Controller, un dossier Model, un dossier Job ... Cela nous paraissait naturel, car après tout, le framework était la pierre angulaire de nos applications.

Pour autant, Laravel n'est pas une architecture, c'est un framework.

Le découpage par défaut que Laravel propose est une séparation technique des préoccupations et non une architecture, car l'architecture se concentre avant tout sur les fonctionnalités, pas sur la technique.

Ne laissez jamais un framework vous imposer sa manière de faire car ce dernier n’a qu’une vague idée de ce que vous souhaitez accomplir.

Ces réflexions paraîtront évidentes à certains et sont théorisées depuis des décennies par de nombreux acteurs de notre profession comme Robert C. Martin, Martin Fowler, Kent Beck et bien d'autres encore. Pourtant, dans notre écosystème, et plus particulièrement dans Laravel, elles restent encore très peu répandues.

Cet article partage le retour d'expérience d'une équipe de 9 développeurs qui, pendant plus d'une année, ont collectivement cherché à réduire leur dette technique et à repenser différemment la conception de leur application afin d'envisager l'avenir plus sereinement.

If you think good architecture is expensive, try bad architecture - Brian Foote

C'est quoi une architecture ?

TLDR : L'architecture est l’art de structurer une application autour de ses besoins métier, en séparant clairement le "quoi" du "comment" pour en faciliter l’évolution, la compréhension et la maintenance

L’architecture est la structure organisationnelle fondamentale de votre application, elle s'exprime en termes de couches, de relations entre les couches, et des principes qui gouvernent leur conception et leur évolution.

Une architecture n’est pas un simple assemblage anarchique de dossiers répondant à des impératifs techniques, au contraire, elle doit mettre en avant les fonctionnalités et rendre explicite ce que fait l’application et comment elle le fait.

Une bonne architecture isole le changement pour éviter qu'il ne se propage à travers l'application, elle regroupe ce qui évolue au même rythme et pour les mêmes raisons.

Good architecture makes the system easy to understand, easy to develop, easy to maintain, and easy to deploy - Robert C. Martin

Une bonne architecture est guidée par le besoin métier, et non dictée par la technologie. Cela signifie que l'on doit toujours distinguer ce que l’on veut faire ("le quoi") de la manière dont on va le faire ("le comment").

Prenons un exemple pour illustrer cette définition, imaginons que votre application ait besoin d'envoyer des emails à ses utilisateurs : c'est une fonctionnalité métier. En revanche, l'implémentation d'un mécanisme d'envoi d'email relève d'une préoccupation technique.

C’est précisément cette séparation, entre intention fonctionnelle et mise en œuvre technique, qui permet de construire des architectures souples et durables, tout en évitant de s’enfermer prématurément dans des solutions techniques limitantes.

La Clean Architecture

TLDR : la Clean Architecture vise à structurer l’application autour de règles de séparation et d’indépendance pour favoriser testabilité, évolutivité et la résilience au changement.

Proposée par Robert C. Martin dans son livre Clean Architecture, cette approche s’inspire de plusieurs modèles existants, comme l'architecture Hexagonale, l'Onion Architecture ou bien encore le Screaming Architecture.

L'origine de la Clean Architecture proposé par Robert C. Martin

La Clean Architecture encourage deux choses fondamentales : la testabilité et la mise en pratique des principes SOLID.

Elle repose sur une séparation claire des préoccupations en plusieurs couches distinctes où chaque type de logique est regroupé et isolé des autres, les interactions entre ces couches suivent un certain nombre de règles précises que nous allons explorer plus en détail.

Tout d'abord, la règle des dépendances : il ne peut exister de dépendances cycliques entre deux couches. Une dépendance va toujours d’une couche A vers une couche B, mais jamais dans les deux sens.

Ensuite, l’indépendance des couches : les dépendances doivent pointer vers les couches supérieures et non vers les couches inférieures, conformément au principe d’inversion des dépendances.

Les couches supérieures sont celles contenant la logique de haut niveau de votre application, la logique essentielle de votre entreprise, tandis que les couches inférieures sont celles contenant la logique de plus bas niveau, la logique technique.

Ces règles favorisent le découplage entre vos composants et permettent de tester chaque partie du système de manière approfondie et indépendante, sans dépendre d’un contexte d’exécution spécifique.

Nous aborderons plus en détail les couches dans le chapitre suivant.

Les données circulant entre les couches sont généralement des DTOs sans logique métier. De plus, ces DTOs ne peuvent traverser plusieurs couches successivement sans être transformés.

La Clean Architecture prône également une certaine indépendance vis-à-vis du monde extérieur qu'il s'agisse de la base de données, des API ou même du framework, certaines couches seront plus susceptibles de connaître ces acteurs que d'autres.

Plus une couche est de haut niveau et moins elle connait le monde extérieur.

Il existe encore un certain nombre d'autres règles que nous n'aborderons pas forcément dans cet article. En mettant en place cette architecture dans nos applications existantes, l'équipe technique à dû faire un certain nombre de concessions que nous aborderons dans un chapitre précis.

Modifier l'architecture d'une application existante est une tâche ardue et potentiellement chronophage et notre compréhension de la Clean Architecture a également évolué au fil du temps.

Nous avons cependant décidé de suivre les règles précédentes et de mettre en place une architecture qui nous semble plus adaptée à nos besoins et aux contraintes de notre legacy.

Ces règles peuvent paraître complexes, voire intimidantes (et elles le sont parfois), mais gardez en tête que l'architecture n'insiste jamais sur la perfection, seulement sur votre capacité à faire de votre mieux.

C’est pourquoi une implémentation, même partielle, de ces différentes règles sera toujours bénéfique pour votre code ainsi qu'une base propices aux évolutions futures.

Les différents couches

TLDR : La Clean Architecture organise le code en couches aux responsabilités bien définies, de plus en plus abstraites à mesure qu’on se rapproche du métier, afin de préserver l’indépendance et la lisibilité du code.

La Clean Architecture cherche avant tout à isoler les différents types de logiques dans un ensemble de couches distinctes, le nombre de ces couches est variable et dépendra de vos enjeux fonctionnels.

Ces couches sont la representation des niveaux d'abstraction présent dans votre code, plus une couche est proche des interactions utilisateur et des contraintes techniques, plus elle est concrète et de bas niveau. Tandis que chaque couche supérieure, s'approchant progressivement du métier de votre entreprise, sera de plus en plus abstraite et de haut niveau.

Les représentations schématiques habituelles, comme celles présentées si dessous, présentent habituellement quatre couches : domaine, applicative, infrastructure et presentation.

Ces couches sont à titre indicatif et ne doivent pas être considérées comme strictement obligatoires, seules les différentes règles que nous avons vues plus haut sont strictement nécessaires.

Essayons de définir simplement ces différentes couches :

Le code présent dans la couche de presentation est celui qui est directement exposé à l'utilisateur. Il s'agit généralement des composants permettant à la fois d'acceder à l'application (controller, commande ...) et de sortir de l'application (affichage d'une page, redirection ...).

Le code présent dans la couche infrastructure est celui qui gère les interactions avec les dépendances extérieures, telles que la base de données, les API, la gestion des fichiers ... Cette couche implémente également les interfaces définies dans les couches supérieures. Elle doit rester interchangeable afin de permettre une évolution technologique sans impacter le cœur de votre application.

Le code présent dans la couche application contient les cas d'utilisation de votre application. Il sert d'intermediaire entre la logique métier (couche domain) et les interactions avec l'utilisateur et l’extérieur (couches infrastructure et presenter). Cette couche contient les services applicatifs, les actions ainsi que leurs DTOs et les validations. Elle ne contient aucune logique métier propre mais assure l'orchestration des composants de la couche domain.

Le code présent dans la couche domain encapsule les règles métier fondamentales de l'application. Il définit les entités et les comportements propres au domaine fonctionnel, indépendamment de toute technologie ou infrastructure sous-jacente. Cette couche incarne l’essence même de votre métier et doit s'assurer de la cohérence des règles applicatives de votre système.

Rappelons que ce nombre de 4 couches n'est en aucun cas absolu ou définitif et correspond avant tout à un cloisonnement logique des responsabilités : vous pourriez en créer davantage ou, au contraire, en supprimer certaines.

Qu'importe leur nombre, l'essentiel sera toujours de respecter les règles définissant les interactions entre ces couches que nous avons vu précédemment.

L'appréciation de ces différentes couches est parfois nébuleuse, essayons d'y voir plus clair avec un simple exemple.

Cas concret

Pour illustrer les couches et les différentes formes de logique, nous allons imaginer le contrôleur suivant permettant de créer une commande pour un produit :

1class OrderController extends Controller
2{
3 public function create(Request $request)
4 {
5 $product = Product::find($validated['product_id']);
6 
7 $totalPrice = $product->price * $validated['quantity'];
8 
9 $totalPrice = totalPrice * ($validated['percentage'] / 100);
10 
11 $totalPrice *= 1.2;
12 
13 $order = new Order();
14 $order->productId = $validated['product_id'];
15 $order->quantity = $validated['quantity'];
16 $order->totalPrice = $totalPrice;
17 $order->save();
18 
19 return response()->json([
20 'Order created successfully with a total amount of ' . $totalPrice
21 ], 201);
22 }
23}

Ce contrôleur est on ne peut plus basique et vous l'avez probablement déjà rencontré par le passé. Dans le cas présent, les différents types de logiques y sont tous mélangés.

Le contrôleur possède à la fois la logique métier, la logique applicative, la logique de présentation, la logique d'infrastructure ... Cette trop grande promiscuité des logiques rend le code difficilement maintenable et chaque brique fonctionnelle impossible à tester indépendamment.

Ex: Il est impossible de tester l'insertion en base de données sans passer par le contrôleur.

La logique applicative et la logique business se retrouvent également totalement empêtrées dans des impératifs techniques, comme la gestion des requêtes HTTP ou celle de la base de données, la rendant totalement dépendante d'un contexte d'utilisation spécifique.

Ce code, bien que fonctionnel, est donc difficilement maintenable et testable.

Voyons désormais comment nous pourrions redécouper ce code en plusieurs couches distinctes en suivant les règles de la Clean Architecture que nous avons définies plus haut.

La couche de présentation

Commençons par le composant le plus simple, le controller : sa seule et unique responsabilité sera de recevoir la requête HTTP et de transmettre les informations necessaires au bon fonctionnement de l'action.

Le controller n'implémente ici aucune logique particulière : son rôle se limite à instancier les classes appropriées et à les mettre en relation, en tenant compte du contexte d'exécution, pour permettre le bon déroulement de l'action.

1class OrderController extends Controller
2{
3 public function __construct(
4 private CreateOrderAction $action,
5 private CreateOrderPresenter $presenter,
6 ) {}
7 
8 public function create(Request $request)
9 {
10 $output = $this->action->handle(new Input(
11 productId: $request->input('product_id'),
12 quantity: $request->input('quantity'),
13 discount: $request->input('discount'),
14 ));
15 
16 return $this->presenter->present($output);
17 }
18}

Dans un second temps, le controller recevra la réponse de l'action et la transmettra directement au presenter afin de générer la réponse HTTP.

Le presenter est à la fois un composant simple et précieux car en isolant toute la logique de présentation du contrôleur et de l'action, il permet de tester facilement le comportement de votre application sans se soucier de son contexte d'utilisation.

1class CreateOrderPresenter
2{
3 public function present(Output $output): JsonResponse
4 {
5 $message = 'Order created successfully with a total amount of ' . $output->totalPrice;
6 
7 return response()->json([$message], 201);
8 }
9}

Un presenter permet de s'assurer que, dans une situation donnée, l'application réagit d'une maniere attendu. Ainsi, vous pouvez tester unitairement le presenter sans même avoir besoin de créer une requête HTTP ni de mocker de nombreux autres composants.

La couche application

La couche applicative contient les actions de notre application, elle est chargée d'orchestrer les différentes étapes nécessaires à la réalisation d'une fonctionnalité.

L'action a eu de nombreux noms au fil des années : Action, UseCase, Interactor ...

Une action se lit aisément en diagonale car elle décrit ce qui doit être fait et non comment le faire : Le code d'une action est donc généralement épuré, verbeux et possède peu de complexité logique.

1class CreateOrderAction
2{
3 public function __construct(
4 private OrderRepository $repository,
5 ) {}
6 
7 public function handle(Input $input): Output
8 {
9 $order = $this->repository->initiateOrder($input->productId);
10 
11 $order->defineQuantity($input->quantity);
12 
13 $order->calculateTotalPrice();
14 
15 $order->applyCustomerDiscount($input->discount);
16 
17 $order->addApplicableTaxes();
18 
19 $this->repository->commitOrder($order);
20 
21 return new Output($order->totalPrice);
22 }
23}

L'action manipulera le plus souvent des services et des interfaces provenant de sa propre couche mais également des composants provenant de sa couche supérieure : la couche domaine.

Il est essentiel que les composants entrant et sortant d'une action soient des DTOs, c'est-à-dire des objets simples et sans logique métier (Input et Output). Ces DTOs permettent de totalement isoler l'action des dépendances inférieures provenant, dans notre cas, du contrôleur.

Ces DTOs agissent comme un pattern d'anti corruption, graces à eux, notre action n'a aucune connaissance de la requête HTTP ou de la réponse JSON ce qui la rend facilement testable et totalement réutilisable dans d'autres contextes d'exécution.

L'action conduit la danse des entities - Robert C. Martin

La couche domaine

La couche domaine contient principalement les entités et les services de haut niveau de votre application. Ces entités sont des objets qui encapsulent à la fois les données et les comportements spécifiques au domaine indépendamment des technologies ou infrastructures sous-jacentes.

Les entités de la couche domaine représentent l'essence même de votre application et sont les seuls à véritablement contenir de la logique métier.

1class Order
2{
3 private bool $taxesApplied = false;
4 
5 public function __construct(
6 public int $price,
7 public int $productId,
8 public int $quantity,
9 public int $totalPrice,
10 ) {}
11 
12 public function defineQuantity(int $quantity): void
13 {
14 $this->quantity = $quantity;
15 }
16 
17 public function calculateTotalPrice(): void
18 {
19 $this->totalPrice = $this->price * $this->quantity;
20 }
21 
22 public function applyCustomerDiscount(float $percentage): void
23 {
24 $this->totalPrice -= $this->totalPrice * ($percentage / 100);
25 }
26 
27 public function addApplicableTaxes(): void
28 {
29 if ($this->taxesApplied) {
30 throw new LogicException("Taxes have already been applied.");
31 }
32 
33 $this->totalPrice = $this->totalPrice * 1.2;
34 
35 $this->taxesApplied = true;
36 }
37}

Dans cet exemple, l'entité Order regroupe l'ensemble de la logique métier relative à la gestion d'une commande. Toutefois, ces préoccupations auraient pu être réparties de manière plus horizontale à travers plusieurs services afin de limiter les responsabilités de chaque composant.

La couche domaine est la couche supérieure de votre application et tous les composants qui s'y trouvent ne dépendent que d'eux-mêmes. Il ne peut y avoir aucune dépendance avec les composants des couches inférieures.

Cette isolation est essentielle car en étant découplées de toute dépendance technologique, d'infrastructure ou de présentation, ces entités peuvent être testées de manière indépendante et réutilisées dans tous les contextes possibles.

La couche infrastructure

La couche d’infrastructure est triviale et contient le code de plus bas niveau de votre application. C’est la couche la plus technique : vous y trouverez le code qui fait le pont avec les dépendances extérieures, comme les API, les bases de données ou encore les systèmes de fichiers.

1class OrderRepositoryInDatabase implements OrderRepository
2{
3 public function __construct(
4 private Database $database,
5 ) {}
6 
7 public function initiateOrder(int $id): Order
8 {
9 $product = $this->database->table('products')->find($id);
10 
11 throw_unless($product, ProductNotFoundException::class);
12 
13 return new Order(
14 productId: $product->productId,
15 price: $product->price,
16 );
17 }
18 
19 public function commitOrder(Order $order): void
20 {
21 $this->database->table('orders')->insert([
22 'product_id' => $order->productId,
23 'quantity' => $order->quantity,
24 'total_price' => $order->totalPrice,
25 ]);
26 }
27}

Contrairement à cet exemple, simplifié pour le cadre de l'article, il sera préférable de garder les interfaces les plus atomiques possibles, le fameux I du principe SOLID, et donc de disposer de plusieurs composants plutôt qu'une seule classe repository.

Bien souvent, l’infrastructure sera un détail de votre application : le code qui s’y trouve n’a pas de valeur métier spécifique ou différenciante pour votre entreprise. Elle se contente d’implémenter les contrats définis par les couches supérieures, sans y introduire de nouvelle logique métier.

Une logique d’infrastructure peut toutefois y être présente, comme la gestion des requêtes HTTP, de la persistance ou de la configuration, mais elle ne doit jamais impacter les choix de conception des autres couches : La couche infrastructure dépend uniquement des autres couches et ne doit pas les influencer.

Nous y voilà ! Nous avons finalement découpé notre contrôleur en un ensemble de classes distinctes en isolant chaque type de logique dans sa propre couche tout en respectant les différentes règles que nous nous étions fixées.

Rappelez-vous, les règles en question étaient :

Si nous détaillons les différentes couches, ainsi que les relations existantes entre les classes, nous obtenons le schema suivant :

Nous avons développé un outil en ligne pour dessiner facilement et visualiser la stabilité d'une architecture : dependency-drawing

L’exemple initial, relativement simple, visait à mieux comprendre les différents types de logiques et les dynamiques existant entre les couches. Cependant, ce découpage fonctionnera tout aussi bien avec un périmètre plus vaste de composants.

Si vous êtes attentif, vous aurez remarquer qu'un mot est apparu à plusieurs reprises dans la définition de ces couches : la testabilité.

Ce découpage strict visait avant tout à contrôler le flux d’informations et de dépendances : chaque couche ne dépend que d’un nombre restreint d’autres couches. Cet effort de conception renforce leur testabilité et leur maintenabilité, en limitant les liens avec le reste de l’application.

La mise en pratique de ces règles n'a été possible qu'en mettant en œuvre plusieurs stratégies techniques que nous allons maintenant explorer.

L'inversion des dépendances

TLDR : L'inversion des dépendances permet aux couches supérieures d'exprimer leurs besoins sans dépendre des détails techniques des couches inférieures, assurant ainsi un découplage fort et une meilleure testabilité.

Inévitablement, les couches de votre application doivent interagir entre elles, et tous nos efforts de conception consistent justement à maîtriser ces interactions.

Dans notre précédent exemple, le composant CreateOrderAction avait besoin de manipuler des Order via une instance de OrderRepository.

Cependant, cette dépendance provient d'une couche inférieure (la couche infrastructure), rappelez-vous, les dépendances doivent toujours pointer vers les couches supérieures, jamais vers les couches inférieures, et aucune dépendance cyclique n'est permise.

Pour remédier à cela, nous allons introduire un contrat dans la couche applicative qui sera ensuite implémenté dans la couche infrastructure.

Cette inversion des dépendances est essentielle pour reprendre le contrôle des dépendances et d'éviter que notre action soit liée à une implémentation technique spécifique.

Fondamentalement, notre action n’a pas besoin d’une base de données, ce dont elle a réellement besoin ... c’est de sauvegarder un order !

L’interface joue ici un rôle clé car elle permet d’exprimer un besoin fonctionnel que la couche infrastructure devra implémenter, dans notre cas, via la base de données : nous venons ainsi de découpler le "quoi" (sauvegarder un order) de son "comment" (l’enregistrer en base de données).

Ce pattern d'inversion des dépendances s'applique systématiquement lorsque qu'une couche supérieure doit communiquer avec une couche inférieure, il peut également être utilisé à l'intérieur d'une même couche afin de mieux séparer les préoccupations.

Prenons l'exemple de la couche de présentation suivante : nous avons un PrintPresenter chargé d'imprimer une commande, cependant, nous ne voulons pas que ce Presenter dépende directement d'une technologie d'impression spécifique.

En déclarant un simple contrat, le presenter peut exprimer ses besoins fonctionnels que les différentes implémentations devront satisfaire.

Si vous souhaitez en savoir plus sur l'inversion de dépendances, nous avons traité en profondeur cette notion dans cet article.

Le passage de frontières

TLDR : Pour éviter un couplage insidieux et des effets de bord en cascade, chaque couche doit transformer les objets qu’elle reçoit et exposer ses propres DTOs adaptés à son contexte.

Tout comme les dépendances, l’information circule entre les couches de votre application et doit respecter les mêmes règles que celles vues précédemment.

Nous avons vu plus tôt qu'une bonne architecture doit limiter la propagation des changements, cette propagation est souvent causée par des dépendances mal gérées mais aussi par un mauvais cloisonnement de l'information.

L'information peut être source de couplage et fragiliser sournoisement votre conception ... sans même que vous vous en rendiez compte.

Imaginons les deux composants suivants : le composant A communique avec le composant B qui détient des DTOs.

En manipulant des DTOs définis par le composant B, le composant A devient dépendant de ce dernier. Toute modification de composant B impactera donc le composant A.

Maintenant, imaginons qu'un nouveau composant X interagisse avec le composant A, qui lui retourne directement les DTOs provenant de B.

Cette situation est problématique car elle fragilise notre code : une modification du composant B pourrait affecter le composant X alors même que ce dernier ne communique pas directement avec lui : le couplage des composants explose.

Cette problématique est fréquente car nous prêtons rarement attention à la portée des objets que nous manipulons. C’est particulièrement vrai avec Eloquent, que l’on fait souvent circuler librement dans toute l’application, sans réelle restriction, alors qu’il s’agit d’un objet lourd, intimement lié à la base de données et doté d’un cycle de vie complexe.

Laisser l'information circuler sans contrôle, et traverser plusieurs couches successives, sera souvent générateur de bugs et compliquera grandement la maintenabilité de votre application.

Une simple modification quelque part ... entraînera des modifications en cascade ailleurs, vous forçant à modifier un nombre conséquent de composants très éloignés de la modification initiale, cette situation a même un nom : le Shotgun surgery !

Pour éviter cela, il faut s'assurer que l'information ne circule qu'au sein des limites strictes de la couche qui la gère, ou tout du moins dans un nombre restreint de composants.

Dans notre exemple précédent, c'est au composant A de transformer les DTOs avant de les transmettre au composant X.

Cette strategie pourrait nécessiter la création d'un nouveau DTO, proche du précédent mais adapté à un nouveau contexte, il ne faut pas hésiter à créer ces objets car même semblable toutes les duplications de code ne se valent pas.

Une duplication dangereuse est celle qui oblige à modifier plusieurs endroits dès qu'un changement survient, à l’inverse, des objets similaires mais évoluant pour des raisons différentes et à des rythmes distincts ne constituent pas une duplication problématique.

Une duplication est également dangereuse quand elle concerne de la logique métier. Ici, nous parlons de DTOs : des objets simples, sans logique, faits pour transporter des données, leur duplication est donc parfaitement bénigne.

Vous pourriez penser que ces duplications alourdissent le code, notamment lorsque les objets sont très similaires, mais sont-ils vraiment identiques ?

Prenons l'exemple réel d'un outil d'analyse de code qui récupère une liste de fichiers, les analyse, puis retourne les résultats à l'utilisateur.

Cette application repose sur trois composants :

Chaque composant retourne un DTO correspondant à un "fichier" qui contient certaines informations en commun.

Nous pourrions être tentés de retourner le même objet d'un composant à l'autre, après tout, les propriétés et la portée des informations de ce DTO nous semblent identiques au premier abord : cela reste un fichier ?

Avec ce découpage, nous nous retrouverions dans cette situation problématique : le composant Reporting deviendrait dépendant du composant Discovery bien qu'il n'interagisse pas avec lui. Reporting pourrait ainsi subir les modifications et les effets de bord d'un composant qu'il ne connait pas.

Sans frontières franches, l'instabilité s'installera progressivement entre les composants de votre application, vos dépendances deviendront fragiles et vous rencontrerez régulièrement des bugs étranges et des comportements inattendus provenant de composants éloignés du vôtre :

Fragile code breaks in bizarre and strange ways - Robert C. Martin

Pourtant, en y regardant de plus près, chaque couche y apporte une nuance justifiant la création d’un objet spécifique.

Le composant Discovery renvoie un DTO représentant un fichier trouvé sur le disque, avec des propriétés telles que son nom et son chemin absolu.

En revanche, le composant Analysis construit un DTO représentant un fichier analysé, contenant des informations supplémentaires comme des métriques et des analyses de code. Certaines propriétés initiales, comme le chemin absolu, peuvent alors devenir inutiles.

Comme vous pouvez le voir, bien que ces DTO partagent certaines propriétés en commun, leur rôle diffère fondamentalement d'un composant à l'autre. Cette seule distinction justifie leur création afin de préserver des frontières franches entre les composants de votre application.

Architecture is the art of drawing boundaries. - Robert C. Martin

Nos tentatives d'implémentation

Vous en savez maintenant d'avantage sur notre vision de la Clean Architecture, nous allons maintenant décrire les différentes tentatives d'implémentation que nous avons eu l'occasion de mettre en œuvre.

Première implémentation

Dans cette première tentative, nous nous sommes principalement concentrés sur l’action sans chercher à totalement isoler les différentes couches.

Lors de cette refactorisation, tout le code initialement présent dans le contrôleur a été redécoupé en actions et presenters, chacun ayant une responsabilité distincte : la logique métier pour les actions et la logique de présentation pour les presenters.

Le contrôleur est désormais extrêmement simple et se limite à transmettre les informations nécessaires à l’action et à retourner la réponse du presenter.

Par souci de simplicité, nous avons placé ces nouvelles classes aussi près que possible des contrôleurs initiaux afin de ne pas perturber l'organisation existante des dossiers.

L'organisation des dossiers est désormais la suivante :

1App/
2└── Http/
3 └── Controller/
4 ├── OrderController
5 └── Order/
6 ├── Store/
7 │ ├── Action.php
8 │ ├── Presenter.php
9 │ ├── Input.php
10 │ └── Output.php
11 └── Delete/
12 ├── Action.php
13 ├── Presenter.php
14 ├── Input.php
15 └── Output.php

Actuellement, les composants interagissent entre eux de la manière suivante :

Le composant Order contient les différentes actions et presenters, et communique avec le code existant (le legacy).

Ce découpage de l'existant, bien que simple à réaliser, nous a permis d’alléger considérablement les responsabilités du contrôleur initial et de le rendre beaucoup plus concis.

Désormais, tout le code métier est contenu dans des composants dédiés, et la logique de présentation, déléguée au presenter, est bien séparée du contrôleur et de l'action.

Bien que ce découpage soit relativement simple, il offre un meilleur contrôle des dépendances, une meilleure lisibilité du code et, enfin, une meilleure testabilité des différentes logiques.

Pour autant, comme nous pouvons le constater sur le schéma, le legacy est toujours présent et notre action en est directement dépendante, transgressant l'une de nos règles étiquetées plus haut : les dépendances doivent pointer vers les couches internes, jamais vers les couches externes.

Rappelez-vous que le travail d'architecture consistera souvent à faire des compromis et qu'un legacy sera toujours une contrainte forte, votre architecture n'a pas besoin d'être parfaite pour explorer des solutions, plusieurs stratégies auraient pu être mises en place pour résoudre ce problème.

Tout d'abord, nous aurions pu créer des proxies afin de servir de passerelle entre le legacy et les nouvelles classes, seules ces classes proxies auraient été dépendantes du legacy.

Cette solution ne corrigerait pas totalement le problème des dépendances mais en limiterait la portée : seules les proxies seraient affectées par une modification ou un effet de bord du legacy.

Une solution plus complexe, mais également plus robuste, consisterait à créer des interfaces pour limiter les dépendances, conformément au principe d’inversion des dépendances. Chaque interface représenterait un besoin fonctionnelles déclaré par l'action que le legacy devrait satisfaire.

Cette approche permettrait de totalement séparer les préoccupations et de limiter au maximum les effets de bord potentiels du legacy sur les nouvelles classes.

Seconde implémentation

Notre seconde implémentation est particulière car elle s'applique à un ensemble de nouvelles fonctionnalités totalement indépendantes de notre legacy.

Chaque fonctionnalité étant également dissociée les une des autres, nous avons pu les développer en parallèle et mettre en place davantage de couches.

1App/Console/Commands/
2└── Delete/
3 ├── Order/
4 │ ├── Presenter/
5 │ │ ├── CliPresenter.php
6 │ │ ├── CliViewModel.php
7 │ │ └── CliView.php
8 │ ├── Application/
9 │ │ ├── UseCase.php
10 │ │ ├── Input.php
11 │ │ └── Output.php
12 │ ├── Domain/
13 │ │ ├── Entities/
14 │ │ ├── Services/
15 │ │ └── Ports/
16 │ └── Infrastructure/
17 │ └── Adapters/
18 └── Product/
19 ├── Presenter/
20 ├── Application/
21 ├── Domain/
22 └── Infrastructure/

Dans cette architecture, le dossier Delete, à la racine de Commands, regroupe les différentes fonctionnalités liées aux suppressions, comme la suppression d'un Order ou d'un Product, etc.

Chaque fonctionnalité est ensuite organisée en couches : Presenter, Application, Domain et Infrastructure.

Au sein d'une fonctionnalité, les relations entre les couches sont rigoureusement respectées :

Chaque couche contient la logique et les composants habituels que nous avons présentés précédemment.

Cette implémentation nous a également permis d'introduire davantage de composants dans la couche de présentation, notamment un ViewModel et un composant View, ces nouveaux composant communiquent de la maniere suivante :

Avec cette approche, la responsabilité du Presenter se limite à préparer les données nécessaires à l'affichage : il joue ainsi un rôle de passerelle. Préparer les informations et gérer l'affichage sont deux responsabilités distinctes que l'on peut séparer afin de faciliter les tests de ces composants.

1class Presenter
2{
3 public function __construct(
4 private View $view,
5 ) {}
6 
7 public function present(Output $output)
8 {
9 return match ($output->orderDeletionFailed) {
10 true => $this->deletionFailed($output),
11 false => $this->deletionSuccess($output),
12 };
13 }
14 
15 private function deletionFailed(Output $output)
16 {
17 $message = $this->buildDeleteFailureMessage($output);
18 
19 return $this->view->render(new ViewModel(
20 name: 'orders-delete-failed',
21 message: $message,
22 orderId: $output->orderId,
23 statusCode: 400,
24 ));
25 }
26 
27 private function deletionSuccess(Output $output)
28 {
29 return $this->view->render(new ViewModel(
30 name: 'orders-delete',
31 orderName: $output->orderName,
32 orderId: $output->orderId,
33 statusCode: 200,
34 ));
35 }
36 
37 private function buildDeleteFailureMessage(Output $output): string
38 {
39 return "La commande #{$output->orderId} n’a pas pu être supprimée.";
40 }
41}

Ce découpage supplémentaire n'est pas toujours nécessaire, dans certains cas, le Presenter peut parfaitement assumer, à lui seul, plusieurs responsabilités.

En revanche, dès que la complexité augmente, cette séparation devient précieuse car elle permet de tester indépendamment chaque responsabilité (préparer et afficher) qui répondent à des contraintes techniques et fonctionnelles distinctes.

Vous pourrez aussi vérifier, à l'aide d'un test unitaire, que le Presenter réagit d’une certaine manière en fonction des différentes entrées fournies par l’Output sans vous soucier d'une quelconque contraintes d'affichage.

Dans cette seconde implémentation, l'absence de legacy et l'indépendance de chaque fonctionnalité nous ont permis de structurer ces couches sans difficulté particulière.

Toutefois, le code reste encore entièrement regroupé dans le dossier App\Console, ce qui crée un couplage fort entre nos fonctionnalités et un contexte d'exécution spécifique : une commande artisan en CLI.

Si ce découpage convient aux nouvelles fonctionnalités pouvant être isolées du reste de l'application, il atteint vite ses limites dans des cas plus complexes. C'est pour répondre à cette contrainte que nous avons développé une nouvelle approche.

Troisième implémentation

Troisième et dernière tentative d'implémentation, elle intervient dans le contexte d'une refactorisation de certaines briques importantes de l'application. Une partie du legacy sera réécrite mais de nombreux composants existants seront conservés et réutilisés.

Nous avons repris les différentes stratégies mises en place pour les précédentes implémentations mais une séparation bien plus nette entre Laravel et nos nouvelles fonctionnalités a été réfléchie et mise en place.

1App/
2├── Http/
3│ └── Controller/
4│ └── OrderController.php
5└── Features/
6 ├── Order/
7 │ ├── Applications/
8 │ │ ├── Store/
9 │ │ │ ├── Action.php
10 │ │ │ ├── Presenter.php
11 │ │ │ ├── View.php
12 │ │ │ ├── ViewModel.php
13 │ │ │ ├── Input.php
14 │ │ │ ├── Output.php
15 │ │ ├── Delete/
16 │ │ │ ├── Action.php
17 │ │ │ ├── Presenter.php
18 │ │ │ ├── View.php
19 │ │ │ ├── ViewModel.php
20 │ │ │ ├── Input.php
21 │ │ │ ├── Output.php
22 │ │ ├── Ports/
23 │ │ ├── OrderRepositoryInterface.php
24 │ │ ├── OrderServiceInterface.php
25 │ ├── Domain/
26 │ │ ├── Entity.php
27 │ │ ├── Service.php
28 │ ├── Infrastructure/
29 │ ├── OrderInDatabase.php
30 │ ├── OrderService.php

Une particularité cependant : les différents composants de la couche de présentation sont présents dans le dossier Application, au plus proche de l'action.

Nous respectons cependant le sens des dépendances et, à aucun moment, l'action n'a connaissance des composants liés à la présentation. Ce compromis nous a paru intéressant car il permet de réduire le nombre de dossiers tout en conservant une structure claire et un sens des dépendances cohérent.

Un autre avantage de cette approche est de regrouper les différents cas d’utilisation d’un même périmètre fonctionnel dans un seul dossier. Par exemple, le dossier Order dans Features rassemble l’ensemble du code et des fonctionnalités associées à ce périmètre : suppression, ajout d'un order, etc ...

Le code devient plus explicite quant à ses fonctionnalités et les couches Infrastructure et Domain sont présentes au même niveau que la couche Application, qui regroupe les actions. Cette proximité permet de partager un code commun et réutilisable entre les différentes fonctionnalités.

Toutes les fonctionnalités partageant un même périmètre se retrouvent ensemble, évoluant au même rythme, pour les mêmes raisons, et partageant les mêmes composants.

Ce découpage est particulièrement efficace et s'avère pertinent pour la plupart des situations que nous avons rencontrées par la suite.

La tâche la plus ardue sera probablement de définir les périmètres fonctionnels de votre application. Avec du recul, vous réaliserez que celle ci effectue un nombre limité d'actions et que ses fonctionnalités forment des ensembles homogènes.

Votre application passe peut-être des commandes, renouvelle des contrats, permet des exports ...

Prenez le temps d'identifier ces périmètres avant même de commencer à coder car ils constitueront le point de départ de votre architecture et seront les moins susceptibles de changer avec le temps.

Des compromis nécessaires

TLDR : L’essentiel n’est pas de suivre la Clean Architecture à la lettre mais d’en comprendre l’esprit. Même une implémentation partielle apporte déjà un véritable gain qualitatif.

Pour de nombreuses raisons, la Clean Architecture, comme toutes les architectures en couche, est souvent complexe à mettre en œuvre. Il sera souvent judicieux d’en adopter progressivement les principes plutôt que de viser une conformité totale dès le départ.

Nos premières implémentations n'étaient que partielles mais elles nous ont permis de progressivement monter en compétences tout en nous posant de nouvelles questions sur comment architecturer nos projets, la conception d'une application est un processus très itératif : on essaye - on apprend.

L'architecture est l'art de faire des compromis - Martin Fowler

Autorisez-vous à expérimenter et à faire des erreurs car elles seront les bases solides de vos futures architectures. Quand bien même vous déciderez de transgresser certaines règles, le plus important sera toujours de comprendre leur raison d'être.

Une règle ne devient pas caduque quand que vous décidez de l'ignorer, vous aurez beau ne pas découper votre application en couches ... les différents types de logiques continueront d'exister, elles ne disparaîtront dès lors que vous décidez de fermer les yeux.

Transgresser une règle n'est fondamentalement pas grave (et parfois inévitable), mais l’essentiel sera de comprendre les compromis que cela implique et les conséquences que cela entraînera.

Dans le cadre de Laravel, une règle fondamentale de la Clean Architecture sera très difficile à respecter : celle de totalement isoler le framework des couches supérieures.

Laravel ne devrait tout simplement pas exister dans la couche Domaine.

C'est vrai, et nous comprenons l'intention derrière cette règle : le monde extérieur devrait avoir le minimum d'emprise sur les règles métier de notre entreprise.

Dans les faits, il est très difficile de respecter à la lettre cette règle, nous avons ainsi décidé de la transgresser tout en conservant son essence : les couches les plus internes ne doivent pas dépendre de composants de Laravel qui dicteraient une manière d'aborder les problèmes.

Ainsi, nous autorisons dans les couches internes l'utilisation des classes utilitaires de Laravel comme les Collections, les Str, certains helpers, etc ... À aucun moment ces classes ne nous imposeraient une manière de développer, contrairement à des composants plus lourds tels que le client HTTP, ou tout simplement Eloquent.

Le découpage des différents types de logiques cloisonnera naturellement Laravel dans des couches spécifiques, avec du recul, nous observons le schéma suivant :

Les couches externes sont très dépendantes de Laravel, car au plus proche du framework et de l'utilisateur, ce qui nous semblait normal et opportun.

Laravel s’estompe progressivement au fur et à mesure que nous remontons dans notre architecture. Relativement peu présent dans nos actions et la couche applicative, le framework a tendance à totalement s’éclipser dans la couche Domaine, de par la nature du code qui s'y trouve.

Ces concessions nous ont semblé essentielles pour nous permettre d'avancer et d'itérer. Pour autant, elles n'ont pas impacté négativement les métriques de qualité de code que nous allons désormais aborder.

L'analyse des métriques

TLDR : La Clean Architecture n'est pas qu'un concept théorique, ses effets sont mesurables. Son adoption transforme significativement la structure de votre code : moins de couplage, plus de cohésion, et une complexité réduite

Juger la qualité du code est toujours un exercice délicat car cette dernière dépend fortement d'un contexte fonctionnel. Chaque situation impose des priorités différentes et la qualité prend des formes variées selon les objectifs d'une application.

Nous avons abordé à de très nombreuses reprises ces questions : ici, ou , ou encore

Certaines métriques peuvent cependant nous aider à évaluer de manière plus objective la qualité du code. Ces dernières permettent de mesurer des aspects importants tels que la complexité logique, la cohérence, la structure, le couplage des classes, ainsi que l’indice de maintenabilité du code.

Dans notre situation, ces mesures sont particulièrement utiles pour visualiser l'impact des changements architecturaux provenant de la mise en application de la Clean Architecture.

Nous avons effectué de nombreuses analyses tout au long des différentes expérimentations à l'aide d'outils tels que phpmetrics, phplocs ou des outils que nous avons nous-mêmes développés, que vous retrouverez sur ce site : php-quality-tools.com

Voici les résultats de 3 métriques sur l'un de nos projets ayant connu la mise en œuvre de la Clean Architecture :

Couplage des classes

La première métrique, l'efferent coupling, mesure le degré de dépendance d’une classe vis-à-vis des autres. Autrement dit, elle indique combien de classes une classe donnée utilise ou connaît directement.

Avant la mise en place de la Clean Architecture, chaque classe dépendait en moyenne de 9 autres classes. Ce chiffre révèle une forte interdépendance entre les classes rendant le code difficile à maintenir, à tester ou à faire évoluer.

Aujourd’hui, cette moyenne est descendue à 1,85. Cette nette diminution est représentative d'une architecture plus modulaire, où les responsabilités sont mieux isolées et les dépendances davantage réparties de manière horizontale.

Un faible efferent coupling améliore non seulement la maintenabilité et la testabilité du code, mais réduit aussi les effets de bord en cas de modification.

Cohésion des classes

La deuxième métrique, le LCOM (Lack of Cohesion of Methods), mesure la cohésion interne d’une classe en analysant la manière dont ses méthodes interagissent entre elles. Une faible cohésion, c’est-à-dire des méthodes qui n’utilisent pas les mêmes propriétés ou ne s’appellent jamais entre elles, suggère que la classe regroupe plusieurs responsabilités et devrait probablement être redecoupée.

À l’inverse, une forte cohésion indique généralement qu’une classe est bien focalisée sur un rôle unique.

Avant l’adoption de la Clean Architecture, le LCOM moyen atteignait 6,56, révélant des classes aux responsabilités multiples et aux méthodes peu liées entre elles.

Aujourd’hui, cette valeur est tombée à 1,15, ce qui témoigne d’une architecture plus simple et soigné, avec des classes plus cohésif et mieux alignées sur un seul rôle.

Complexité cyclomatique

Notre troisième métrique, la complexité cyclomatique (CCN), mesure le nombre de chemins logiques que peut suivre l’exécution d’une méthode, en fonction des structures de contrôle comme les conditions, boucles, match, etc. Plus cette valeur est élevée et plus le code est difficile à lire, à tester et à maintenir en raison du grand nombre de cas à couvrir.

Avant l’adoption de la Clean Architecture, la complexité moyenne était de 13, rendant certaines portions du code particulièrement difficiles à appréhender. Aujourd’hui, cette complexité est descendue à 2,81, ce qui témoigne d’un code plus lisible, plus linéaire et beaucoup plus facile à maintenir.

En regardant ces trois indicateurs, il est évident que la mise en œuvre de la Clean Architecture a eu un impact profond et bénéfique sur nos projets :

La Clean Architecture ne se limite donc pas à un concept purement théorique, en considérant nos différentes analyses, sa mise en œuvre dans nos projets peut être considérée comme un succès concret et mesurable.

Clean Archi VS MVC

TLDR : Le MVC simpliste de Laravel rassure par sa familiarité, mais seule une architecture en layer comme la Clean Architecture peut réellement structurer votre code, le rendre testable, maintenable et apte à évoluer.

Vous vous demandez peut-être maintenant comment vous lancer dans cette aventure ou tout simplement si cela est une bonne idée ou même nécessaire ? Après tout, le MVC de Laravel est simple et intuitif, pourquoi se lancer dans une architecture plus complexe ?

Il sera difficile d'apporter une réponse définitive à cette question, car la réponse dépendra de votre projet, de la maturité de votre équipe et de vos objectifs.

Cette interrogation, tout à fait légitime, nous permet cependant d'aborder un dernier sujet.

Le MVC n'est pas simple, il est simpliste, et si vous craignez l'overengineering, vous devriez peut-être tout autant vous préoccuper de la sous-conception.

L'apparente simplicité du MVC vient avant tout de sa monotonie. Dans une application Laravel classique, vous retrouvez votre dossier controller, votre dossier service, un dossier model ...

Cette monotonie nous rassure, car aux yeux d'un développeur, elle permet de prendre rapidement ses marques dans un nouveau projet : cette forme d'architecture n'est pas déstabilisante.

Comme nous l'avons dit à plusieurs reprises dans cet article, l'organisation par défaut des dossiers de Laravel repose sur un découpage purement technique : elle ne structure ni les fonctionnalités de l'application, ni le flux d'informations et encore moins ses dépendances, cela n'est pas une véritable architecture.

Une architecture en layer, comme présentée ici avec la Clean Architecture, est par nature intimidante car elle se concentre sur les fonctionnalités métier de l'application.

Ces fonctionnalités vous seront, dans un premier temps, inconnues, imprévisibles, car liées aux métiers d'une application que vous ne connaissez peut-être pas encore ... Il est donc tout à fait normal d'être, dans un premier temps, perdu car l'appréciation de l'architecture passera par votre compréhension du métier de l'application.

Nous oublions souvent que le MVC tel qu'il est implémenté dans Laravel s'éloigne fortement du pattern d'origine, théorisé par Trygve Reenskaug dans les années 70, initialement conçu pour les interfaces graphiques.

Le M de MVC, pour Model, ne correspond en rien à ce que nous appelons un "Model" dans Laravel. Dans l’architecture MVC originelle, le Model représentait le métier de l’application ainsi que sa logique associée, mais n’intégrait nativement aucun mécanisme de persistance, cette responsabilité étant considérée hors de son périmètre.

Dans une approche en Clean Architecture, les Models seront fréquemment des DTOs, représentant un ensemble de données à transmettre entre les couches, sans aucune logique ou notion de persistance.

Par exemple, le ViewModel, présent dans la couche de présentation, est un DTO contenant les informations à transmettre à la vue depuis un controller … Le MVC est donc présent en Clean Architecture !

Toutefois, dans une architecture en couches, le MVC ne représente qu’un petit sous-ensemble de classes au sein d’une architecture plus large et horizontale.

Cette nuance est pourtant souvent négligée et il n’est pas rare de voir des applications Laravel entièrement conçues autour du triptyque Contrôleur-Modèle-Vue, quand bien même le MVC n’a jamais été destiné à être le seul fondement structurant d’une application.

Au final, la seule question légitime sera : à quoi répond la Clean Architecture ?

Le terme a été omniprésent dans tous les chapitres de cet article : la Clean Architecture permet la testabilité, la maintenabilité et favorise la mise en pratique des principes SOLID.

La segmentation du code en couche, la séparation des différentes logiques, l'application des règles stricts sur les dépendances doivent vous permettre de construire une application solide et résistante aux modifications futures.

Est-ce que votre application est importante et aura besoin d'évoluer ? Cette seule et unique question pourra vous guider dans le choix de l'architecture à adopter pour votre application.

Une architecture est mauvaise quand elle démontre son incapacité à évoluer - Robert C. Martin

Conclusion

Nous arrivons à la fin de ce long article concernant notre retour d'expérience sur la Clean Architecture que nous avons démarrée fin 2023.

Pourquoi mes applications devenaient-elles inévitablement obsolètes au bout de quelques années ? Ce questionnement, au cœur d'une remise en question de l’approche traditionnelle de la conception logicielle, a donné naissance à l’un des épisodes les plus excitants de ma carrière.

En tant que développeurs, nous apprenons à coder, rarement à concevoir. Nous maîtrisons nos langages, nos frameworks … mais beaucoup moins les fondations, les principes, les véritables mécaniques de l’architecture logicielle.

There are no easy decisions in software architecture - Neal Ford

Le chemin fut long, parfois difficile, mais également enrichissant et formateur. En tant que Lead, ma responsabilité a été d’accompagner les équipes dans ces réflexions parfois abstraites, parfois dérangeantes, et d’apprendre à naviguer ensemble entre incertitudes et expérimentations.

Peu à peu, couche après couche, nous avons appris à éloigner notre regard du code brut pour mieux percevoir les types de logique, les frontières, les dépendances et enfin les synergies pouvant exister entre les composants d'une application.

Ces réflexions seront coûteuses en temps, surtout au début, il faudra du temps pour assimiler les principes et comprendre l’esprit derrière la Clean Architecture. Mais une fois cette base assimilée les équipes pourront enfin se concentrer sur ce qui a du sens : la valeur métier, le couplage, l’évolutivité.

Commencez petit, sans crainte des erreurs, sans chercher à tout refactoriser … même un périmètre réduit, même une seule couche bien pensée peut devenir un terrain d’apprentissage précieux.

Et même si, au départ, le gain semble modeste, ces expérimentations s’additionnent progressivement et changent votre manière d'aborder la conception : elles transforment vos habitudes et apportent de la hauteur à vos réflexions sur l'architecture.

Si vous souhaitez approfondir ces notions, voici quelques lectures essentielles qui nous ont guidés durant cette année :

Vous y trouverez tous les détails importants sur les concepts abordés dans cet article et bien d'autres choses encore qui changeront à coup sûr votre vision des architectures logicielles.

Architecture is not about tools and building materials, architecture is about usage - Robert C. Martin


Pour finir sur un message plus personnel, cet article a été le plus long et complexe que j’aurai eu l’occasion de rédiger. Il représente, au moment où j’écris ces dernières lignes, un travail débuté il y a maintenant plusieurs années et rendu possible par le talent et l’investissement des développeurs constituant mes équipes. Il n’aurait jamais été possible sans leur confiance et leur capacité à s’engager dans des voies sinueuses, et à être à l’aise avec leur propre incertitude.

Merci à eux, et à vous, pour votre lecture.

A lire

Autres articles de la même catégorie