Vincent Beauvivre

Legacy to DDD : Partie 2 - Comment moderniser votre application PHP avec le Domain-Driven Design

21 minutes de lecture

Cet article fait suite à notre précédent article sur le DDD et son application à un projet vieillissant, réalise en CakePHP. Dans la première phase, nous avons passé deux actions essentielles en mode DDD : une lecture et une écriture. C’est le bon moment pour creuser les avantages et fonctionnalités que nous offre le DDD !

Pour retrouver les parties 1 à 3 : https://slash.troopers.coop/article/legacy-to-ddd-du-concret-partie-1

4. Aller plus loin dans le DDD

Avant d’aller plus loin, regardons de plus près ce que nous avons fait là.

4.1. Value-object

Considérons par exemple la signature de notre bookmarkUpdater

1namespace Domain\Bookmark\Updater;
2
3class BookmarkUpdater
4{
5    public function update(
6        Bookmark $bookmark,
7        string $title,
8        string $url,
9        string $description,
10        array $tagsTitle
11    ): ?Bookmark {
1213    }
14}

Pas facile de faire confiance à cette variable $url pour détenir réellement une adresse web. On ne sait d’ailleurs pas si le protocole est http/s ou un autre. Et si on remplacait cette simple chaîne de caractère en objet métier qui portera le sens de ce qu’elle est !

1namespace Domain\Bookmark\Updater;
2
34use Domain\Bookmark\ValueObject\Url;
5
6class BookmarkUpdater
7{
8    public function update(
910        s̶t̶r̶i̶n̶g̶ ̶$̶u̶r̶l̶
11        Url $url,
12    ): ?Bookmark {
1314    }
15}

En DDD, on le nomme value object. C’est un objet métier, avec toute la valeur que cela implique. Mais à la différence des autres objets métier (comme le bookmark), il n’a pas d’identité propre.

1namespace Domain\Bookmark\ValueObject;
2
3class Url
4{
5    public $value;
6
7    public function __construct(string $url) {
8        $validation = (bool) preg_match(
9            "#(?i)\b((?:https?://|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'\".,<>?«»“”‘’]))#",
10            $url
11        );
12        if (!$validation) {
13            throw new InvalidValueException('Invalid URL');
14        }
15
16        $this->value = $url;
17    }
18}

Je n’ai pas pu résister à y ajouter une validation minimum, avec cette regex trouvée sur stackoverflow que je n’ai même pas testée :P. Au passage, j’ai ajouté cette petite exception tout simple.

1namespace Domain\Bookmark\ValueObject;
2
3class InvalidValueException extends \DomainException
4{
5}    

Il nous faut aussi réaliser la création du value object quand on récupère les données de la persistance via le repository. On réalise à ce moment que lorsque l’on fait remonter des données depuis la persistance, on passe aussi par la validation. Ce n’est pas pertinent car les règles de validations peuvent changer dans la vie du projet, et on peut se retrouver avec des données stockées invalides. On va donc s’arranger pour créer la value-object quand même.

1namespace Domain\Bookmark\ValueObject;
2
3class Url
4{
5    public $value;
6
7    public static function fromString($url) {
8        $validation = (bool) preg_match(
9            "#(?i)\b((?:https?://|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'\".,<>?«»“”‘’]))#",
10            $url
11        );
12        if (!$validation) {
13            throw new InvalidValueException('Invalid URL');
14        }
15
16        return new self($url);
17    }
18
19    public static function fromPersistedString($url) {
20        return new self($url);
21    }
22
23    private function __construct(string $url) {
24        $this->value = $url;
25    }
26}

On pourrait même envisager d’ajouter une validation des données même quand on récupère les données de la base de données. Par exemple, pour éviter un http://example.com/"><script>alert("Vibby hacked here.")</script>. Ok, la couche infrastructure est censée éviter ces injections, mais il est bon de savoir qu’on peut faire des vérifications ici. Il nous faut maintenant ajouter cette logique à la transformation des objet métier Cakephp vers le modèle métier

1namespace App\Controller\Component;
2
3use Domain\Bookmark\ValueObject\Url;
4
5class BookmarkTransformerComponent extends Component
6{
7    public function modelToEntity(BookmarkModel $bookmarkModel): Bookmark
8    {
910        $̶b̶o̶o̶k̶m̶a̶r̶k̶E̶n̶t̶i̶t̶y̶-̶>̶s̶e̶t̶(̶'̶u̶r̶l̶'̶,̶ ̶$̶b̶o̶o̶k̶m̶a̶r̶k̶M̶o̶d̶e̶l̶-̶>̶u̶r̶l̶)̶;̶;̵
11        $bookmarkEntity->set('url', $bookmarkModel->url->value);
1213    }
14
15    public function EntityToModel(Bookmark $bookmarkEntity): BookmarkModel
16    {
1718        $̶b̶o̶o̶k̶m̶a̶r̶k̶M̶o̶d̶e̶l̶-̶>̶u̶r̶l̶ ̶=̶ ̶$̶b̶o̶o̶k̶m̶a̶r̶k̶E̶n̶t̶i̶t̶y̶-̶>̶g̶e̶t̶(̶'̶u̶r̶l̶'̶)̶;̶
19        $bookmarkModel->url = Url::fromPersistedString($bookmarkEntity->get('url'));
2021    }
22}

