Vincent Beauvivre

Legacy to DDD du concret ! (partie 1)

19 minutes de lecture

L’utilisation d’outils pour améliorer la façon de développer des projets informatiques est la clef pour obtenir des résultats adaptés aux différents besoins priorisés d’un projet : coût, maintenabilité, rapidité, lisibilité, performance, évolutivité …

Parmi eux, le Domain Driven Development (que nous nommerons DDD) améliore considérablement la qualité, la lisibilité et la testabilité sur le long terme, au détriment du temps de mise en place. Le postulat de base du DDD est simple : toute programmation devrait refléter la réalité du domaine (ou métier) avant toute considération technique.

Que faire si on réalise après coup que cette technique aurait sûrement été précieuse pour un projet ? Le passage d’un projet legacy en DDD est-il si coûteux ? Voyons cela ensemble par un exemple concret. L’idée est ici est de partir d’un projet existant, d’y apporter les modifications, en pointant le point de vue DDD.

Disons qu’un client vient me demander de moderniser une application existante en php 7.1 (dernière release le 24 octobre 2019), basée sur le framework Cakephp en version 3.1. Prenons en exemple la démo de Cakephp : . Elle n’a pas été touchée depuis 2017. Je l’ai forkée pour l’occasion ici :

Attention : Appliquer le DDD à cette application n’est sûrement pas très pertinent. Elle ne présente pas de forte valeur métier. Mais elle a l’avantage d’être facile à comprendre et disponible en open-source ^^ Parfait pour un exemple !

Par souci de lisibilité, j’ai systématiquement placé les propriétés en public dans les différentes classes. À ne pas faire sur un vrai projet !

Notre premier objectif sera d’identifier et d’extraire toutes les actions métier et de les mettre à part dans la toute nouvelle couche domaine de cette application. Ensuite, nous aborderons le chemin de la modernisation, et même celui de l’extension.

1. Préparer le terrain

1.1. Faire fonctionner l’appli existante

Quand on a récupéré ce code, on va tâcher de le faire tourner en local. On peut voir qu’il y a de la configuration Ansible et Vagrant dessus. Mais comme nous sommes joueurs, je vous propose plutôt de passer par un petit docker-compose pour mettre ça en place ;)

1# ./docker-compose.yaml
2version: '3'
3
4services:
5  web:
6    build:
7      context: .docker/php
8    networks:
9      - backend
10    depends_on:
11      - db
12    volumes:
13      - .:/app:rw,cached
14    ports:
15      - 9050:80
16    entrypoint: /docker-entrypoint.d/start.sh
17
18  db:
19    image: mysql:5
20    networks:
21      - backend
22    volumes:
23      - ./config/schema:/dumps:ro,cached
24    environment:
25      - DEBUG=false
26      - MYSQL_USER=bookmark
27      - MYSQL_PASSWORD=bookmark
28      - MYSQL_DATABASE=bookmark
29      - MYSQL_ROOT_PASSWORD=bookmark
30
31networks:
32  backend:

Je vous épargne la conf de tout ceci, mais vous trouverez tout dans ce commit : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/a4f354a2a6a049eac909ec9ee0008909f1316f0aOuvre une nouvelle fenêtre

On lance le tout.

1docker-compose up

On créé et on rempli la base de données

1docker-compose exec db bash
2mysql -u root -pbookmark
3create database bookmark;
4CREATE USER 'bookmark'@'%' IDENTIFIED BY 'bookmark';
5GRANT ALL PRIVILEGES ON bookmark.* TO 'bookmark'@'%';
6FLUSH PRIVILEGES;
7exit
8mysql -u root -pbookmark < /dumps/app.sql bookmark
9mysql -u root -pbookmark < /dumps/i18n.sql bookmark
10mysql -u root -pbookmark < /dumps/sessions.sql bookmark
11exit

On peut déjà se connecter sur http://0.0.0.0:9050/users/loginOuvre une nouvelle fenêtre avec user@example.com et «password».

1.png

Pour chaque section, vous trouverez le parcours sur un historique git. En voici la première étape : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/a4f354a2a6a049eac909ec9ee0008909f1316f0aOuvre une nouvelle fenêtre

1.2. Laisser de la place à nos nouveaux outils

La prochaine tâche consistera à faire de la place parmi les sources du projet pour de nouveau éléments. On va donc dédier un espace du dossier ./src pour les sources pré-existante.

1mv src cakephp
2mkdir src
3mv cakephp ./src/

Puis on va modifier la résolution des domaines de noms pour qu’il trouve ces affaires au bon endroit.

