Introduction

Pourquoi ce tutoriel ?

Alors que la Clean Architecture a plus le vent en poupe que jamais ces dernières années, j'ai moi-même passé beaucoup de temps à chercher des ressources en PHP, voire en Symfony (mon Framework de coeur), pour découvrir plus facilement cette philosophie.

Disclaimer

Oui, la Clean regroupe des concepts qui permettent justement de s'affranchir du Framework, afin d'en être le plus agnostique possible.
Mais je suis persuadé qu'à l'apprentissage, rien de mieux que des exemples avec lesquels nous sommes familiers, pour correctement assimiler ces principes.

Et voilà la raison pour laquelle j'écris ce tutoriel aujourd'hui, un tutoriel que j'aurais aimé trouver quand j'ai commencé à apprendre les fondements de la Clean, un tutoriel qui:

  • me donne des exemples sur un langage et un framework que je connais
  • m'accompagne avec un mini-projet à transfomer étape par étape vers une approche "clean"
  • utilise l'état de l'art de mon langage (ici, PHP 8.4)
  • propose une approche décomplexée de la Clean, qui comprend que la réalité et la complexité d'un projet demande parfois de faire des concessions ou d'adapter la Règle à son besoin.

Si nous sommes alignés sur ces points, et que c'est aussi ce que vous recherchez, alors vous êtes au bon endroit !

Ce que n'est pas la Clean Architecture

Avant de rentrer dans le vif du sujet, j'aimerais préciser le périmètre de mon approche de la Clean Architecture, en éliminant ce qui pour moi ne rentre pas dans la philosophie de la Clean.

À mon sens, la Clean Archi, ce n'est pas:

  • Des dogmes à respecter absolument sans se poser de questions
  • Un cadre imposant un nommage particulier pour nos classes, fichiers, et dossiers
  • Une formule magique dénuée de défauts
  • Du clean code (je peux faire de la clean en codant comme un cochon)
  • Des bonnes pratiques universelles

Il peut exister autant d'applications de la Clean Architecture qu'il existe d'équipes de développeurs. Mais le point commun entre ces équipes sera le suivant: un système de couches qui protègent le métier et ses objets en son centre, découplés des implémentations techniques qui gravitent autour.

Je vais malgré tout essayer de donner une vision la plus universelle possible de la Clean, qui corresponde le plus possible à la réalité. Mais si certains de mes choix ne vous plaisent pas, n'oubliez pas que vous êtes libres d'adapter mes propositions à votre vision.

Tip

Souvent, on reste très attachés à la règle et aux dogmes quand on débute sur un sujet.
Mais c'est en prenant de l'expérience et de la confiance que l'on peut intelligement tordre cette règle (sans jamais la transgresser) pour l'adapter à nos préférences !

Prérequis

Pour suivre ce tutoriel, il est préférable a minima d'avoir déjà entendu parler de la Clean Architecture, et de s'y être un peu intéressé. Au cours des différentes étapes, j'essaierai malgré tout d'expliquer tous mes choix, et les concepts associés.

Sinon, ce tutoriel s'adresse en particulier aux développeurs PHP qui ont déjà travaillé avec Symfony, mais le code présenté restera très simple à comprendre.

On est tout bon ? Alors c'est parti !

Présentation du projet

Rappels Clean Architecture