Et enfin, nous l’avions oublié, la mise à jour de l’appel à l’updater

1namespace Application\UpdateBookmark;
2
3use Domain\Bookmark\ValueObject\Url;
4
5class UpdateBookmarkHandler
6{
7    public function __invoke(
8        UpdateBookmarkInput $input
9    ): ?Bookmark {
10        $bookmark = $this->bookmarkRepository->findById($input->id);
11        try {
12            $url = Url::fromString($input->url)
13        } catch (InvalidValueException $exception) {
14            return null;
15        }
16
17        $bookmark = $this->updater->update(
1819            $̵i̵n̵p̵u̵t̵-̵>̵u̵r̵l̵,̵
20            $url,
21        );
2223    }
24}

Je vous conseille vivement de créer les value object le plus tôt possible (dans les handler), et de ne travailler qu’avec ces objets dans toute la partie domaine. Franchement, c’est très confortable de travailler avec des objets que l’on connaît et en qui on peut avoir confiance.

La value-object est tout à fait disposée à recevoir toute validation spécifique à un projet. Par exemple, un value object « pourcentage » pourra valider que sa valeur est bien comprise entre 0 et 100. Ou bien des règles encore plus dirigées par le métier, comme une commission qui ne doit jamais 10%.

Toutes les propriétés peuvent ainsi être adaptées sous la forme de value-object : les chaînes de caractères, les entiers et décimaux, des tableaux… On peut même y stocker des données composées, comme une adresse postale composée d’un numéro, nom de voie, code postal, etc. C’est en fait un choix métier de faire ce regroupement. Et ce sera à la couche infra de se débrouiller pour la stocker, puis de la recréer depuis la persistance.

Une des régles importantes du DDD, c’est que toute classe métier créée doit être valide. Dans tout les cas on doit pouvoir compter sur une base minimale de logique en son sesin. Ceci est valable pour les objets métier (le bookmark) ou les value-object. Cependant, c’est le métier lui-même qui défini ce qu’est un objet valide.

Suivi des modificactions : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/aff719ccb6cb8e9e473d2411db331630ee99596cOuvre une nouvelle fenêtre

4.2. Validation de l’input

Nous venons de mettre en place un niveau de validation basique au niveau de value-object. Mais il y a d’autres validations qu’il nous faudra mettre en place. La couche domaine de l’application ne fait confiance à personne. Il nous faut donc mettre en place une validation des demandes qui y sont faites. Voyons donc la validation de input de mise à jour d’un bookmark.

1namespace Application\UpdateBookmark;
2
3class UpdateBookmarkValidator
4{
5    public function validate(
6        UpdateBookmarkInput $input
7    ): array {
8        $violations = [];
9        if (mb_strlen($input->title) < 3) {
10            $violations[] = 'Title must be at last 3 char long';
11        }
12        if (mb_strlen($input->title) > 1024) {
13            $violations[] = 'Title cannot be more than 1024 char long';
14        }
15
16        return $violations;
17    }
18}

On placera ici toute sorte de validation qu’on est en mesure de coder. On peut aussi créer des règles de validation basées sur plusieurs champs. Par exemple, la description est obligatoire si le titre fait moins de 10 caractères. Oui, les règles métiers ont parfois leurs raisons que la raison ignore ! Il nous faut maintenant exécuter cette validation en tête du handler.

1namespace Application\UpdateBookmark;
2
3use Domain\Bookmark\Exception\ViolationCollectionException;
4
5class UpdateBookmarkHandler
6{
78    private $validator;
9
10    public function __construct(
1112        UpdateBookmarkValidator $updateBookmarkValidator
13    ) {
1415        $this->validator = $updateBookmarkValidator;
16    }
17
18    public function __invoke(
19        UpdateBookmarkInput $input
20    ): ?Bookmark {
21        $errors = $this->validator->validate($input);
22        $bookmark = $this->bookmarkRepository->findById($input->id);
23        if (!$bookmark) {
24            r̵e̵t̵u̵r̵n̵ ̵n̵u̵l̵l̵;̵
25            $errors[] = 'Bookmark does not exists.';
26        }
27        if (count($errors)) {
28            throw new ViolationCollectionException('Errors occured with your request', $errors);
29        }
3031
32    }
33}