1# ./composer.json
23    "autoload": {
4        "psr-4": {
5            "App\\": "src"
6            "App\\": "src/cakephp"
7        }
8    },
9

Nous allons conserver le domaine de nom *App* pour la partie Cakephp, pour ne pas avoir a le modifier partout dans l’existant. À noter qu’il faut aussi préciser à Cakephp où trouver ses templates et ses traductions.

1// config/app.php
23        'paths' => [
4            'plugins' => [ROOT . DS . 'plugins' . DS],
5            'templates' => [APP . 'cakephp/Template' . DS],
6            'locales' => [APP . 'cakephp/Locale' . DS],
7        ],
8

Le commit : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/7121450d3f51d802714368ab93d4390b0ff1080aOuvre une nouvelle fenêtre

2. Commencer par un bout : afficher un bookmark

Commençons pas un bout, le contrôleur BookmarksController::view. Vraisemblablement, il sert à afficher un bookmark, il faut donc le retrouver. Confions cette responsabilité à la couche métier !

Avant de modifier le contrôleur, créons le parcours de l’action coté domaine.

2.png

2.1. DDD : C’est parti !

En DDD, tout action faite au métier entre par une «porte» bien identifiée : l’« entrypoint ». Il se présente sous la forme d’un objet qui explicite l’intention métier. Créons cet objet pour l’action de retrouver un bookmark.

Cet entrypoint ce place dans la couche « application » qui sert à faire l’interface entre l’infrastructure (cakephp dans notre cas), et la couche du domaine (ou métier).

1namespace Application\GetBookmark;
2
3class GetBookmarkInput
4{
5    public $id;
6}

Puis nous créons un handler capable de la traiter.

1use \Domain\Bookmark\Repository\BookmarkRepository;
2use \Domain\Bookmark\Model\Bookmark;
3
4class GetBookmarkHandler
5{
6    private $bookmarkRepository;
7
8    public function __construct(
9        BookmarkRepository $bookmarkRepository
10    ) {
11        $this->bookmarkRepository = $bookmarkRepository;
12    }
13
14    public function __invoke(
15        GetBookmarkInput $input
16    ): ?Bookmark {
17        return $this->bookmarkRepository->find($input->id);
18    }
19}

Maintenant, le repository pour aller chercher la donnée.

1namespace Domain\Bookmark\Repository;
2
3use \Domain\Bookmark\Model\Bookmark;
4
5interface BookmarkRepository
6{
7    public function find(string $id): ?Bookmark;
8}

Vous remarquerez que ce n’est qu’une simple interface, nous mettrons son implémentation sur pied tout à l’heure, côté Cakephp. Et finalement, voici enfin l’objet métier !

1namespace Domain\Bookmark\Model;
2
3class Bookmark
4{
5    public $id;
6    public $title;
7    public $url;
8    public $description;
9}

OK, je l’ai simplifié au max pour vous faciliter la lecture :) À ne pas faire sur une vraie prod !

N’oublions pas de faire connaître cette nouvelle partie à l’autoloader.

1# ./composer.json
23    "autoload": {
4        "psr-4": {
5            "App\\": "src/cakephp",
6            "Application\\": "src/Application",
7            "Domain\\": "src/Domain"
8        }
9    },
10

Puis mettre à jour ses résolutions.

1docker-compose exec web composer dump-autoload

Résumé : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/064c6a84ac65753746ad9ae273b056857402c033Ouvre une nouvelle fenêtre

Cette action est très simple, elle répond au besoin rudimentaire d’un affichage. En DDD, c’est le domaine qui décide de ce points d’entrées. Il n’est pas question ici de faire un simple CRUD. Mais un point d’entrée à un véritable sens pour le métier. Ce qui est tout à fait possible pour ce point de lecture d’un bookmark.

2.2. Appel depuis le contrôleur Cakephp

Quand on essaye de faire appel à la couche métier depuis Cakephp, on se heurte à un problème : Cakephp n’a pas de mécanisme de container pour gérer les dépendances entre les classes. Qu’à cela ne tienne, nous allons créer notre propre container sur mesure. Utilisons pour cela le concept de composant de Cakephp

