Vincent Barrault

Passer au CQRS avec Symfony et le composant Messenger

Passer au CQRS avec Symfony et le composant Messenger

Table des matières

Comment utiliser simplement Messenger de Symfony pour respecter le pattern CQRS ?

En temps normal, quand je développe une application Symfony, je segmente mon code en 3 couches:

Toutes les opérations de lecture et d'écriture se retrouvent dans les Controller/Manager/Repository associés.

Mais une autre segmentation, qui se nomme CQRS, est très utilisée en association avec l'event sourcing pour séparer les intentions de lecture et d'écriture.

Cela peut, par exemple, permettre d'allouer plus de serveurs à la lecture qu'à l'ecriture, ou inversement.

Les Commandes

Les commandes représentent donc les intentions d'écriture dans le système. Chaque commande devra implémenter l'interface Command et définir des attributs comme le ferai un modèle pour stocker les informations de la demande.

Par exemple, une commande pour bloquer un compte utilisateur pourrait avoir un champ userId contenant l'id de l'utilisateur à bloquer.

./src/Common/Command/Command.php
<?php

declare(strict_types=1);

namespace App\Common\Command;

interface Command
{
}

Pour chaque commande, je crée un Handler qui est chargé d'effectuer une action dans le système. Pour reprendre mon exemple de la commande qui bloquerait un utilisateur, l'Handler de cette commande pourrait éventuelement avoir un UserRepository en dépendance et faire un appel à une fonction updateUser de ce dernier.

./src/Common/Command/CommandHandler.php
<?php

declare(strict_types=1);

namespace App\Common\Command;

interface CommandHandler
{
}

Et enfin, le pattern CQRS recommande de créer un Bus de commande pour pouvoir facilement y intégrer des middleware par la suite.

Un middleware possible sur le Bus des commandes serait un middleware transactionel pour une base de données relationelle.

./src/Common/Command/CommandBus.php
<?php

declare(strict_types=1);

namespace App\Common\Command;

interface CommandBus
{
    function dispatch(Command $command): void;
}

Query

Les Query représentent, elles, des intentions de lecture. Chaque Query devra implémenter l'interface Query et définir des attributs qui représenteront les données utiles pour l'élaboration de la Query.

Par exemple, si j'imagine une Query qui retourne une liste paginée d'utilisateurs, deux attributs possibles seront: pageNumber et pageSize.

./src/Common/Query/Query.php
<?php

declare(strict_types=1);

namespace App\Common\Query;

interface Query
{
}

Comme pour les commandes, chaque Query a son propore Handler qui récupère l'intension de lecture et qui effectue la collecte des données.

./src/Common/Query/QueryHandler.php
<?php

declare(strict_types=1);

namespace App\Common\Query;

interface QueryHandler
{
}

En revanche, le QueryBus, contrairement au CommandBus, retourne un mixed qui sera la réponse pour le Controller.

./src/Common/Query/QueryBus.php
<?php

declare(strict_types=1);

namespace App\Common\Query;

interface QueryBus
{
    function ask(Query $query): mixed;
}

Installation du composant Messenger de Symfony

Maintenant que les interfaces sont définies, je vais passer à l'implémentation. Le composant Symfony Messenger sera parfait pour mes implémentations de Bus.

Je vais commencer par l'installer:

composer require symfony/messenger

Et modifier la configuration de ce dernier comme ceci:

./config/packages/messenger.yaml
framework:
    messenger:
        default_bus: command.bus
        buses:
            command.bus:
                middleware:
                    - validation
                    # - doctrine_transaction
            query.bus:
                middleware:
                    - validation
            # event.bus:
            #     default_middleware: allow_no_handlers
            #     middleware:
            #         - validation

Et enfin, je vais faire deux classes qui vont venir wrapper le MessengerBusInterface pour ne pas avoir à l'utiliser directement.

./src/Common/Command/MessengerCommandBus.php
<?php

declare(strict_types=1);

namespace App\Common\Command;

use Symfony\Component\Messenger\MessageBusInterface;

final class MessengerCommandBus implements CommandBus
{
    /**
     * @var MessageBusInterface
     */
    private $commandBus;

    public function __construct(MessageBusInterface $commandBus)
    {
        $this->commandBus = $commandBus;
    }

    public function dispatch(Command $command): void
    {
        $this->commandBus->dispatch($command);
    }
}

Pour le MessengerQueryBus, je peux rajouter le trait HandleTrait, qui simplifie la récupération de la réponse du Bus.

./src/Common/Query/MessengerQueryBus.php
<?php

declare(strict_types=1);

namespace App\Common\Query;

use Symfony\Component\Messenger\HandleTrait;
use Symfony\Component\Messenger\MessageBusInterface;

final class MessengerQueryBus implements QueryBus
{
    use HandleTrait {
        handle as handleQuery;
    }

    public function __construct(MessageBusInterface $queryBus)
    {
        $this->messageBus = $queryBus;
    }

    public function ask(Query $query): mixed
    {
        return $this->handleQuery($query);
    }
}

Et voilà, il ne reste plus qu'à créer des commandes et des queries pour satisfaire notre métier.