En plus de lever l’exception en cas d’invalidité de l’input, nous ajoutons aussi le cas où le bookmark est introuvable. Par exemple, si il a été supprimé par un autre utilisateur entre temps.

On a créé ici cette exception qui va se charger de porter la liste des erreurs à la mise à jour, auquels nous avons donné le nom de violation.

1namespace Domain\Bookmark\Exception;
2
3use Exception;
4use Throwable;
5
6class ViolationCollectionException extends Exception
7{
8    public $violationCollection;
9
10    public function __construct($message, array $violationCollection, $code = 0, Throwable $previous = null)
11    {
12        $this->violationCollection = $violationCollection;
13        parent::__construct($message, $code, $previous);
14    }
15}    

À ce stade, notre application aboutira à une erreur 500 si on essaie de modifier le titre d’un bookmark par « ab » car elle ne respecte pas la règle des 3 caractères. Du point de vue du domaine, la règle métier est respectée, la fonction est remplie.

Il va donc falloir s’adapter côté infrastructure pour en rendre compte correctement à l’utilisateur. Pour cela, nous allons pouvoir intercepter cette exception.

1namespace App\Controller;
2
3class BookmarksController extends AppController
4{
5    public function edit($id = null)
6    {
7        if ($this->request->is(['patch', 'post', 'put'])) {
89            try {
10                $handler($input);
11                $this->Flash->success('The bookmark has been saved.');
12                return $this->redirect(['action' => 'index']);
13            } catch (ViolationCollectionException $exception) {
14                $this->Flash->error(implode(' ', $exception->violationCollection));
15            }
16        }
1718    }
19}

N'oublions pas d’injecter le validateur via notre container maison.

1namespace App\Controller\Component;
2
3use Application\UpdateBookmark\UpdateBookmarkValidator;
4
5class ContainerComponent extends Component
6{
7    public function __construct(ComponentRegistry $registry, array $config = [])
8    {
910        $this->container[UpdateBookmarkValidator::class] = new UpdateBookmarkValidator();
11        $this->container[UpdateBookmarkHandler::class] = new UpdateBookmarkHandler(
12            $this->BookmarkRepository,
13            $this->container[BookmarkUpdater::class],
14            $this->container[UpdateBookmarkValidator::class]
15        );
16    }
17}

Et nous voici avec de magnifiques messages d’erreur sur la validation qui proviennent directement de la couche domaine. Nous pouvons retirer les validations faites côté Cakephp, dans la classe BookmarkTable.

Le résumé précis dans ce commit : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/8fa09557b8914d8b55a04b17b37eec888cf943cbOuvre une nouvelle fenêtre

Nous avons mis en place ici la validation sur la base de l’input, mais on pourrait utiliser la même technique pour faire de la validation au niveau de l’objet métier avec des règles différentes. Par exemple, pour ce point d’entrée, on pourrait refuser que l’url soit sur un autre protocole que http. Mais pour l’objet lui-même, il pourrait autoriser aussi ftp ou tout autre protocole.

4.3. Validation basée sur le contexte

Pour aller plus loin, on pourrait avoir des règles de validations basées sur le contexte global. Par exemple, le nom de domaine en cours, ou l’utilisateur connecté. Commençons par le validateur lui-même.

1namespace Domain\Bookmark\Validator;
2
3use Domain\Bookmark\Context\CurrentUserProvider;
4use Domain\Bookmark\Model\Bookmark;
5
6class BookmarkUpdaterValidator
7{
8    private $currentUserProvider;
9
10    public function __construct(
11        CurrentUserProvider $currentUserProvider
12    ) {
13        $this->currentUserProvider = $currentUserProvider;
14    }
15
16    public function validate(Bookmark $bookmark): array
17    {
18        $violations = [];
19        $currentUser = $this->currentUserProvider->getCurrentUser();
20        if ($currentUser === null || $currentUser->id !== $bookmark->user->id) {
21            $violations[] = 'You cannot modify that bookmark since you are not the owner';
22        }
23        return $violations;
24    }
25}

Nous utilisons un nouveau service ici pour retrouver l’utilisateur courant.

1namespace Domain\Bookmark\Context;
2
3use \Domain\Bookmark\Model\User;
4
5interface CurrentUserProvider
6{
7    public function getCurrentUser(): ?User;
8}

Il s’agit d’une interface, car nous laissons son implémentation pour l’infrastructure, dans le cadre d’un composant Cakephp.