Avant de présenter le projet, voici une liste de concepts dont vous pourrez vous servir comme d'un pense-bête tout au long de ce tutoriel.

  • Domain: Le coeur de la logique métier, indépendant de tout framework, base de donnée et librairie externe. Il contient les objets et les règles métier, ainsi que des contrats d'interface.
  • Application: La couche qui orchestre les cas d'usages métier, en coordonnant le Domain, ses règles et ses interfaces. Ici on cherche à accomplir des UseCase métier, en se servant des règles métier du Domain.
  • Infrastructure: La couche la plus externe, qui contient le framework, les librairies et toutes les implémentations techniques concrètes (base de donnée, service d'email, API externes, ...), selon les contrats d'Interface du Domain. On l'appellera parfois simplement Infra pour gagner du temps !

Le Domain est donc le coeur de votre application, il contient tous les objets métier & les règles fonctionnelles.

Souvent on peut voir une autre couche dans les projets Clean, la couche Presentation. C'est elle qui s'occupe de :

  • Récupérer le résultat d'une requête
  • Formater cette réponse au bon format (json, HTML, ...) et la retourner à l'utilisateur.

Pour ma part, je n'utilise pas du tout cette couche. C'est un choix tout à fait personnel, je trouve que ça reste le rôle de l'Infrastructure, et je garde cette logique dans mes Controllers. Mais c'est une préférence qui peut être challengée dans vos projets bien entendu !

La Boîte de Leitner

Durant ce tutoriel, nous allons prendre un projet existant que j'ai développé, une application Symfony classique, et très simple, pour petit à petit la migrer vers une architure Clean.

Pour cela, j'ai décidé de développer une Boîte de Leitner.

La méthode Leitner, c'est une stratégie d'apprentissage et de révision de fiches qui est décrite par les scientifiques comme l'une des plus efficaces.

On image une boîte compartimentée avec des numéros. Chaque compartiment correspond à un jour, et chaque compartiment successif doit être de plus en plus espacé temporellement:

  • Compartiment 1: Jour 1
  • Compartiment 2: Jour 2
  • Compartiment 3: Jour 5
  • Compartiment 4: Jour 10
  • ...

leitner-box

Puis on écrit des cartes, aussi appelées flash cards, ou cartes de révision, qui contiennent une question au recto, et la réponse au verso.

Le jour 1 je sors les cartes présentes dans le compartiment 1 et j'essaie de répondre à chaque question de chaque carte:

  • Bonne réponse ? Je déplace la carte dans le compartiment 2
  • Mauvaise réponse ? Je replace la carte dans le premier compartiment.

Et on continue ainsi de suite avec le jour suivant. À chaque bonne réponse, je déplace la carte dans le compartement suivant. À la moindre mauvaise réponse, la carte retourne dans le tout premier compartiment, et on recommence.

Si je répond correctement à une Carte se trouvant dans le dernier compartiment, alors la carte est retirée pour de bon: On estime que la notion est assimilée.

Comme vous le devinez, ce système est assez simple à développer, et surtout à automatiser.

J'aimerais donc pouvoir créer des cartes de révision, et que celles-ci me soient soumises régulièrement (via l'envoi d'un e-mail par exemple), pour que je puisse tenter d'y répondre, et qu'elles soient automatiquement déplacées dans les compartiments correspondants.

Et ainsi de suite, je recevrai chaque jour une notification m'indiquant à quelles cartes je dois répondre aujourd'hui.

Pas de panique vous n'avez pas à tout développer de votre côté, on va partir ensemble de cette modeste base de code que vous retrouverez sur ce repo Github.

Ce projet utilise une base de donnéee PostgreSQL (dans un container Docker), PHP 8.4 et Symfony 7.3. Avec Docker Compose et le Symfony CLI, vous devriez être en mesure de lancer le projet. Dans le doute, n'hésitez pas à lancer un symfony check:requirements pour vous assurer que tout est bon.

Pour le reste, le README du projet devrait vous accompagner pour le setup (n'oubliez pas de lancer les migrations Doctrine). Prenez le temps de découvrir et de vous familiariser avec l'application.

Important

Pour le moment vous pouvez découvrir l'application via une interface simpliste développée en Twig, pour bien vous familiariser avec le concept de Leitner.
Lors du passage en Clean Archi, on supprimera la couche Twig, pour transformer notre Leitner Box en une API ne retournant que du JSON.
Le but est de rester simple, sans superflu, et se focaliser sur l'essentiel. Et aussi de prouver qu'il est très simple, en Clean Archi, de changer le type de Réponse de nos Controller.
Vous trouverez un fichier tests/requests.http dans lequel des requêtes HTTP sont prêtes à l'emploi (attention à changer les ID quand nécessaire) pour utiliser et tester l'API. Votre IDE devrait vous permettre de lancer ces requêtes directement depuis le fichier.

Pour un premier tour d'horizon, visitez la page d'accueil, puis créer votre première Carte. Une fois cela fait, elle apparaît dans votre liste de Cartes.

À présent, on vous être notifié chaque jour des Cartes auxquelles on doit répondre. Pour cela, imaginons une tâche cron qui appellera la commande DailyTestNotifCommand.

Pour cela, on le fait manuellement via le terminal:

$ bin/console app:daily-test-notif

Et voilà, un email est envoyé ! Pour le consulter, rendez-vous sur , l'adresse Mailpit (automatiquement lancé via docker compose).

Un lien vous redirigera vers une page où seules les cartes du jour vous seront proposées pour y répondre !

  • Bonne réponse ? Super, la Carte est "rangée" dans le compartiment suivant, il faudra y répondre à nouveau dans 3 jours.
  • Mauvaise réponse ? Dommage ! La carte reste dans le premier compartiment, venez retenter votre chance demain !

Vous trouverez également une Entité Card dont voici les propriétés:

  • $question: La question associée à la Carte
  • $answer: La réponse
  • $initialTestDate: La date initiale à laquelle la question nous est soumise
  • $delay: Le délai entre la $initialTestDate et la prochaine date de test (on incrémente cette valeur à chaque fois qu'on répond correctement à la question)
  • $active: La Carte est-elle activée ou désactivée

Pour le moment, notre entité utilise Doctrine pour se brancher à la base de données:

<?php declare(strict_types=1); namespace App\Entity; use App\Repository\CardRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: CardRepository::class)] class Card { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 255)] private string $question; #[ORM\Column(length: 255)] private string $answer; #[ORM\Column(type: Types::DATE_MUTABLE, nullable: true)] private ?\DateTimeInterface $initialTestDate = null; #[ORM\Column] private ?bool $active = null; #[ORM\Column(length: 255, nullable: true)] private ?string $image = null; #[ORM\Column(options: ['default' => 0])] private int $delay = 0; public function getId(): ?int { return $this->id; } public function getQuestion(): string { return $this->question; } public function setQuestion(string $question): self { $this->question = $question; return $this; } public function getAnswer(): string { return $this->answer; } public function setAnswer(string $answer): self { $this->answer = $answer; return $this; } public function getInitialTestDate(): ?\DateTimeInterface { return $this->initialTestDate; } public function setInitialTestDate(?\DateTimeInterface $initialTestDate): self { $this->initialTestDate = $initialTestDate; return $this; } public function isActive(): ?bool { return $this->active; } public function setActive(bool $active): self { $this->active = $active; return $this; } public function getImage(): ?string { return $this->image; } public function setImage(?string $image): self { $this->image = $image; return $this; } public function getDelay(): int { return $this->delay; } public function setDelay(int $delay): self { $this->delay = $delay; return $this; } }

C'est en jouant avec ces simples propriétés que notre Leitner Box est fonctionnelle.

On dispose d'un CRUD dans le Controller, ainsi que d'une méthode pour soumettre des réponses aux questions.

Visitez le dossier Service et notamment la classe HandleCardSolving pour découvrir la logique qui se cache sous le capot.

Vous dévouvrirez également la constante TEST_DELAY, qui représente le délai, en nombre de jours, entre chaque compartiments. J'ai choisi ces délais arbitrairement.

Ici, si on répond bon à chaque fois, on répondra aux questions au J1, puis J+3, J+7, etc...

Je vous laisse explorer le repo pour plus de détails sur cette version de l'app sans Clean Archi !

Important

À partir de maintenant, sur le repo Github de l'application, vous pouvez switcher sur la branche refacto-clean pour découvrir le projet entièrement réécrit en Clean.
Je vais progressivement montrer comment migrer l'architecture, et vous pourrez suivre pas à pas via le repo si vous ne souhaitez pas tout réécrire vous-même.

Identifier le Domain

Le Domain: Objets, comportement et règles

Anemic Domain

Comme vous pouvez le constater, notre architecture est celle par défaut proposée par Symfony lorsqu'on crée un nouveau projet: tous les dossiers sont dans src/ et dans un namespace App. Et c'est très bien comme ça, surtout pour un projet de cette taille. Mais comme tout projet, il peut être amené à grossir, et là, on regrettera peut-être de ne pas s'être imposé à l'avance des contraintes d'architecture.

Il est donc l'heure de prendre le problème à la racine et d'identifier le coeur de métier de notre application.