1namespace App\Controller\Component;
2
3use Application\GetBookmark\GetBookmarkHandler;
4use Cake\Controller\Component;
5use Cake\Controller\ComponentRegistry;
6use Exception;
7
8/**
9 * @property BookmarkRepositoryComponent $BookmarkRepository
10 */
11class ContainerComponent extends Component
12{
13    public $components = ['BookmarkRepository']; // C’est la magie de Cakephp pour charger un composant depuis un autre composant
14    private $container = [];
15
16    public function __construct(ComponentRegistry $registry, array $config = [])
17    {
18        parent::__construct($registry, $config);
19        
20        $this->container[GetBookmarkHandler::class] = new GetBookmarkHandler($this->BookmarkRepository); // On crée nos services ici
21    }
22
23    public function get($serviceName)
24    {
25        if (!isset($this->container[$serviceName])) {
26            throw new Exception('Cannot find service');
27        }
28
29        return $this->container[$serviceName];
30    }
31}

Définir chaque service individuellement sera sûrement un peu fastidieux, mais c’est le prix a payer pour faire cohabiter des paradigmes aussi éloignés que la philosophie de Cakephp et l’injection de dépendance.

En paramètre du constructeur de GetBookmarkHandler, nous avons donné le composant BookmarkRepositoryComponent. Il doit implémenter l’interface BookmarkRepository.

1namespace App\Controller\Component;
2
3use App\Model\Entity\Bookmark;
4use App\Model\Table\BookmarksTable;
5use Cake\Controller\Component;
6use Cake\ORM\TableRegistry;
7use Domain\Bookmark\Model\Bookmark as BookmarkModel;
8use Domain\Bookmark\Repository\BookmarkRepository;
9
10/**
11 * @property BookmarksTable $Bookmarks
12 */
13class BookmarkRepositoryComponent extends Component implements BookmarkRepository
14{
15    public function findById(string $id): ?BookmarkModel
16    {
17        $Bookmarks = TableRegistry::get('Bookmarks'); // C’est la magie de Cakephp pour récupérer l’objet table qui fait les requêtes
18        $bookmarkEntity = $Bookmarks->get($id);
19        if (!$bookmarkEntity instanceof Bookmark) {
20            return null;
21        }
22        $bookmarkModel = new BookmarkModel();
23        $bookmarkModel->id = $bookmarkEntity->id;
24        $bookmarkModel->title = $bookmarkEntity->get('title');
25        $bookmarkModel->url = $bookmarkEntity->get('url');
26        $bookmarkModel->description = $bookmarkEntity->get('description');
27
28        return $bookmarkModel;
29    }
30}

Dans la dernière partie, on construit l’objet métier à partir de l’entité Cakephp, car c’est le contrat exigé par la couche domaine.

Maintenant, il nous faut importer le composant Container dans la fonction initialize du contrôleur.

1    $this->loadComponent('Container');

Et nous pouvons nous en servir dans le contrôleur.

1namespace App\Controller;
2
3use \Application\GetBookmark\GetBookmarkInput;
4
5class BookmarksController extends AppController
6{
78
9    /**
10     * View method
11     *
12     * @param string|null $id Bookmark id.
13     * @return void
14     * @throws \Cake\Network\Exception\NotFoundException When record not found.
15     */
16    public function view($id = null)
17    {
18        $input = new GetBookmarkInput();
19        $input->id = $id;
20        $handler = $this->Container->get(GetBookmarkHandler::class);
21        $bookmarkModel = $handler($input);
22        $bookmark= new Bookmark();
23        $bookmark->set('title', $bookmarkModel->title);
24        $bookmark->set('url', $bookmarkModel->url);
25        $bookmark->set('description', $bookmarkModel->description);
26        $bookmark->set('id', $bookmarkModel->id);
27        $this->set('bookmark', $bookmark);
28        $this->set('_serialize', ['bookmark']);
29    }
3031}

Là, on vient de faire exactement le chemin inverse qu’auparavant : passer de l’objet métier à l’entité Cakephp, puisque c’est sa façon de travailler.

On peut constater que la partie Cakephp ne remplit aucune autre fonction que l’interprétation de la requête HTTP et le rendu d’un retour. C’est précisément ce que l’on attend de la couche infrastructure. Les actions métiers (sur les données) sont déléguées à la couche domaine.

Les modifications : https://github.com/cakephp/bookmarker-tutorial/commit/33595707c4f7e2dc3cf09b064fe2d6710129ac9cOuvre une nouvelle fenêtre

Et voilà ! Notre application passe maintenant par une couche domaine indépendante pour manipuler nos données ! Il n’y a pour le moment aucun intérêt, on a même perdu des fonctionnalités :) Mais progressons dans cette voie !

2.3. Iso-fonctionnel

Sur le site de l’application, à la page d’un bookmark, on peut voir qu’il manque deux éléments importants : l’utilisateur «propriétaire» et les tags associés. Il va nous falloir les ajouter en tant que modèles.