1namespace App\Controller\Component;
2
3use App\Model\Entity\User;
4use Cake\Controller\Component;
5use Cake\Controller\Component\AuthComponent;
6use Domain\Bookmark\Context\CurrentUserProvider;
7use Domain\Bookmark\Model\User as UserModel;
8
9/**
10 * @property UserTransformerComponent $UserTransformer
11 * @property AuthComponent $Auth
12 */
13class CurrentUserProviderComponent extends Component implements CurrentUserProvider
14{
15    public $components = ['Auth', 'UserTransformer'];
16
17    public function getCurrentUser(): ?UserModel
18    {
19        return $this->UserTransformer->EntityToModel(new User($this->Auth->user()));
20    }
21}

Ajoutons tout de suite ces dépendances à notre container.

1namespace App\Controller\Component;
2
3use Domain\Bookmark\Validator\BookmarkUpdaterValidator;
4 
5/**
6* @property CurrentUserProviderComponent $CurrentUserProvider
7*/
8class ContainerComponent extends Component
9{
10    p̶u̶b̶l̶i̶c̶ ̶$̶c̶o̶m̶p̶o̶n̶e̶n̶t̶s̶ ̶=̶ ̶[̶'̶B̶o̶o̶k̶m̶a̶r̶k̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶'̶,̶ ̶'̶T̶a̶g̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶'̶]̶;̶
11    public $components = ['BookmarkRepository', 'TagRepository', 'CurrentUserProvider'];
12
13    public function __construct(ComponentRegistry $registry, array $config = [])
14    {
1516        $this->container[BookmarkUpdaterValidator::class] = new BookmarkUpdaterValidator($this->CurrentUserProvider);
17        $this->container[UpdateBookmarkHandler::class] = new UpdateBookmarkHandler(
1819            $this->container[BookmarkUpdaterValidator::class]
20        );
21    }
22}

Et bien il ne nous reste plus qu’à appliquer ce nouveau validateur au handler.

1namespace Application\UpdateBookmark;
2
3class UpdateBookmarkHandler
4{
56    private $updateValidator;
7
8    public function __construct(
910        BookmarkUpdaterValidator $bookmarkUpdaterValidator
11    ) {
1213        $this->updateValidator = $bookmarkUpdaterValidator;
14    }
15
16    public function __invoke(
17        UpdateBookmarkInput $input
18    ): ?Bookmark {
19        if (!$bookmark) {
20            $errors[] = 'Bookmark does not exists.';
21        } else {
22            $errors = array_merge($errors, $this->updateValidator->validate($bookmark));
23        }
24        if (count($errors)) {
25            throw new ViolationCollectionException('Errors occured with your request', $errors);
26        }
27    }
28}

Ainsi les erreurs de notre nouveau validateur vont s’ajouter aux autres erreurs déjà existantes.

Nous sommes maintenant en mesure d’établir des règles de validations basées sur toute sorte d’élucubration que le métier voudra voir implémenter !

Il nous reste une petite chose à faire cependant, c’est de transmettre les erreurs de validations à la création des value-object pour les afficher à l’utilisateur.

1namespace Application\UpdateBookmark;
2
3class UpdateBookmarkHandler
4{
5    public function __invoke()
6    {
78        try {
9            $url = Url::fromString($input->url);
10        } catch (InvalidValueException $exception) {
11            $errors[] = $exception->getMessage();
12        }
13        if (count($errors)) {
14            throw new ViolationCollectionException('Errors occured with your request', $errors);
15        }
1617        $bookmark = $this->updater->update(
18            U̵r̵l̵:̵:̵f̵r̵o̵m̵S̵t̵r̵i̵n̵g̵(̵$̵i̵n̵p̵u̵t̵-̵>̵u̵r̵l̵)̵,̵
19            $url,
20        )
2122    }
23}

Le résumé en code ici : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/ee34d69633084c34c1094217f6a37558502a2e57Ouvre une nouvelle fenêtre

Maintenant que nous avons un peu de code dans le domaine, il est clair que ce cœur de l’application est la partie la plus cruciale de l’application. La tester untiairement est donc sûrement une très bonne idée. Gardons cette tâche pour le chapitre 5.6, quand nous aurons une gestion des dépendances plus complète.

4.4. Langage ubiquiste

Dans le projet sur lequel nous travaillons ici, les termes utilisés sont Bookmark, Url, Tag, etc. Mais il pourrait survenir que ces termes ont été choisis par le développeur au moment de réaliser concrètement les implémentations.

Mais si on parle avec d’autres acteurs du projet, on va peut-être réaliser qu’il a un décalage de vocabulaire entre les acteurs. Ce décalage est source d’incompréhensions et donc d’erreurs. D’ailleurs, ce n’est pas les idées qui forment les mots, mais bien les mots qui forment les idées.

C’est pourquoi le DDD nous propose d’utiliser un langage ubiquiste, c'est-à-dire universel tous les acteurs du projet. Ça peut donner parfois des résultats étranges, notamment quand le métier parle en français. On se retrouve avec du bon franglais dans le code, comme une méthode getSociete ou bien une classe CreerSocieteHandler. Mais le gain en efficacité du projet en réel !