Première bonne pratique quand on adopte la Clean Architecture, toujours commencer son développement par le Domain, toujours from bottom to top. On commence donc par créer un nouveau dossier src/Domain.

Et pour savoir ce que l'on va mettre dedans, on va essayer d'identifier 2 concepts:

  • Les objets métiers (Domain Model), et leur comportement
  • Les règles métier

Pour cela on veut se poser la question "Que fait mon application ?", et en l'occurence, j'aimerais répondre: "Mon application sert à me présenter des cartes de révision automatiquement certains jours. Je veux aussi être capable de gérer (création, suppression, ...) ces cartes".

C'est plutôt simple, j'estime n'avoir qu'un seul objet métier, la Card. Maintenant, un objet tout seul, ça ne sert à rien. Cet objet doit pouvoir se comporter. Prenons un peu de temps sur cette notion, car elle est cruciale, et souvent oubliée des développeurs qui utilisent des ORMs comme Doctrine (dont je fais partie !).

Comme très bien expliqué dans cet article de Martin Fowler, un fléau s'est abattu sur le monde de l'orienté objet: l'Anemic Domain Model, ou le Domaine Anémique en bon français.

Il s'agit d'un anti-pattern, dans lequel nos objets se sont appauvris pour ne contenir plus que la donnée: c'est-à-dire uniquement des propriétés, des relations avec d'autres objets, ainsi que tout un panel de getter et setter. En résumé, ça ressemble grandement à notre bonne vieille entité Doctrine.

Le problème avec cette approche, c'est que nos objets ne sont plus que des coquilles vides de sens, qui ne font que transiter de la donnée entre la base de données et notre application, mais qui n'ont aucun comportement, aucun behavior.

Martin Fowler, dans son article, explique selon lui qu'il n'y a aucun intérêt à faire de l'orienté objet si on n'utilise pas ce pourquoi l'Objet a été créé. Un objet se doit de combiner donnée et logique, et contenir des méthodes qui lui permettent de se comporter.

Ainsi, la tendance des dernières années à systèmatiquement séparer les données dans les objets et la logique dans des services serait alors un non-sens total.

Le Service ne devrait qu'orchestrer et coordonner les objets ensemble et avec le reste du programme, mais pas contenir de connaissance ou de comportement métier.

J'insiste sur ce point car il s'agit, à mon sens, d'un concept qui m'a vraiment aidé à comprendre ce que je faisais "de travers", et m'a donné un nouvel angle de compréhension de la solution qu'est la Clean Architecture: redonner le pouvoir au Domain.

Note

Je ne porte aucun jugement sur une pratique que j'utilise moi-même encore régulièrement. Mais je pense qu'il est intéressant de comprendre comment les ORMs ont modifié la façon dont on perçoit le rôle d'un objet: une entité fortement liée au schéma de base de donnée, et qui ne représente que de la donnée.
C'est en revenant à la base de l'Objet que nous allons pouvoir aller de l'avant.

Pour info, il existe un article entier sur l'Anémie du Domain sur le Blog d'Eleven Labs, si vous souhaitez aller plus loin.

Propriétés & Immutabilité

Reprenons notre objet Card qui n'est pour le moment qu'une entité Doctrine, ne reflettant que de la donnée persitée en base. Oublions la base de donnée. De quoi à besoin ma Card pour fonctionner ? On ne conservera que les propriétés qui ont un sens fonctionnellement. Pas besoin de garder des timestamp tels que $updatedAt par exemple (à moins que cette valeur ait une vraie utilité fonctionnelle). Dans notre cas, la Card était déjà plutôt bien définie, avec peu de propriétés.

Voici donc ma proposition:

<?php declare(strict_types=1); namespace Domain; /** * Immutable domain object representing a Card in the Leitner box system */ readonly class Card { /** * Delay schedule for the Leitner box system (in days) */ private const array TEST_DELAY = [1, 3, 7, 15, 30, 60]; public function __construct( public string $question, public string $answer, public ?\DateTimeInterface $initialTestDate, public ?bool $active, public int $delay = 1, public ?string $id = null, ) { } // ... }

Premier élément à noter, l'utilisation du readonly pour rendre cet objet immuable. Il est intéressant de garder tous ses Domain Object Models immuables pour la lisibilité du code et pour minimiser les erreurs. Une fois que je crée un objet Card, il ne changera jamais, donc aucun risque qu'un appel à une fonction cachée change secrètement la valeur d'une propriété avant que je persiste le tout en base: mon code gagne en fiabilité.

Question

C'est super mais que faire si j'ai vraiment envie de modifier une propriété de mon objet ?

La solution dorénavant dans ce cas est assez simple. Imaginons que nous souhaitons désactiver une Card car on ne souhaite plus qu'elle apparaisse dans notre boîte. On rajoute cette fonction à notre classe :

// ... /** * Creates a new Card with the updated active status */ public function withActive(bool $active): self { return new self( $this->question, $this->answer, $this->initialTestDate, $active, $this->delay, $this->id, ); } // ...

Puis j'appelle cette fonction ainsi, pour qu'elle me retourne un nouvel objet, avec la propriété active modifiée :

public function disableCard($card): Card { $disabledCard = $card->withActive(false); return $disabledCard; }

Quels sont les avantages d'une telle méthode ?

  • Par défaut mon objet est immutable, stable dans le temps, il garde le même state.
  • Je crée des fonctions with*() uniquement pour définir des situations dans lesquelles j'autorise des propriétés à changer. Le nom de ces fonctions ont un sens, contrairement à de simples setters.
  • Plutôt que de manipuler 1 objet Card, que je modifie plusieurs fois au cours de mon programme, ici je retourne à chaque fois des objets différents. Chacun à son propre état, et si je les nomme bien, il est beaucoup plus aisé de les manipuler et de suivre ce qui se passe dans le code.

Cela permet une meilleure fluidité de développement, plutôt que d'avoir par exemple un seul objet $card qui passe à la machine à laver, se faisant muter de fonction en fonction, durant plusieurs dizaines de lignes, et à la fin on ne sait plus quelles sont les valeurs de ses propriétés.

Vous aurez également remarqué que nous avons changé le namespace en supprimant le préfixe App. Pour que ce changement fonctionne, n'oublions pas de modifier notre composer.json au niveau de l'autoload ainsi:

"autoload": { "psr-4": { "Domain\\": "src/Domain/", "Application\\": "src/Application/", "Infrastructure\\": "src/Infrastructure/" } }

Je vous offre un petit spoil des dossiers que nous allons créer par la suite, c'est cadeau !

Note

Il existe une autre manière d'organiser son Architecture, qui est par feature. Dans ce cas, chaque fonctionnalité a son propre dossier Domain, Application, et Infrastructure.
Par exemple :
src/feature1/Domain/feature1.model.php
src/feature1/Infrastructure/feature1.controller.php
src/feature1/UseCase/feature1.useCase.php

Et ainsi de suite pour les autres fonctionnalités. C'est juste une autre manière de faire mais cela revient exactement au même.
Personnellement je préfère largement avoir une seule fois les dossiers Domain, Application, et Infrastructure à la racine, quitte à diviser par fonctionnalité en dessous.

Comportement

Très bien, il ne nous reste plus qu'une chose à rajouter à notre objet, dont nous avons parlé plus tôt: un comportement. Moins de blabla, plus de code, voici quelques comportements à ajouter à notre Card:

/** * Resolves the provided answer to the card */ public function resolve(string $answer): self { if ($this->isAnswerCorrect($answer)) { return $this->handleSuccessfulAnswer(); } return $this->handleFailedAnswer(); } /** * Checks if this card is due for testing today */ public function isDueForTesting(): bool { if (!$this->active) { return false; } if (!$this->initialTestDate instanceof \DateTime) { return false; } $dueDate = (clone $this->initialTestDate)->modify($this->delay . 'days'); return $dueDate <= new \DateTime('today'); } /** * Checks if the provided answer is correct for this card */ public function isAnswerCorrect(string $answer): bool { return strtolower(trim($answer)) === strtolower($this->answer); } /** * Handles a failed answer attempt by resetting the card's delay and setting the initial test date to now */ private function handleFailedAnswer(): self { return $this->withInitialTestDate(new \DateTime()) ->withDelay(1); }

Note

Comme pour tous les exemples de ce tutoriel, le contenu des classes n'est pas exhaustif. Pour voir le code dans son intégralité, gardez en parallèle une page Github ouverte avec le code complet de notre application.
En l'occurence, vous pourrez trouver d'autres exemples de comportements directement dans la classe Card.php du repo !

Super ! Notre Card est dorénavant capable de se comporter. On distinguera les règles métier qui vérifient et renvoient un résultat comme notre isAnswerCorrect, des comportements qui renvoient une nouvelle instance de Card suite à une modification. À noter que nos comportements peuvent combiner des appels aux règles métier pour décider quoi faire, avec des appels à d'autres comportements.

Prenons un exemple. La méthode resolve va décider d'appeler handleFailedAnswer OU handleSuccessfulAnswer en fonction du retour de la règle isAnswerCorrect ! Et si la réponse est mauvaise, handleFailedAnswer va renvoyer une nouvelle Card avec son délai réinitialisé suite à la mauvaise réponse.

Et vous l'aurez deviné, la fonction disableCard, que nous évoquions plus haut fait aussi partie du comportement de notre Card !

Interfaces & Exceptions

On a presque finit de construire le Domain, mais on va être confronté à un problème: comment pourrai-je plus tard communiquer avec l'Infrastructure ? Deux réponses à cela:

  • La couche Domain de doit jamais appeler une autre couche, elle ne dépend de personne.
  • La couche Applicative par contre, qui contient les UseCases, doit pouvoir orchestrer le Domain et l'Infrastructure, mais sans pour autant dépendre de l'Infra.

En fait, il faut imaginer ce que l'on appelle un flow of control en Clean Archi.

flow-of-control

Ce flow est une flèche qui ne se dirige que dans un sens: elle part de l'Infra, passe par l'Application, et atteri dans le Domain.

Elle permet de schématiser la gestion des dépendances: L'infra peut dépendre de l'Application, qui peut dépendre du Domain, mais jamais dans le sens inverse. Le Domain n'a aucune dépendance vers l'Application, qui n'a aucune dépendance vers l'Infra.

Le seul moyen de communiquer avec notre Infra, c'est de se rappeler du D de nos bons vieux principes SOLID: le Dependency Inversion Principle.

Pour rappel, ce principe nous aide à découpler nos couches comme ceci:

  • Les modules de haut niveau (ici, notre Domain) ne doivent pas importer les modules de bas niveau (nos implémentations techniques dans l'Infrastructure). À la place, on doit utiliser... des Interfaces !

Et voilà on sait comment régler notre problème ! On va ajouter dans notre Domain des contrats d'interface que notre Application pourra utiliser, sans avoir besoin de savoir quelles sont les implémentations concrètes de ces interfaces côté Infrastructure.

Par exemple, c'est typiquement ce genre d'interface que nous allons mettre ici :

interface CardRepositoryInterface { public function listAllCards(): iterable; public function findCard(string $id): ?Card; /** @throws CannotCreateCard */ public function createNewCard(Card $card): void; /** @throws CannotEditCard */ public function editCard(Card $card): void; /** @throws CannotRemoveCard */ public function removeCard(string $id): void; /** @return iterable<Card> */ public function findTodayCards(): iterable; }

On n'ajoutera dans nos interfaces que les méthodes dont nous sommes sûr qu'elles seront utiles à notre Application, ni plus, ni moins. Cela permet notamment de ne pas être parasité par les nombreuses autres méthodes et propriétés que nous mettent à disposition Framework, librairies, APIs, ORMs,..

Pour les ORMs comme Doctrine par exemple, on n'exposera pas la Connection à la base de donnée, le QueryBuilder, ou encore toutes les fonctions toutes faites telles que find, findOneBby, etc... qui n'ont aucun sens métier dans notre Application. C'est nous qui définissons, avec des termes clairs et logiques, le nom de nos interfaces et de leurs méthodes, et on laisse le soin à l'Infrastructure de se débrouiller avec cela.

Important

Pour rappel, le but ici est théoriquement de pouvoir être capable de se débarasser de la couche Infrastructure et de la remplacer par une autre (changement de Framework, d'ORM, ou d'autres API..), sans que cela ait la moindre incidence sur notre Domain ou notre couche Applicative, grâce au découplage.
Ce n'est pas un objectif en soi de pouvoir changer de Framework du jour au lendemain. L'idée est de trouver le meilleur des deux mondes entre un meilleur confort de développement, et éviter le risque de burnout si un jour on est amené à faire un tel changement.
De plus vous pouvez voir le problème en l'inversant : vous n'allez peut-être pas changer le Framework, mais prendre un morceau de votre code métier pour le migrer vers un autre micro-service par exemple, qui lui possède une toute autre Infrastructure. Et à ce moment-là, vous serez reconnaissant d'avoir un programme très faiblement couplé !
En bref, c'est en respectant au maximum cette philosophie que vous ne ferez plus l'erreur de développer une fonctionnalité en partant du Framework, mais bien de penser d'abord aux besoins et aux règles métier, et en implémentant l'Infra seulement à la fin, et en vous servant des Interfaces que vous aurez peut-être créées.

Enfin, on remarquera que mon Interface peut lever des Exceptions particulières et personnalisées. Celles-ci permettent de communiquer à l'Infrastructure quelle Exception lever à quel moment, pour que notre couche Applicative sache à quoi l'erreur correspond, et comment réagir.

Ces Exceptions sont rangées dans le Domain au même titre que les Interfaces, comme par exemple:

<?php declare(strict_types=1); namespace Domain\Exception; class CannotRemoveCard extends \Exception { public function __construct(string $message = 'Failed to remove card', int $code = 0, ?\Throwable $previous = null) { parent::__construct($message, $code, $previous); } }

Pareil ici, ces Exceptions ont des noms clairs, on doit comprendre ce qu'il s'est passé simplement en lisant le nom de la classe, et il n'y pas forcément besoin d'ajouter le suffixe Exception si on ne veut pas être répétitif.

Rendez-vous sur le repo Github pour voir plus d'Interfaces et d'Exceptions.

Orchestrer le Domain avec les UseCase

La couche Application

Notre Domain est à présent riche en règles métier & comportement, il contient des Interfaces, des Exceptions, il serait temps de faire fonctionner tout cela ensemble ! C'est là le but de la couche Application, aussi appelée UseCases, ou parfois même Services, bien que qu'à titre personnel, je n'aime pas le terme "Service" qui est trop générique et foure-tout à mon goût.

Pour ma part, j'aime appeler cette couche "Application" et suffixer toutes les classes qu'elle contient par ...UseCase. Je trouve cela plus parlant pour comprendre l'objectif de cette couche: orchestrer l'application au travers de cas d'usages métier. Chaque classe de cette couche doit répondre à un cas d'usage applicatif réel, et se servir du Domain (Model, règles, Interfaces) pour y répondre.

En général, ces classes ne doivent contenir qu'une méthode execute() dont le but est d'exécuter le cas d'usage applicatif, rien de plus, rien de moins.

Un UseCase peut donc être quasi-dépourvu de contenu, s'il n'est qu'un passe plat entre l'Infrastructure et le Domain, par exemple si l'on souhaite créer une Card:

<?php declare(strict_types=1); namespace Application; use Domain\Card; use Domain\CardRepositoryInterface; use Domain\Exception\CannotCreateCard; readonly class CreateCardUseCase { public function __construct( private CardRepositoryInterface $cardRepository, ) { } /** @throws CannotCreateCard */ public function execute(Card $card): void { $this->cardRepository->createNewCard($card); } }

Voilà l'exemple le plus simple possible. On rappelle qu'à ce stade selon le flow of control, le UseCase ne doit dépendre que de la couche inférieure à lui, soit le Domain, d'où l'injection du CardRepositoryInterface, définie dans le chapitre précédent sur le Domain.

Note

Dans la réalité, il est rare qu'un UseCase soit aussi dépouillé que celui-ci, un projet plus complet aurait nécessité certainement un traitement plus exhaustif, comme réagir à la création de la Carte, ajouter des garde-fous, etc...

C'est bien joli mais il n'y a pas grand chose à orchestrer dans ce cas précis. Prenons un cas plus complexe. Je souhaite répondre à la question d'une Carte, et savoir si j'ai bien répondu ou non.

Créons ce UseCase:

<?php declare(strict_types=1); namespace Application; use Domain\Card; use Domain\CardRepositoryInterface; use Domain\Exception\CannotEditCard; readonly class SolveCardUseCase { public function __construct( private CardRepositoryInterface $cardRepository, ) { } /** @throws CannotEditCard */ public function execute(Card $card, string $answer): bool { // Update the card based on whether the answer was correct $updatedCard = $card->resolve($answer); $this->cardRepository->editCard($updatedCard); return $card->isAnswerCorrect($answer); } }

Reprenons cette classe depuis le début:

  • Comme pour le UseCase précédent, on injecte le repository pour mettre à jour la Card dans la base de donnée en fonction de la réponse donnée
  • Ma fonction execute prend en paramètre une Card et la réponse proposée
  • On utilise le comportement de notre objet (la méthode resolve($answer)) pour correctement gérer sa mise à jour en cas de bonne ou mauvaise réponse
  • On met à jour la Card dans la base de donnée via l'interface du Repository (méthode editCard()).
  • On se sert de la règle métier isAnswerCorrect pour retourner true ou false en fonction de la validité de la réponse.

Ce qui est important de remarquer, c'est qu'il n'y a aucune logique métier dans nos UseCase. Car ce n'est pas son rôle. Le UseCase sera simplement appelé par l'Infrastructure (requête d'un utilisateur par exemple), pour donner une réponse en orchestrant le Domain (vérification de règles, appels à des interfaces, ...).

Maintenabilité et Tests

À ce stade, nous avons donc des cas d'usages métier bien définis et représentés par des classes. C'est l'idéal pour ajouter des tests unitaires, qui chacun testeront de façon bien isolée chaque cas.

Peut-être connaissez-vous l'adage Don't mock what you don't own ?

Il peut être en effet compliqué d'essayer de mocker une classe provenant de librairies externes, comme Doctrine, car parfois elles sont complexes, et dépendent elles-mêmes d'autres classes que l'on comprend moins. La mocker pourrait même apporter des effets de bord indésirables lors des tests.

Bonne nouvelle, ce problème n'existe pas ici ! Notre couche Application est protégée de l'Infrastructure et donc du Framework, des librairies externes, de l'ORM, etc...

Jusqu'ici, tout nous appartient, grâce aux interfaces fournies par le Domain. Ces interfaces qui ne contiennent à chaque fois que le contrat nécéssaire au fonctionnement de notre projet, pas de superflu ni de détails d'implémentation.

On peut donc les mocker sans danger si l'on souhaite ajouter des tests unitaires, car nous possèdons ces Interfaces !

Grâce à tout cela, notre code est réellement maintenable, car il est totalement isolé du reste, il ne sait pas quelle base de données est utilisée par dessus, quel Framework, quel client mail, ... Tout cela pourrait changer du jour au lendemain que ça ne changerait rien pour notre Domain et notre Application, et surtout ... Les tests passeraient toujours ! Ce que cela signifie en particulier, c'est qu'on ne teste que des UseCase qui ne représentent que de la logique métier. Ici, si vous faites une erreur d'implémentation d'une librairie, ou d'utilisation de votre Framework, ça ne viendra pas polluer le résultat de vos tests, qui isolent votre logique métier. Si votre test fail, il y a beaucoup plus de chances que ce soit vraiment un souci de cohérence de votre métier.

Ajouter une Infrastructure

Le Framework

La première chose que nous allons mettre dans notre Infrastructure, c'est notre Framework, en l'occurence Symfony. Pour cela on commence par créer un dossier Infrastructure\Symfony, puis on va séparer chaque sous-dossier selon les Symfony Components utilisés.

C'est une préférence personnelle, mais le fait que Symfony fonctionne avec des packages isolés les uns des autres qui peuvent être ajoutés briques par briques au fur et à mesure, cela facilite grandement le découpage des dossiers:

1 package = 1 dossier

Ce qui nous donne ceci:

src/
└───Domain/
│
└───Application/
│
└───Infrastructure/
    │   └───Symfony/
        │   └───Command/
        │   └───Controller/
        │   └───Mailer/
        │   └───Http/

Petite exception pour les Controller qui ont leur propre dossier car il s'agira du point d'entrée de notre application, qui pour rappel, est une API REST.

Il nous suffit à présent de déplacer tous les fichiers correspondants dans leurs dossiers respectifs, et d'implémenter les interfaces du Domain quand il y en a. Par exemple, on a créé une NotificationInterface dans le Domain pour définir les méthodes attendues. On implémente donc cette interface ainsi:

<?php declare(strict_types=1); namespace Infrastructure\Symfony\Mailer; use Domain\Notification; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Email; readonly class AppMailer implements Notification { public function __construct(private MailerInterface $mailer) { } public function sendTestCardsNotification(string $to, string $subject, string $htmlBody): void { $email = (new Email()) ->from('leitner@box.com') ->to($to) ->subject($subject) ->html($htmlBody); $this->mailer->send($email); } }

Et c'est tout ! Ainsi notre Domain reste agnostique de l'implémentation technique (il n'a pas à savoir quel type de Notification sera envoyée, un mail, un sms, ...), et notre Infrastructure sait ce qu'elle doit faire grâce au contrat d'interface. Ici donc, on injecte le Mailer de Symfony pour envoyer notre Notification.

Demain, on pourrait préfèrer une notification via SMS, ou une push notification, il suffira d'ajouter ces classes. L'Application, elle, restera inchangée, car elle dépend uniquement de l'Interface Notification.

Doctrine et DBAL

Bien ! Parlons à présent de notre ORM, Doctrine. On le place dans un dossier à part, et dans notre cas nous n'avons qu'un seul Repository à implémenter.

Voilà à quoi cela ressemble :

<?php declare(strict_types=1); namespace Infrastructure\Doctrine\Repository; use Doctrine\DBAL\Connection; use Domain\Card; use Domain\CardRepositoryInterface; class PostgresCardRepository implements CardRepositoryInterface { public function __construct(private readonly Connection $connection) { } public function findCard(string $id): ?Card { $queryBuilder = $this->connection->createQueryBuilder(); $result = $queryBuilder ->select('c.id, c.question, c.answer, c.delay, c.initial_test_date, c.active') ->from('card', 'c') ->where('c.id = :id') ->setParameter('id', $id) ->executeQuery() ->fetchAssociative(); if ($result === false) { return null; } return $this->mapToCard($result); } private function mapToCard(array $row): Card { return Card::create( $row['question'], $row['answer'], $row['initial_test_date'] ? new \DateTime($row['initial_test_date']) : new \DateTime(), (bool) $row['active'], (int) $row['delay'], $row['id'], ); } }

Comme vous le constatez, en réalité nous n'utilisons pas l'ORM de Doctrine, car nous ne faisons aucun mapping d'entité. Nous utilisons uniquement Doctrine DBAL (Database Abstraction Layer) pour construire nos requêtes.

Avantages de DBAL ? C'est beaucoup plus léger que l'ORM en entier, on n'injecte que l'object Connection, et c'est bon on peut se connecter et faire des requêtes dans notre base, tout en profitant du QueryBuilder pour nous aider à construire nos requêtes. De plus, les performances sont bien plus intéressantes qu'avec la surcouche de l'ORM.

Inconvénient ? Pas de méthode magique (findBy, etc...), il faut écrire toutes nos requêtes, même les plus simples (je vois ça aussi comme un avantage pour garder le contrôle sur les données récupérées et la performance).

Autre inconvénient, qui dit pas d'ORM dit pas de mapping automatique entre le résultat de la requête SQL et notre entité Card. Et donc c'est à nous de faire ce mapping nous-mêmes, avec la méthode mapToCard que vous trouvez ci-dessus.

Dans notre monde simpliste, cette méthode suffit à couvrir tous nos usages, et on continue à gagner en performance sur l'usine qu'est l'ORM. Mais dans le monde réel, il faudra bien penser à valider le données récupérées, et faire un travail de maintenance constant sur ce genre de méthode pour qu'elle résiste au changement. De nombreuses autres alternatives exites sûrement, votre imagination est la seule limite !

Les Requests, les DTOs et la Serialization

Bien, maintenant que cela est fait, il ne faut pas oublier une étape cruciale : notre application doit être capable de communiquer avec l'extérieur. Pour cela, on doit pouvoir valider et contrôler les données reçues, mais aussi correctement serializer les données renvoyées, pour ne pas exposer de données sensibles ou superflues. On va se servir de DTOs pour tout cela, en distinguant deux types:

  • Les DTOs de type Request
  • Les DTOs de type Response

Commençons par le plus simple, les Response. On crée un objet en y ajoutant uniquement les propriétés que l'on souhaite exposer. Par exemple, lorsque je souhaite essayer répondre à la question d'une Card, il vaut mieux que je n'expose pas la réponse, et que seule la question soit accessible. Créons donc un TestCardDto ainsi:

<?php declare(strict_types=1); namespace Infrastructure\Symfony\Http\Response; readonly class TestCardResponse { public function __construct( public string $id, public string $question, ) { } }

Et voilà, il ne nous restera plus qu'à retourner cet objet depuis notre controller au moment voulu.

Pour ce qui est des Request, ce sont aussi de simples DTOs, mais sur lesquels on ajoute des Constraint du Validator de Symfony. Mettons que je souhaite créer une nouvelle Card, je n'accepterai que trois informations:

  • La question (qui ne doit pas être vide)
  • La réponse (pareil)
  • Le statut de la Carte (par défaut à true)

Ce qui donne:

<?php declare(strict_types=1); namespace Infrastructure\Symfony\Http\Requests; use Domain\Card; use Symfony\Component\ObjectMapper\Attribute\Map; use Symfony\Component\Validator\Constraints as Assert; #[Map(target: Card::class)] readonly class CreateCardRequest { public function __construct( #[Assert\NotBlank(message: 'La question ne peut pas être vide')] public string $question = '', #[Assert\NotBlank(message: 'La réponse ne peut pas être vide')] public string $answer = '', public ?\DateTimeInterface $initialTestDate = null, public bool $active = true, ) { } }

Important

Vous vous demandez peut-être pourquoi je fais de la validation de données ici dans l'Infrastructure plutôt que via des règles de gestion dans le Domain. C'est vrai, ce devrait être le rôle du Domain de contenir ces règles !
Il est totalement normal de se poser cette question et c'est là une limite parfois floue qu'il vous reviendra de trancher: est-ce que je fais une validation technique (le format d'une donnée brute en entrée par exemple) ou une validation fonctionnelle, auquel cas il faut en effet que cette règle soit contenue dans le Domain.
Ici, je vérifie simplement que mes données en entrée ne sont pas vides, et j'estime que ça n'a aucun intérêt fonctionnel, et pas de valeur métier, je décide donc de laisser la charge de cette validation au Validator de Symfony.
À vous d'arbitrer quand vous vous retrouverez dans des cas similaires !

Vous remarquerez le namespace de ces DTO, respectivement Infrastructure\Symfony\Http\Response & Infrastructure\Symfony\Http\Requests. J'ai fait le choix de les placer dans le répertoire Symfony\Requests car cela me parraissait le plus logique, mais surtout parce que les Requests utilisent le Validator et l'ObjectMapper de Symfony, ce qui en font des objets totalement dépendants du Framework.

Vous aurez remarqué que je n'ai pas encore mentionné l'utilisation de l'ObjectMapper dans notre CreateCardRequest. Prenons un instant pour parler de ce composant.

Symfony Object Mapper

Au moment où j'écris ces lignes, le nouveau composant Symfony ObjectMapper vient de sortir. C'est une super nouvelle car ce dernier va grandement faciliter le mapping d'un objet à un autre. Je vous laisse le lien vers la documentation de ce nouveau composant pour en savoir plus.

Ce qu'il faut retenir, c'est que Symfony va se servir du nom des propriétés de notre DTO pour les faire correspondre avec notre objet métier, sans avoir besoin de toujours créer un new Card() à chaque réception d'une Request. J'ai juste besoin d'ajouter l'attribut #[Map(target: Card::class)], au dessus de mon DTO, et c'est bon il est prête à être mappé sur mon objet Card.

Mais pour voir ce composant en action, il faut que l'on parle des Controller, où toute cette logique de communication avec l'extérieur va se dérouler.

Le Controller

Nous avons décidé de faire une API, c'est donc un Controller HTTP que nous créerons ici. Mais gardez en tête que nous pourrions décider de renvoyer du HTML, ou de ne fonctionner uniquement que dans un Terminal, cela ne changerait absolument rien: notre Domain et notre Application ne sait pas dans quel environnement elle évolue, si elle est un site web, une desktop app, une app mobile, ou n'importe quoi d'autre. Ça, ne n'est pas du ressort de nos règles métiers, c'est l'Infrastructure qui décide de cette implémentation technique.

Dans les chapitres précédents, nous avons commencé par créer nos règles métier dans le Domain, nous avons défini nos UseCase, et nous avons nos Request et Response prêts à être utilisés.

C'est l'heure pour le Controller, point d'entrée de notre API, de recevoir ces requêtes et de faire appel à tout ce beau monde.

Première chose à faire, listons toutes les Card existantes dans notre base de données:

/** ... */ class CardController extends AbstractController { public function __construct( private readonly GetTodayAvailableCardsToTestUseCase $cardsAvailableToTestUseCase, /** ... */ ) { } #[Route('/cards/test', name: 'app_cards_test', methods: [Request::METHOD_GET])] public function listCardsToTest(): JsonResponse { $cardsToTest = $this->cardsAvailableToTestUseCase->execute(); $testCards = []; foreach ($cardsToTest as $card) { $testCards[] = new TestCardDto( $card->id ?? '', $card->question, ); } return $this->json([ 'cards' => $testCards, ]); } /** ... */

Dans ce cas ultra-simpliste, nous souhaitons simplement récupérer les Card auxquelles je dois répondre aujourd'hui. Je sais que j'ai un UseCase qui me permet de le faire, auquel je fais appel. Puis, je décide de serializer les données car je ne veux pas exposer les réponses aux questions dans ce cas précis. Pour cela j'utilise le TestCardDto créé précédemment. Enfin je retourne mes données, c'est tout !

La logique sera un poil plus complexe lorsqu'il s'agira de vouloir créer une nouvelle Card:

#[Route('/new', name: 'app_card_new', methods: [Request::METHOD_POST])] public function newCard(#[MapRequestPayload] CreateCardRequest $createCardRequest): JsonResponse { $violations = $this->validator->validate($createCardRequest); if (\count($violations) > 0) { $errors = []; foreach ($violations as $violation) { $errors[$violation->getPropertyPath()] = $violation->getMessage(); } return $this->json([ 'error' => 'Validation failed', 'violations' => $errors, ], Response::HTTP_BAD_REQUEST); } $card = $this->objectMapper->map($createCardRequest, Card::class); try { $this->createCardUseCase->execute($card); return $this->json([ 'message' => 'Card created successfully', 'card' => $card, ], Response::HTTP_CREATED); } catch (CannotCreateCard $e) { throw new BadRequestHttpException('Failed to create card: ' . $e->getMessage(), $e); } }

Bon, découpons cette métode en plusieurs morceaux :

On commence par définir notre route POST et on utilise le MapRequestPayload de Symfony pour automatiquement mapper la requête vers notre DTO CreateCardRequest. Si le mapping n'est pas possible, Symfony retournera automatiquement une Exception.

Notre Request est à présent bien arrivée jusqu'à nous, on va pouvoir la valider avec le Validator de Symfony, et les Constraint que l'on a appliqué au DTO. Si des erreurs de validation sont trouvées, on les renvoit de manière très triviale.

Si tout va bien, on va pouvoir utiliser l'ObjectMapper de Symfony, qui va automatiquement nous créer un nouvel objet Card, et l'hydrater avec les données de notre CreateCardRequest.

Les données sont validées, et on manipule enfin un objet que le Domain comprend: une Card. On peut donc faire appel à notre Application via le CreateCardUseCase. On sait qu'il peut renvoyer une Exception, donc on englobe le tout dans un try / catch, et en renvoit notre Card nouvellement créée si tout s'est bien passé. Ici, j'estime qu'il n'y a nullement besoin de serializer la donnée, car je viens moi-même de créer cette Card.

C'est tout ! Notre Controller n'a pas à savoir ce qui se passe à l'intérieur de l'Application, il ne sert que de passe plat entre des données en entrée (qu'il faut certes valider et formatter), et des données en retour (que l'on peut vouloir serializer).

La Command

N'oublions pas qu'un cron tourne tous les jours pour envoyer un mail si nécessaire, pour nous prévenir si des Cartes sont présentes dans le compartiment du jour, et qu'on doit y répondre.

Pour cela, on s'est créé un cas d'usage dans la couche Application, et il ne nous reste plus qu'à l'appeler. Tirons profit du composant Command de Symfony, et ajoutons cette commande:

<?php declare(strict_types=1); namespace Infrastructure\Symfony\Command; use Application\SendDailyCardsUseCase; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; #[AsCommand( name: 'app:daily-test-notif', description: 'Daily Notification for test', )] class DailyTestNotifCommand extends Command { public function __construct(private readonly SendDailyCardsUseCase $sendDailyCardsUseCase) { parent::__construct(); } protected function configure(): void { } protected function execute(InputInterface $input, OutputInterface $output): int { $this->sendDailyCardsUseCase->execute(); return Command::SUCCESS; } }

On pourrait même rajouter un try / catch si jamais notre UseCase lève une Exception, pour afficher une erreur proprement dans le terminal.

Et c'est tout, si demain on souhaite envoyer cet email en cliquant sur un bouton depuis une page, on pourra jeter cette commande, créer un Controller, appeler ce même UseCase, et rien d'autre que la couche Infrastructure n'aura a changer, pratique !

Félicitations !

Notre Domain est parfaitement bien isolé du reste, tout est faiblement couplé grâce aux interfaces, et je peux me concentrer sur l'essentiel: développer de la valeur métier.

Bien sûr, je peux toujours m'amuser à choisir des implémentations techniques complexes, challengeantes ou farfelues si l'envie m'en prend, mais au moins mon code métier n'en pâtira jamais, et si un jour je fais de mauvais choix techniques, je peux juste tout remplacer sans toucher au coeur de mon application.

Conclusion

Notre boîte de Leitner a fait peau neuve, et fonctionnellement, rien a changé ! Enfin.. Notre application est devenue une API, pour le bien de ce tutoriel, mais dorénavant, rien ne nous empêche de brancher d'autres types de Controller dans notre Infrastructure. Que l'on renvoit du JSON, de l'HTML, ou même qu'on branche des Commands à notre application pour interagir avec via le terminal, une chose est sûre: notre Domain n'en saura jamais rien, car il reste agnostique de toutes les couches au dessus de lui. C'est l'avantage de dépendre d'abstractions (interfaces) plutôt que d'implémentations concrètes.

Cela nous permet d'adopter une nouvelle façon de développer et d'ajouter des fonctionnalités: Toujours commencer par le Domain. Ce qui se passe au dessus ne devrait jamais être un problème tant que nos règles de gestions et le comportement de notre Domain n'a pas été ajouté. Puis on ajoute la couche Application pour orchestrer notre Domain. Si on a besoin de se connecter à la couche Infra pour une quelconque raison (base de donnée, envoit de mail, ...), alors on crée nos Interfaces dans le Domain, pour se concentrer sur ce que je dois faire plutôt que comment je le fais. Et je peux ajouter mes tests unitaires en isolation avec l'extérieur.

Enfin, quand tout cela est en place, je peux commencer à me demander comment j'implémente mes différentes interfaces, quel Mail Provider, quel type de base de donnée, quel Payment Provider, voire même quel Framework je veux brancher sur mon application. Et tout cela, c'est mon Infrastructure qui s'en charge.

Et voilà ! Je trouve cela beaucoup plus sain de se concentrer sur notre métier avant tout le reste, car c'est là la raison d'être de nos applications.

J'espère que ce tutoriel vous a plu et vous aura appris des choses, n'oubliez pas qu'il n'y a jamais une seule manière de faire, donc adaptez toujours ce que vous lisez à votre situation, votre équipe, et votre sensibilité.

Et pour terminer j'aimerais remercier Noel qui m'aura bien aidé lors mon auto-formation à la Clean Architecture, et sans qui ce tutoriel n'existerai pas !

Merci d'avoir suivi jusqu'ici et à très bientôt sur le blog d'Eleven Labs 👋