1namespace Domain\Bookmark\Model;
2
3class Tag
4{
5    public $id;
6    public $title;
7    public $bookmarks;
8}
9
10
11
12namespace Domain\Bookmark\Model;
13
14class User
15{
16    public $id;
17    public $email;
18    public $password;
19    public $dateOfBirth;
20    public $bookmarks;
21}

Et ajoutons ces propriétés au bookmark

1namespace Domain\Bookmark\Model;
2
3class Bookmark
4{
56    public $user;
7    public $tags;
8}

Malheureusement, nous ne pouvons pas typer ces propriétés : cette possibilité n’existe que depuis PHP 7.4. Nous reviendrons dessus.

Il nous reste a demander à l’ORM de Cakephp d’aller chercher ces données et de les hydrater.

1namespace App\Model\Table;
2
34use Domain\Bookmark\Model\Tag;
5use Domain\Bookmark\Model\User;
67
8class BookmarkRepositoryComponent extends Component implements BookmarkRepository
9{
10    public function findById(string $id): ?BookmarkModel
11    {
1213        $bookmarkEntity = $this->get($id);
14        $bookmarkEntity = $this->get($id, [
15            'contain' => ['Users', 'Tags']
16        ]);
1718        $userModel = new User();
19        $userModel->id = $bookmarkEntity->get('user')->get('id');
20        $userModel->email = $bookmarkEntity->get('user')->get('email');
21        $userModel->dateOfBirth = $bookmarkEntity->get('user')->get('dob');
22        $bookmarkModel->user = $userModel;
23
24        foreach ($bookmarkEntity->get('tags') as $tag) {
25            $tagModel = new Tag();
26            $tagModel->id = $tag->get('id');
27            $tagModel->title = $tag->get('title');
28            $bookmarkModel->tags[] = $tagModel;
29        }       
3031    }
32}

Et le chemin retour, quand on revient au contrôleur.

1namespace App\Controller;
2
34use App\Model\Entity\Tag;
5use App\Model\Entity\User;
67class BookmarksController extends AppController
8{
9
10    public function view($id = null)
11    {
1213        $user = new User();
14        $user->set('id', $bookmarkModel->user->id);
15        $user->set('email', $bookmarkModel->user->email);
16        $bookmark->set('user', $user);
17
18        $tags = [];
19        foreach ($bookmarkModel->tags as $tagModel) {
20            $tag = new Tag();
21            $tag->set('id', $tagModel->id);
22            $tag->set('title', $tagModel->title);
23            $tags[] = $tag;
24        }
25        $bookmark->set('tags', $tags);
2627    }
28}

Nos tags et notre utilisateur s’affiche maintenant correctement.

Par ici le commit : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/5f592d8bbc02a4e709d2184a7d1822447008d3f8Ouvre une nouvelle fenêtre

2.4. Petite refactorisation

Nous avons finalement deux systèmes pour représenter les mêmes données. Il y a un objet bookmark côté domaine (Model), mais également côté Cakephp (Entity).

Avec un peu d'expérience, on sent bien qu’on va passer beaucoup de temps à transformer nos données d’un système à l’autre, alors outillons nous pour ce boulot !

1namespace App\Controller\Component;
2
3use App\Model\Entity\Bookmark;
4use Cake\Controller\Component;
5use Domain\Bookmark\Model\Bookmark as BookmarkModel;
6
7/**
8 * @property TagTransformerComponent $TagTransformer
9 * @property UserTransformerComponent $UserTransformer
10 */
11class BookmarkTransformerComponent extends Component
12{
13    public $components = ['TagTransformer', 'UserTransformer'];
14
15    public function modelToEntity(BookmarkModel $bookmarkModel): Bookmark
16    {
17        $bookmarkEntity= new Bookmark();
18        $bookmarkEntity->set('title', $bookmarkModel->title);
19        $bookmarkEntity->set('url', $bookmarkModel->url);
20        $bookmarkEntity->set('description', $bookmarkModel->description);
21        $bookmarkEntity->set('id', $bookmarkModel->id);
22
23        if ($bookmarkModel->user) {
24            $bookmarkModel->user = $this->UserTransformer->entityToModel($bookmarkEntity->get('user'));
25        }
26        if ($bookmarkModel->tags) {
27            $bookmarkEntity->set('tags', array_map(
28                function ($tagModel) {
29                    return $this->TagTransformer->modelToEntity($tagModel);
30                },
31                $bookmarkModel->tags
32            ));
33        }
34
35        return $bookmarkEntity;
36    }
37
38    public function EntityToModel(Bookmark $bookmarkEntity): BookmarkModel
39    {
40        $bookmarkModel = new BookmarkModel();
41        $bookmarkModel->id = $bookmarkEntity->id;
42        $bookmarkModel->title = $bookmarkEntity->get('title');
43        $bookmarkModel->url = $bookmarkEntity->get('url');
44        $bookmarkModel->description = $bookmarkEntity->get('description');
45
46        if ($bookmarkEntity->get('user')) {
47            $bookmarkEntity->set('user', $this->UserTransformer->entityToModel($bookmarkEntity->get('user')));
48        }
49        if ($bookmarkEntity->get('tags')) {
50            $bookmarkModel->tags = array_map(
51                function ($tagEntity) {
52                    return $this->TagTransformer->entityToModel($tagEntity);
53                },
54                $bookmarkEntity->get('tags')
55            );
56        }
57
58        return $bookmarkModel;
59    }
60}