Le langage ubiquiste, unviversel pour le projet, participe à éviter de créer le décalage entre ce que disent les acteurs du projet, et ce que l’on peut lire dans le code.

5. Assurer l’avenir

5.1. Montée de version

Il est temps de passer à la mise à jour de toutes ces vielles sources. On va passer à la dernière version de Cakephp en deux étapes.

1docker-compose exec web composer require --update-with-dependencies "cakephp/cakephp:3.10.*"

On se retrouve avec un bon paquet de dépréciations relativement facile à traiter. Je vous passe les détails que vour pourrez consulter dans ce commit : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/ee69151acdce62c80da599580bd4ac883c0c6082Ouvre une nouvelle fenêtre

Passons à php 7.4 et composer 3 pour installer cakephp 4 dans de bonnes conditions.

1# .docker/php/Dockerfile
2F̶R̶O̶M̶ ̶p̶h̶p̶:̶7̶.̶1̶-̶f̶p̶m̶-̶b̶u̶s̶t̶e̶
3FROM php:7.4-fpm-buster
45C̶O̶P̶Y̶ ̶-̶-̶f̶r̶o̶m̶=̶c̶o̶m̶p̶o̶s̶e̶r̶:̶1̶ ̶/̶u̶s̶r̶/̶b̶i̶n̶/̶c̶o̶m̶p̶o̶s̶e̶r̶ ̶/̶u̶s̶r̶/̶l̶o̶c̶a̶l̶/̶b̶i̶n̶/̶c̶o̶m̶p̶o̶s̶e̶r̶
6COPY --from=composer /usr/bin/composer /usr/local/bin/composer
78
9
10
11docker-composer stop
12docker-composer up --build

Pour mettre à jour notre base de code, nous allons utiliser l’outil RectorOuvre une nouvelle fenêtre, c’est un outil d’automatisation de la modernisation du code lorsqu’il n’y a pas d'ambiguïté. Par exemple, si une classe implémente une interface, il va mettre à jour les signatures des méthodes de la classe, identiques à celles de l’interface.

D’ailleurs, Cakephp nous propose un outil pour automatiser cette montée de version, qui se base sur Rector.

1docker-compose exec web bash
2git clone https://github.com/cakephp/upgrade
3cd upgrade
4git checkout 4.x
5composer install --no-dev
6bin/cake upgrade filename templates /app/src/cakephp/Template/
7bin/cake upgrade rector --rules phpunit80 /app/tests/
8bin/cake upgrade rector --rules cakephp40 /app/src/cakephp/
9bin/cake upgrade rector --rules cakephp41 /app/src/cakephp/
10bin/cake upgrade rector --rules cakephp42 /app/src/cakephp/
11bin/cake upgrade rector --rules cakephp43 /app/src/cakephp/
12bin/cake upgrade rector --rules cakephp44 /app/src/cakephp/
13exit

On peut maintenant supprimer l’outil de montée de version

1docker-compose exec web rm -rf upgrade

Nous voilà prêt pour le passage sur cakephp 4.4 à proprement parler, la dernière en date.

1docker-compose exec web composer require --dev --update-with-dependencies "phpunit/phpunit:^8.0"
2docker-compose exec web composer remove cakephp/migrations cakephp/bake cakephp/debug_kit --dev
3docker-compose exec web composer require --update-with-dependencies "cakephp/cakephp:4.4.*"
4docker-compose exec web composer require cakephp/migrations:* cakephp/bake:* cakephp/debug_kit --dev --update-with-all-dependencies

Et puisque nous sommes si bien lancés, passons aussi à la dernière version de PHP.

1# .docker/php/Dockerfile
2F̶R̶O̶M̶ ̶p̶h̶p̶:̶7̶.̶4̶-̶f̶p̶m̶-̶b̶u̶s̶t̶e̶r̶
3FROM php:8.1-fpm-buster
45
6
7
8docker-composer stop
9docker-composer up --build

On remplit à nouveau la base de données