La profondeur de transformation de la donnée sera directement liée au données que l’on a de présente au départ. Par exemple, si l’utilisateur n’est pas fourni au départ, il restera vide.

Ici, j’ai décidé de séparer les différentes entités dans des transformateurs différents. Voici celui d’un tag.

1namespace App\Controller\Component;
2
3use Cake\Controller\Component;
4use Domain\Bookmark\Model\Tag as TagModel;
5use App\Model\Entity\Tag as TagEntity;
6
7class TagTransformerComponent extends Component
8{
9    public function modelToEntity(TagModel $tagModel): TagEntity
10    {
11        $tagEntity= new TagEntity();
12        $tagEntity->set('title', $tagModel->title);
13        $tagEntity->set('id', $tagModel->id);
14
15        return $tagEntity;
16    }
17
18    public function EntityToModel(TagEntity $tagEntity): TagModel
19    {
20        $tagModel = new TagModel();
21        $tagModel->id = $tagEntity->id;
22        $tagModel->title = $tagEntity->get('title');
23
24        return $tagModel;
25    }
26}

Et enfin l’utilisateur

1namespace App\Controller\Component;
2
3use App\Model\Entity\User;
4use App\Model\Entity\User as UserEntity;
5use Cake\Controller\Component;
6use Domain\Bookmark\Model\User as UserModel;
7
8class UserTransformerComponent extends Component
9{
10    public function modelToEntity(UserModel $userModel): UserEntity
11    {
12        $userEntity = new UserEntity();
13        $userEntity->set('id', $userModel->id);
14        $userEntity->set('email', $userModel->email);
15
16        return $userEntity;
17    }
18
19    public function EntityToModel(UserEntity $userEntity): UserModel
20    {
21        $userModel = new UserModel();
22        $userModel->id = $userEntity->id;
23        $userModel->email = $userEntity->get('email');
24
25        return $userModel;
26    }
27}

Il nous reste maintenant à nous en servir dans les parties concernées : le contrôleur et le repository

1namespace App\Controller;
23class BookmarksController extends AppController
4{
56    public function view($id = null)
7    {
8        $input = new GetBookmarkInput();
9        $input->id = $id;
10        $handler = $this->Container->get(GetBookmarkHandler::class);
11        $bookmarkModel = $handler($input);
12        $bookmark = $this->BookmarkTransformer->modelToEntity($bookmarkModel);
13
14        $this->set('bookmark', $bookmark);
15        $this->set('_serialize', ['bookmark']);
16    }
1718}
19
20
21
22namespace App\Controller\Component;
2324class BookmarkRepositoryComponent extends Component implements BookmarkRepository
25{
26    public $components = ['BookmarkTransformer'];
27
28    public function findById(string $id): ?BookmarkModel
29    {
30        $Bookmarks = TableRegistry::get('Bookmarks');
31        $bookmarkEntity = $Bookmarks->get($id, [
32            'contain' => ['Users', 'Tags']
33        ]);
34        if (!$bookmarkEntity instanceof Bookmark) {
35            return null;
36        }
37        return $this->BookmarkTransformer->EntityToModel($bookmarkEntity);
38    }
39
40}

Voilà qui simplifie les choses et les rend plus lisibles. Retrouvez le détail de ces modifications ici : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/b60acf7e0dd636b3f290c1f68826ee1c8043c6a6Ouvre une nouvelle fenêtre

2.5. Point d’étape

Nous avons donc le même résultat fonctionnel, tout en passant par notre couche domaine. Ce qui signifie que pour l’utilisateur, la refonte est parfaitement transparente ! Il est donc tout à fait possible de fusionner ces modifications au reste de la base de code, et d’envoyer tout ça en production.

Ce que nous sommes en train de faire là, c’est de la refonte en continu. Sans interruption de service, sans avoir à passer 1 ou 2 ans dans une refonte avant de pouvoir livrer. Pour un décideur ou un chef de projet, il y a là une grande valeur.

Dans notre cas, nous avons à faire à une simple mise à jour des données, c’est pourquoi la porter dans la couche domaine n’a pas été complexe, et aussi qu’elle n’a apporté aucune plus-value. Mais pour une application qui mérite de passer au DDD, le processus de porter les règles métier à la couche domaine permet de les identifier clairement, de les faire comprendre et valider par l’équipe, et de les modifier simplement si besoin.

Pour être plus concret, voici quelques exemple de règle métier complexes :

  • Une réduction sur un e-commerce qui s’applique sur les produits d’une catégorie à condition d’atteindre une certaine somme dans cette catégorie, et uniquement pour les clients n’ayant jamais commandé dans cette catégorie
  • Une règle comptable pour définir si la TVA peut être décomptée ou non d’une facture
  • Un retour d’erreur d’un diagnostic OBD sur un véhicule automobile

3. Modifier un bookmark

Une opération de lecture a été faite. Mais une opération d’écriture sera peut-être un peu plus complexe ? Attaquons-nous à la modification d’un bookmark.

3.png

3.1. Commencer par le domaine métier

À nouveau, on va commencer par implémenter le domaine métier.

Cela n’a rien d’un choix éditorial, mais ça correspond bien à la philosophie DDD : le domaine métier doit être au cœur de l’application. Elle est prioritaire et doit se suffire à elle-même. C’est à la partie infrastructure de s’adapter pour adhérer au domaine.

Créons donc l’input puis le handler.

1namespace Application\GetBookmark;
2
3class UpdateBookmarkInput
4{
5    public $id;
6    public $title;
7    public $url;
8    public $description;
9}
10
11
12
13namespace Application\GetBookmark;
14
15use Domain\Bookmark\Repository\BookmarkRepository;
16use Domain\Bookmark\Model\Bookmark;
17use Domain\Bookmark\Updater\BookmarkUpdater;
18
19class UpdateBookmarkHandler
20{
21    private $bookmarkRepository;
22    private $updater;
23
24    public function __construct(
25        BookmarkRepository $bookmarkRepository,
26        BookmarkUpdater $updater
27    ) {
28        $this->bookmarkRepository = $bookmarkRepository;
29        $this->updater = $updater;
30    }
31
32    public function __invoke(
33        UpdateBookmarkInput $input
34    ): ?Bookmark {
35        $bookmark = $this->bookmarkRepository->findById($input->id);
36        if (!$bookmark) {
37            return null;
38        }
39        $bookmark = $this->updater->update(
40            $bookmark,
41            $input->title,
42            $input->url,
43            $input->description,
44        );
45
46        if ($bookmark) {
47            $this->bookmarkRepository->persist($bookmark);
48        }
49
50        return $bookmark;
51    }
52}

On fait ici appel à un updater qui va véritablement appliquer les modifications au bookmark.

1namespace Domain\Bookmark\Updater;
2
3use Domain\Bookmark\Model\Bookmark;
4
5class BookmarkUpdater
6{
7    public function update(
8        Bookmark $bookmark,
9        string $title,
10        string $url,
11        string $description
12    ): ?Bookmark {
13        $bookmark->title = $title;
14        $bookmark->url = $url;
15        $bookmark->description = $description;
16
17        return $bookmark;
18    }
19}

Son utilisation peut sembler superflu pour le moment, mais il se rendra très utile prochainement.

Il nous faut mettre en place la mise à jour des données. Nous le faisons dans le repository pas simplicité. Mais il serait pertinent d’avoir une autre interface pour les écritures (Domain\\Bookmark\\Depository\\BookmarkDepository)

1namespace Domain\Bookmark\Repository;
2
3use \Domain\Bookmark\Model\Bookmark;
4
5interface BookmarkRepository
6{
78    public function persist(Bookmark $bookmark): void;
9}

Petit commit : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/f4aea58629c5dc793e9e241a2ab6c4fc55243d88Ouvre une nouvelle fenêtre

3.2. La mise à jour côté infra Comme nous l’avons fait pour la vue, on va faire appel à la couche métier pour les interactions avec les données