1docker-compose exec db bash
2mysql -u root -pbookmark < /dumps/app.sql bookmark
3mysql -u root -pbookmark < /dumps/i18n.sql bookmark
4mysql -u root -pbookmark < /dumps/sessions.sql bookmark
5exit
6`

On trouve alors des erreurs liées au fort typage introduit dans cakephp 4. Malgré ses efforts, Rector ne les a pas toutes réglées. Pour y palier, il nous suffit de mettre à jour les fichiers de cakephp qui sont versionnés mais qui ont évolués, à partir de https://github.com/cakephp/app/tree/4.x/srcOuvre une nouvelle fenêtre. C’est le cas notamment des fichiers bin/cake, config/bootstrap, src/cakephp/Application.php, etc.

Autre chose : les fichiers des template changent d’extension et passent de .ctp à .php ce qui me semble bien plus naturel puisque ce sont précisément des fichiers php. Et cakephp propose de les placer plutôt à la racine du projet. Il y a encore quelques petits ajustements, je vous laisse le loisir de voir cela dans ce commit : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/43519dd08804ec6637e0b52ef9c58a6ad0aebc38Ouvre une nouvelle fenêtre

Finalement, on revient rapidement à notre application fonctionnelle !

On récolte ici le fruits de nos efforts ! La séparation des couches de l’application nous a permis une montée de version de l’infrastructure simplifiée.

Si vous en avez déjà fait, vous savez que c’est un travail long et fastidieux. En minimisant la couverture de responsabilité de la couche infra, on simplifie grandement son évolution et sa maintenance. On touche du doigt un des énormes intérêts du DDD. D’ailleurs, dans l’interface de l’application, on ne rencontre pas de dysfonctionnement pour les fonctionnalités que nous avons passées en couche infra + application + domaine. Mais le reste des fonctionnalités rencontre des exceptions. Par exemple, la création d’utilisateur qui aboutit à l’erreur too few arguments to function Cake\\ORM\\Table::newEntity().

5.2. Dépendances et php-arkitect

Nous l’avons déjà précisé, les dépendances entre les couches de l’application ne se font que dans un seul sens : [infrastructure] -> [Application] -> [Domaine]

L’intérêt de cette contrainte, c’est que le domaine conserve une bonne indépendance de l’infrastructure. Et en cas de modification au niveau de l’infrastructure, elle ne devrait jamais impacter le domaine. En voici quelques exemples :

  • mise à jour du framework (comme on vient de le voir)
  • mettre en place un moteur de recherche pour indexer les données
  • utilisation d’un système de messaging en asynchrone pour l’envoi d’email

Par contre, une modification au niveau de la couche domaine peut tout à fait avoir des répercussions au niveau de l’infrastructure. Comme par exemple l’ajout d’un paramètres à un entrypoint. Pour s’assurer que cette règle restera respectée dans le temps, nous allons mettre en place un outil d’analyse : phpaskitectOuvre une nouvelle fenêtre.

1docker-compose exec web composer require phparkitect/phparkitect --dev

Écrivons maintenant la règle toute simple que la couche domaine ne doit dépendre d'aucun autre domaine de nom.

1// ./phparkitect.php
2declare(strict_types=1);
3
4use Arkitect\ClassSet;
5use Arkitect\CLI\Config;
6use Arkitect\Expression\ForClasses\NotHaveDependencyOutsideNamespace;
7use Arkitect\Expression\ForClasses\ResideInOneOfTheseNamespaces;
8use Arkitect\Rules\Rule;
9
10return static function (Config $config): void {
11    $mvcClassSet = ClassSet::fromDir(__DIR__.'/src');
12
13    $rules = [];
14
15    $rules[] = Rule::allClasses()
16        ->that(new ResideInOneOfTheseNamespaces('Domain'))
17        ->should(new NotHaveDependencyOutsideNamespace('Domain'))
18        ->because('we want the domain independent from the outside');
19
20    $config
21        ->add($mvcClassSet, ...$rules);
22};

On lance phpakitect avec la commande suivante

1docker-compose exec web vendor/bin/phparkitect check

C’est un échec, phparkitect nous a trouvé deux violations.

1Domain\Bookmark\Exception\ViolationCollectionException has 2 violations
2  depends on Exception, but should not depend on classes outside namespace Domain because we want a domain independent from the outside (on line 8)
3  depends on Throwable, but should not depend on classes outside namespace Domain because we want a domain independent from the outside (on line 12)

Puisque nous avons défini que nous ne voulions aucune dépendance, il a identifié la classe ```\Exception``` et l’interface ```\Throwable``` comme des dépendances extérieures. Nous allons donc les inclure parmi les classes autorisées.

1// ./phparkitect.php
23    $rules[] = Rule::allClasses()
4        ->that(new ResideInOneOfTheseNamespaces('Domain'))
5        -̶>̶s̶h̶o̶u̶l̶d̶(̶n̶e̶w̶ ̶N̶o̶t̶H̶a̶v̶e̶D̶e̶p̶e̶n̶d̶e̶n̶c̶y̶O̶u̶t̶s̶i̶d̶e̶N̶a̶m̶e̶s̶p̶a̶c̶e̶(̶'̶D̶o̶m̶a̶i̶n̶'̶)̶)̶
6        ->should(new NotHaveDependencyOutsideNamespace('Domain', ['Exception', 'Throwable',]))
7        ->because('we want the domain independent from the outside');
8

On a le même souci avec la dépendance à ```LogicException``` depuis la couche application. Cette identification a la vertu de garder une vision claire et nette des dépendances de la couche domaine. Vérifions aussi les dépendances de la couche application.

1// ./phparkitect.php
23    $rules[] = Rule::allClasses()
4        ->that(new ResideInOneOfTheseNamespaces('Application'))
5        ->should(new NotHaveDependencyOutsideNamespace('Application', ['Domain',]))
6        ->because('we want the application layer dependent on domain only');
7

Tant que nous sommes dans les règles, laissez moi vous montrez comment phparkitect peut aussi nous aider un code propre côté framework avec les règles suivantes.

1// ./phparkitect.php
23    $rules[] = Rule::allClasses()
4        ->that(new ResideInOneOfTheseNamespaces('App\Controller\Component'))
5        ->should(new HaveNameMatching('*Component'))
6        ->because('we want uniform naming for components');
7
8    $rules[] = Rule::allClasses()
9        ->that(new ResideInOneOfTheseNamespaces('App\Controller'))
10        ->andThat(new NotResideInTheseNamespaces('App\Controller\Component'))
11        ->should(new HaveNameMatching('*Controller'))
12        ->because('we want uniform naming for controllers');
13

La syntaxe de phparkitect parle d’elle même que j’ai à peine besoin de paraphraser : *Toute classe dans le nom de domaine App\\Controller\\Component devrait avoir un nom qui ressemble à Component pour garder une cohérence dans le nommage des composants.

Nouveau commit : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/5a522f3efee6b04356044af413052737d3ef1ea1Ouvre une nouvelle fenêtre

5.3. php-cs-fixer

Php-cs-fixer est un outil de formatage du code, en respect des standards et des normes, notamment les PSR-1 et 2. Il s’installe dans un dossier à part afin d’éviter des conflits inutiles entre dépendances, puisque c’est un outil indépendant.

1docker-compose exec web mkdir --parents tools/php-cs-fixer
2docker-compose exec web composer require --working-dir=tools/php-cs-fixer friendsofphp/php-cs-fixer

Puis on lance la correction automatisée

1docker-compose exec web tools/php-cs-fixer/vendor/bin/php-cs-fixer fix src

Il nous a trouvé une bonne vingtaine de corrections à réaliser. En voici un exemple :

1--- a/src/Domain/Bookmark/ValueObject/Url.php
2+++ b/src/Domain/Bookmark/ValueObject/Url.php
3@@ -6,7 +6,8 @@ class Url
4 {
5     public $value;
6 
7-    public static function fromString($url) {
8+    public static function fromString($url)
9+    {

Il ne réalise aucune modification qui puisse présenter un risque pour le fonctionnement de l’application. Les règles que php-cs-fixer applique sont discutables sur le plan de l’efficacité, mais elles ont l’énorme avantage d’uniformiser tout code. On se sent toujours plus à l’aise dans un format que l’on retrouve ! On va le configurer pour lui demander d’aller un peu plus loin, en utilisant toutes les règles sous le nom «PhpCsFixer».

1// ./.php-cs-fixer.php
2$finder = PhpCsFixer\Finder::create()
3    ->in(__DIR__ . '/src');
4
5$config = new PhpCsFixer\Config();
6
7return $config->setRules([
8    '@PhpCsFixer' => true,
9    'array_syntax' => ['syntax' => 'short'],
10])->setFinder($finder);

Puis on relance la correction automatisée sans préciser l’emplacement des sources puisque nous l’avons défini dans le fichier de configuration.

1docker-compose exec web tools/php-cs-fixer/vendor/bin/php-cs-fixer fix

Il nous a trouvé à nouveau 25 corrections. On envoi tout ça dans un nouveau commit : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/3584c595b9472d2b557e011ba716b317af947175Ouvre une nouvelle fenêtre

5.4. phpstan

Ajoutons maintenant un autre outil d’analyse statique qui tâche d’identifier de mauvaises pratiques au sein de l’application.

1 docker-compose exec web composer require --dev phpstan/phpstan

Et lançons le.

1docker-compose exec web vendor/bin/phpstan analyse src

Une seule erreur est identifiée.

1 ------ ----------------------------------------------------------------------------------
2  Line   cakephp/Controller/BookmarksController.php                                        
3 ------ ----------------------------------------------------------------------------------
4  173    Call to an undefined static method App\Controller\AppController::isAuthorized().  
5 ------ ----------------------------------------------------------------------------------

Et effet, c’est un reliquat de la façon de fonctionner du système d’autorisation de cakephp. On va dire que les utilisateurs ne sont pas autorisés par défaut pour le moment.

Phpstan offre 10 niveaux de vérification, de 0 (par défaut) à 9. Laissons de côté le code lié à Cakephp et lançons l’analyse sur les parties domaine et application uniquement.

1docker-compose exec web vendor/bin/phpstan analyse -l 9 ./src/Application ./src/Domain
23
4 [ERROR] Found 40 errors                                                                                                

Joie ! Voilà de quoi améliorer notre code !

Prenons une erreur en exemple.

1 ------ ----------------------------------------------------------------------------------------------------------
2  Line   Bookmark/ValueObject/Url.php                                                                              
3 ------ ----------------------------------------------------------------------------------------------------------
4  7      Property Domain\Bookmark\ValueObject\Url::$value has no type specified.            
5 ------ ----------------------------------------------------------------------------------------------------------

L’erreur est limpide, la propriété value de l’objet Url n’est pas typée. Et pour cause, ce n’était pas possible dans notre php 7.1 original. Les erreurs rencontrées sont en fait de 4 types :

  • Pas de typage pour une propriété
  • Pas de typage pour un paramètre de méthode
  • Pas de typage de retour de fonction
  • Pas de typage pour les éléments d’un tableau

En fait, phpstan va plus loin que les contrôles de php pour le typage et donc la cohérence globale de l’application. Et c’est bien ! Traitons tous ces points.

Si on lance l’analyse sur la partie infra maintenant.

1docker-compose exec web vendor/bin/phpstan analyse -l 9 ./src/cakephp
23
4 [ERROR] Found 93 errors                                                                                                

Il y a là bien plus de travail, nous n’allons pas les traiter. Mais on constate avec bonheur que les parties que nous avons passées sous le paradigme du DDD ne sont que très faiblement touchées ! Encore une bonne raison d’avoir sauté le pas.

Je vous épargne les détails des corrections, mais vous les trouverez dans le commit : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/f8788dc6f3b95389fd5a1c5de9a8201c69092268Ouvre une nouvelle fenêtre

5.5. Évolutions de PHP

À chaque nouvelle version, PHP apporte ces améliorations et nous offre de nouvelles fonctionnalités. Puisque nous en sommes à moderniser, utilisons les dernières évolutions de PHP. En voici trois exemples. Définir des propriétés directement depuis le constructeur d’une classe.

1namespace Application\GetBookmark;
2
3class GetBookmarkHandler
4{
5    p̶r̶i̶v̶a̶t̶e̶ ̶B̶o̶o̶k̶m̶a̶r̶k̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶ ̶$̶b̶o̶o̶k̶m̶a̶r̶k̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶;̶
6
7    public function __construct(
8        B̶o̶o̶k̶m̶a̶r̶k̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶ ̶$̶b̶o̶o̶k̶m̶a̶r̶k̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶
9        private BookmarkRepository $bookmarkRepository
10    ) {
11        $̶t̶h̶i̶s̶-̶>̶b̶o̶o̶k̶m̶a̶r̶k̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶ ̶=̶ ̶$̶b̶o̶o̶k̶m̶a̶r̶k̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶;̶
12    }
1314}    

Définir des propriétés en readonly.

1namespace Application\GetBookmark;
2
3class GetBookmarkInput
4{
5    p̶u̶b̶l̶i̶c̶ ̶i̶n̶t̶ ̶$̶i̶d̶;̶
6    public readonly int $id;
7}

Nommer les paramètres à l’appel d’une fonction.

1namespace App\Controller;
2
3class BookmarksController extends AppController
4{
5    public function view($id = null)
6    {
7        $̶i̶n̶p̶u̶t̶ ̶=̶ ̶n̶e̶w̶ ̶G̶e̶t̶B̶o̶o̶k̶m̶a̶r̶k̶I̶n̶p̶u̶t̶(̶$̶i̶d̶)̶;̶
8        $input = new GetBookmarkInput(id: $id);
910    }
11}

Une fois que tout cela est réalisé, mettons à profit nos outils de bonne tenue de code.

1docker-compose exec web tools/php-cs-fixer/vendor/bin/php-cs-fixer fix
2docker-compose exec web vendor/bin/phparkitect check
3docker-compose exec web vendor/bin/phpstan analyse -l 9 ./src/Application ./src/Domain

Quand tout est vert, encapsulons ces modifications dans un nouveau commit : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/d971a2f0d10865e008363f2b37249dc12a4b8758Ouvre une nouvelle fenêtre

La couche domaine doit aussi évoluer avec ces dépendances, même si nous l’avons limité autant que possible. Mais elle dépend bien entendu de PHP, et peut-être quelques classes natives du langage, comme \DateTime.

Point d’étape

Nous voici avec une application bien mieux découpée, controllée. Mais le voyage est loin d’être fini ! Dans la prochaine étape, nous verrons comment améliorer la qualité et la confiance dans notre application, et comment brancher une infrastructure plus moderne !