1namespace App\Controller;
23use Application\UpdateBookmark\UpdateBookmarkHandler;
4use Application\UpdateBookmark\UpdateBookmarkInput;
56class BookmarksController extends AppController
7{
8    public function edit($id = null)
9    {
10        if ($this->request->is(['patch', 'post', 'put'])) {
11            $input = new UpdateBookmarkInput();
12            $input->id = $id;
13            $input->title = $this->request->data['title'];
14            $input->url = $this->request->data['url'];
15            $input->description = $this->request->data['description'];
16            $handler = $this->Container->get(UpdateBookmarkHandler::class);
17            $bookmarkModel = $handler($input);
18            if ($bookmarkModel) {
19                $this->Flash->success('The bookmark has been saved.');
20                return $this->redirect(['action' => 'index']);
21            }
22            $bookmark = $this->BookmarkTransformer->modelToEntity($bookmarkModel);
23            $this->Flash->error('The bookmark could not be saved. Please, try again.');
24        } else {
25            $input = new GetBookmarkInput();
26            $input->id = $id;
27            $handler = $this->Container->get(GetBookmarkHandler::class);
28            $bookmarkModel = $handler($input);
29            $bookmark = $this->BookmarkTransformer->modelToEntity($bookmarkModel);
30        }
3132    }
33
34}

Il nous faut implémenter aussi la mise à jour des données.

1namespace App\Controller\Component;
23class BookmarkRepositoryComponent extends Component implements BookmarkRepository
4{
56    public function persist(BookmarkModel $bookmark): void
7    {
8        $Bookmarks = TableRegistry::get('Bookmarks');
9        $Bookmarks->save($this->BookmarkTransformer->ModelToEntity($bookmark));
10    }
11}    

Nous avons bien fait de mettre à part notre système de transformation des objet du système Cakephp au domaine métier, car nous l’avons utilisé pas moins de 5 fois déjà !

Nous avons créé de nouveau services qu’il nous faut préciser au container.

1namespace App\Controller\Component;
2
3use Application\UpdateBookmark\UpdateBookmarkHandler;
4use Domain\Bookmark\Updater\BookmarkUpdater;
56
7class ContainerComponent extends Component
8{
9    public function __construct(ComponentRegistry $registry, array $config = [])
10    {
1112        $this->container[GetBookmarkHandler::class] = new GetBookmarkHandler($this->BookmarkRepository); // On crée nos services ici
13        $this->container[BookmarkUpdater::class] = new BookmarkUpdater();
14        $this->container[UpdateBookmarkHandler::class] = new UpdateBookmarkHandler($this->BookmarkRepository, $this->container[BookmarkUpdater::class]);
15    }
1617}

Le commit de cette partie : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/355bc6e08aa9cc9d21116032be85a0956b433b1cOuvre une nouvelle fenêtre

À présent, nous avons une interface opérationnelle pour la modification d’un bookmark, mais il manque une fonctionnalité ;)

3.3. Les entités liées

Nous avons traité le cas simple ici, mais nous avons écarté la liaison entre bookmark et tag. Il y a ici une spécificité métier qu’il va nous falloir implémenter : la liste des tags est donnée sous la forme d’une liste de titres. Quand un titre inexistant parmi les tags est trouvé, il faut créer ce tag. C’est une action qui est faite pour le moment dans la classe App\Model\Table\BookmarksTable

Nous allons mettre à profit notre BookmarkUpdater pour y matérialiser cette logique.

1namespace Domain\Bookmark\Updater;
2
34use Domain\Bookmark\Model\Tag;
5use Domain\Bookmark\Repository\TagRepository;
6
7class BookmarkUpdater
8{
9    private $tagRepository;
10
11    public function __construct(
12        TagRepository $tagRepository
13    ){
14        $this->tagRepository = $tagRepository;
15    }
16
17    public function update(
1819        array $tagsTitle
20    ): ?Bookmark {
2122        $bookmark->tags = [];
23        foreach ($tagsTitle as $tagTitle) {
24            $tag = $this->tagRepository->findByTitle($tagTitle);
25            if (!$tag) {
26                $tag = new Tag();
27                $tag->title = $tagTitle;
28            }
29            $bookmark->tags[] = $tag;
30        }
31
32        return $bookmark;
33    }
34}

Ce code n'est pas optimisé, puisqu’on fait autant d'appels à la base de données que de titres de tag. Je l’ai fait ainsi pour en faciliter la lecture. Nous avons fait appel à un nouveau repository ici.

1namespace Domain\Bookmark\Repository;
2
3interface TagRepository
4{
5    public function findByTitle(string $title): ?Tag;
6}

En remontant la chaîne, on ajoute ce paramètre au handler.

1namespace Application\UpdateBookmark;
2
3class UpdateBookmarkHandler
4{
5    public function __invoke(UpdateBookmarkInput $input)
6    {
78        $bookmark = $this->updater->update(
910            $input->tagsTitle
11        );
1213    }
14}

Puis au tour de l’input

1namespace Application\UpdateBookmark;
2
3class UpdateBookmarkInput
4{
56    public $tagsTitle;
7}

En résumé ici : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/4ecad97fb7f82f7951bc956cda763172484c0047Ouvre une nouvelle fenêtre

3.4. Tags sur la couche infra

Côté contrôleur, il nous faut préciser la liste des titres de tags passés en paramètre.

1namespace App\Controller;
2
3class BookmarksController extends AppController
4{
56    public function edit($id = null)
7    {
8        if ($this->request->is(['patch', 'post', 'put'])) {
910            $input->tagsTitle = array_filter(
11                array_unique(
12                    array_map(
13                        'trim',
14                        explode(',', $this->request->data['tag_string'])
15                    )
16                )
17            );
1819        }
2021    }
22}

Et nous avons introduit un tout nouveau TagRepository que nous implémentons maintenant

1namespace App\Controller\Component;
2
3use App\Model\Table\BookmarksTable;
4use Cake\Controller\Component;
5use Cake\ORM\TableRegistry;
6use Domain\Bookmark\Model\Tag;
7use Domain\Bookmark\Repository\TagRepository;
8
9/**
10 * @property BookmarksTable $Bookmarks
11 * @property TagTransformerComponent TagTransformer
12 */
13class TagRepositoryComponent extends Component implements TagRepository
14{
15    public $components = ['TagTransformer'];
16
17    public function findByTitle(string $title): ?Tag
18    {
19        $Tags = TableRegistry::get('Tags');
20        $tagEntity = $Tags->find()->where(['Tags.title =' => $title])->first();
21        if (!$tagEntity) {
22            return null;
23        }
24
25        return $this->TagTransformer->EntityToModel($tagEntity);
26    }
27}

Pour parachever le tout, ne pas oublier de mettre à jour notre service avec sa nouvelle dépendance.

1namespace App\Controller\Component;
23class ContainerComponent extends Component
4{
56    public function __construct(ComponentRegistry $registry, array $config = [])
7    {
89        $this->container[BookmarkUpdater::class] = new BookmarkUpdater();
10        $this->container[BookmarkUpdater::class] = new BookmarkUpdater($this->TagRepository);
11    }
12}

Et puisque nous l’avons transféré côté métier, nous pouvons débrancher la fonctionnalité côté Cakephp en supprimant les méthodes beforeSave et _buildTags de la classe App\\Model\\Table\\BookmarksTable.

En voici le résumé : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/4184cb19417b389b069ad941040aa8a5e2f314efOuvre une nouvelle fenêtre

Nous avons maintenant un cas complet d’écriture en passant par la couche du domaine. Comme pour la vue, nous sommes iso-fonctionnel avec l’existant. Nous pouvons pousser en prod ^^

3.5. Point d’étape

Nous avons vu deux cas de base d’une application web : récupération d’une donnée et modification d’une donnée. Sur la base de cette approche, toute action pourra être maintenant réalisée. Et toute règle métier pourra être implémentée du côté domaine. Nous voici parés d’un bon pied pour moderniser cette petite application.

Nous pourrions maintenant passer en revue toutes les actions des contrôleurs pour leur appliquer le même sort. Mais je vous laisse cela en travaux pratiques :) Les MR sont les bienvenues sur le repo.

Si vous avez surement remarquer que les dépendances ne se font que dans un seul sens : l’infrastructure utilise le domaine, mais le domaine reste bien indépendante la couche infrastructure.

Il nous restera cependant quelques épines. Il restera côté Cakephp des règles métier qui devraient trouver leur place dans le domaine. Je pense notamment à la validation des entités. Elle passe aujourd’hui par la classe BookmarkTable, notamment à travers la méthode validationDefault. Il nous faudra bien transférer ces règles dans le domaine. Et d’une manière générale, il faudra dépouiller toutes les classe finissant par Table, à l'exception de la méthode initialize.

Un autre point tout à fait remarquable aussi : Cakephp n’est pas le framework le plus moderne, et pourtant, on peut l’adapter pour cet exercice. On conserve ainsi la simplicité d’un framework « rapide », tout en apportant la puissance d’un métier solide. On peut même envisager de conserver ce framework dans le cadre de cette modernisation, Nous verrons cela dans un prochain article que nous publierons prochainement !