Symfony et le CQRS

Symfony et le CQRS

Je m'abonne
Temps de lecture: 22 mins

Cet article signe ma reprise en 2026, et pas n'importe quel sujet : un pattern d'architecture. Le C.Q.R.S pour Command Query Responsibility Segregation (ouais, en 2026 on fait mal là…).

C'est quoi tout ça ?

Un pattern d'architecture, c'est un peu comme un design pattern (Factory, Observer, Strategy…), mais pour l'architecture en général, alors qu'un design pattern sera plutôt localisé sur une classe et un comportement en particulier.

Je suis certain que tu connais d'autres patterns que le C.Q.R.S, comme celui par défaut de Symfony : le M.V.C (Model Vue Controller). Tu as peut être déjà vu le D.D.D (Domain Driven Design), qui n'est pas vraiment un pattern mais une philosophie de code (code agnostique).

Le M.V.C c'est le "standard" du web.

On a notre point d'entrée de requête par le contrôleur, qui appellera la data via le modèle (base de données) et mettra en forme tout ça dans la vue, qui nous reviendra dans la réponse.

Pattern MVC
Pattern M.V.C

Notre point central pour un M.V.C, c'est donc le contrôleur, notre chef d'orchestre.

Le point noir de ce pattern, c'est que tout repose sur le contrôleur et qu'on finit invariablement par obtenir du code spaghetti dans celui-ci pour gérer de la logique métier. Alors qu'au final, il est juste censé être un chef d'orchestre.

On tente d'y mettre des classes Services, des Handlers (fourre-tout) etc ... ce qu'on obtient au final c'est juste du code bien legacy.

C'est une approche qui a fait ses preuves et qui est toujours très utilisée. Mais dans le cas d'une API, par exemple, on s'encombre de la vue.

C'est pour ça qu'aujourd'hui on parle de C.Q.R.S, qui va nous permettre de réduire la responsabilité du contrôleur à uniquement son rôle principal : être le point d'entrée et de sortie de notre application.

Durant l'article, je parle uniquement d'API, mais garde en tête qu'on peut mixer les patterns. J'ai déjà fait du C.Q.R.S et du M.V.C dans la même application, sur différents points d'entrée. L'important, avant tout, c'est de bien définir le besoin pour adapter la conception derrière.

1. Le pattern C.Q.R.S

Ce pattern, comme évoqué dans l'introduction, va nous permettre de décharger notre contrôleur de la responsabilité de la logique métier ainsi que de l'écriture et de la lecture de la base de données.

À aucun moment, notre contrôleur ne va prendre la responsabilité sur la récupération et la modification des données.

Et au sein même de notre conception, les actions de modification seront séparées des actions de lecture : c'est le principe de ségrégation des responsabilités.

Voyons comment s'articule ce pattern par rapport au M.V.C pour la lecture :CQRS QueryOn a donc notre contrôleur où l'information ne fait que transiter pour attérir dans un Message de type Query (on verra par la suite) puis pris en charge par un Bus jusqu'à notre Handler qui sera charger de lire dans la base puis de retourner tout ça (sous forme de DTO par exemple).

Pour l'écriture :

Command CQRS

On voit donc trois termes faire leur apparition : Command, Query et Bus.

Il faut voir les messages (Command & Query) comme des « ordres de mission » que l'on envoie au système. Celui-ci va les interpréter et exécuter les actions qui en découlent en fonction du contenu du message.

De cette manière, on peut créer autant de messages que d'actions à effectuer et ainsi bien séparer les responsabilités.

Les messages transitent via des bus (promis, ce ne sont pas ceux de la SNCF — le but, c'est qu'ils arrivent à l'heure ! 😄), qui sont chargés de les envoyer vers les bons handlers.

Par analogie, imagine-toi dans une gare avec une enveloppe dans les mains. Un contrôleur vient te voir, lit son contenu et t'indique immédiatement le bon train à prendre.
Eh bien, notre pattern, c'est exactement ça : c'est le type du message qui permet au bus de router vers le bon handler.

2. La mise en place

Ne perdons pas de temps, installons notre projet Symfony et le composant Messenger, qui va bien nous aider pour le C.Q.R.S. Messenger nous permettra de gérer le bus et les différents middlewares que l'on pourra utiliser un peu plus tard.

Les étapes :

  • symfony new cqrs --version="8.0.*"
  • composer require doctrine/orm symfony/serializer-pack symfony/object-mapper symfony/uid symfony/maker-bundle symfony/messenger
  • On lance ensuite notre app et notre base de données avec docker compose up -d --build && symfony server:start -d

On se crée un contrôleur pour gérer notre arrivée dans l’application :

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/user')]
final class UserController extends AbstractController
{
    public function __construct() {}
    
    #[Route('/new', name: 'app_new_user', methods: ['POST'])]
    public function new(): Response
    {

    }
    
    #[Route('/{id}', name: 'app_get_user', requirements: ['id'=>'(?!new$).+'], methods: ['GET'])]
    public function get(string $id): Response
    {

    }
}

On a donc un /user/new, qui va gérer la création d'un utilisateur, et un /user/{id}, qui va gérer la récupération d'un utilisateur.

On va tout de suite créer notre première entité (et la seule) : User.

Pour l'exemple, Symfony risque de râler si tu fais la commande helper make:user, car nous n'avons pas installé le composant Security. Pour la démo, on utilisera juste User en tant qu'entité avec make:entity. (Tu peux installer Security, mais ce n'est vraiment pas nécessaire).

Symfony qui rale

Pour nôtre entité, elle doit avoir les champs suivants :

  • firstname (varchar)

  • lastname (varchar)

  • gender (bool et 0 par défaut)

  • email (varchar)

Avant de faire la migration, on va se rendre dans notre entité pour générer nous-mêmes les ID qui serviront de clé primaire à la base de données, afin de ne pas donner cette responsabilité à la base de données. De cette façon, on va pouvoir, par la suite, faire appel à des middlewares (je prépare le terrain pour les points suivants).

#[ORM\Id]
#[ORM\Column]
private ?string $id = null;

...
    
public function __construct()
{
    $this->generateId();
}

public function generateId(): void
{
    $this->id = Uuid::v4();
}

On fait la migration et on l'envoie à la base : symfony console make:mig && symfony console d:m:m .

On va pouvoir se concentrer sur le pattern et pour commencer, celui de la Command.

3. La Command

Plaçons nous sur la partie /new de notre application : 

#[Route('/user')]
final class UserController extends AbstractController
{
    public function __construct() {}
    
    #[Route('/new', name: 'app_new_user', methods: ['POST'])]
    public function new(): Response
    {

    }

Dans un premier temps, on va avoir besoin de récupérer sur notre route un Json avec les informations du futur user.

Pour bien gérer la réception, on va se créer un D.T.O dans src/DTO :

namespace App\DTO;

final class NewUserDTO
{
    public function __construct(string $firstname, string $lastname, string $email, ?bool $gender = false) {}
}

et on va l'injecter dans notre route avec un attribut MapRequestPayload :

#[Route('/new', name: 'app_new_user', methods: ['POST'])]
public function new(#[MapRequestPayload] NewUserDTO $newUserDTO): Response
{
    dd($newUserDTO);
}

Si on fait une requête sur /user/new :

JSON

On a bien notre DTO hydraté automatiquement, on va pouvoir passer à la création du Message.

DTO hydraté

Le message d'une Command, c'est finalement un simple objet contenant les propriétés du D.T.O. Un peu redondant à première vue… mais l'usage est différent.

Un D.T.O, c'est la traduction d'une vue, une sorte de "contrat de sortie" tandis qu'un message est vraiment lié à la logique métier. Et dans un message, il faut éviter d'envoyer des objets, facilement sérializable.

On se crée donc notre NewUserCommand, que l'on va mettre dans src/Messenger/Message :

namespace App\Messenger\Message;

use App\DTO\NewUserDTO;

class NewUserCommand
{
    private string $firstname;
    private string $lastname;
    private bool $gender;
    private string $email;
    
    public function __construct(NewUserDTO $newUserDTO
    ) {
        $this->firstname = $newUserDTO->firstname;
        $this->lastname = $newUserDTO->lastname;
        $this->gender = $newUserDTO->gender;
        $this->email = $newUserDTO->email;
    }

    public function getFirstname(): string
    {
        return $this->firstname;
    }

    public function getLastname(): string
    {
        return $this->lastname;
    }

    public function isGender(): bool
    {
        return $this->gender;
    }

    public function getEmail(): string
    {
        return $this->email;
    }
}

Je range ça dans Messenger/Message, mais si tu veux ranger un peu autrement, libre à toi. On pourrait gérer un peu en mode D.D.D avec du Domain pour le coup, car c'est vraiment lié au métier ici. Si tu n'es pas familier avec le D.D.D, on va éviter de l'aborder pour aujourd'hui de toute façon !

Bien ! On a créé notre message, il faut encore que l'on crée notre Handler, qui va prendre en charge le message.

La structure d'un handler, c'est toujours la même, peu importe le type de message (Query ou Command) :

  • L'attribut du handler #[AsMessageHandler]

  • Le __construct()

  • Le __invoke(MonMessage $message)

C'est grâce au type de message dans notre fonction __invoke() que le bus sait sur quel handler il devra déposer le message et continuer ainsi la chaîne de traitement.

Notre handler se placera dans Messenger/CommandHandler, histoire de ranger un peu les choses :

namespace App\Messenger\CommandHandler;

use App\Entity\User;
use App\Messenger\Message\NewUserCommand;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
class NewUserCommandHandler
{
    public function __construct() {}
    public function __invoke(NewUserCommand $message)
    {
		dump("Handler powaaaaa");
        dd($message);
    }
}

Actuellement, on a donc :

  • Notre D.T.O récupéré par notre route "POST".

  • Notre message qui récupère les infos du D.T.O.

  • Notre handler qui traitera le message.

Mais il nous manque le maillon manquant de la chaîne : comment envoyer notre message vers notre handler ? Avec le bus, bien sûr, mais il faut quand même lui indiquer, car actuellement notre message, en termes de structure, n'est qu'une classe comme un D.T.O.

On se rend donc dans notre contrôleur et on crée notre message :

#[Route('/new', name: 'app_new_user', methods: ['POST'])]
public function new(#[MapRequestPayload] NewUserDTO $newUserDTO): Response
{
    $message = new NewUserCommand($newUserDTO);
}

On indique ensuite au Bus de prendre le message et de le "dispatch" :

#[Route('/new', name: 'app_new_user', methods: ['POST'])]
public function new(#[MapRequestPayload] NewUserDTO $newUserDTO, MessageBusInterface $messageBus): Response
{
    $message = new NewUserCommand($newUserDTO);

    $stamp = $messageBus->dispatch($message);
        
    dd($stamp);
}

Ce dispatch() permet au bus de dispatcher les messages vers les handlers correspondants, notre maillon manquant. On stocke ça dans $stamp pour pouvoir avoir accès au retour du message.

En effet, quand le bus transporte le message, il encapsule celui-ci dans une enveloppe où il met plusieurs informations supplémentaires.

Si on refait la requête, on a bien le message qui est délivré au handler.

Retour dd()On peut rajouter un peu de logique métier dans notre handler pour enregistrer le User :

public function __construct(private readonly EntityManagerInterface $entityManager) {}

public function __invoke(NewUserCommand $message): string
{
    $newUser = new User();

    $newUser->setEmail($message->getEmail())->setFirstname($message->getFirstname())->setLastname($message->getLastname())->setGender($message->isGender());

    $this->entityManager->persist($newUser);

    $this->entityManager->flush();

    return $newUser->getId();
}

Petit disclaimer :

Dans la pratique, quand on gère des UUID, il serait très rare (voire quasiment improbable) d'obtenir une collision avec un autre UUID déjà enregistré en base. Mais comme on aime l'improbable, il faudrait prévoir le cas où Doctrine nous retourne une erreur de contrainte d'unicité dans un try/catch et régénérer un UUID. Pour éviter de complexifier le code, je ne l'inclus pas, mais évidemment, en production, il faudra gérer ce cas.

On obtient bien notre envelope ($stamp), qui nous permet d'avoir accès au résultat :

Notre $stamp

C'est d'ailleurs pas très accessible d’y avoir accès comme ça alors on va améliorer un peu notre Bus pour récupérer automatiquement le result de notre HandleStamp.

4. Pimp my Bus

Prêt à mettre les mains dans le cambouis ? Créons nous une classe pour encapsuler notre Bus dans src/Messenger :

namespace App\Messenger;

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

class TransportBus
{
    use HandleTrait;

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

    public function transport(object $message): mixed
    {
       return $this->handle($message);
    }
}

Ok, on explique un peu cette classe.

Ici, on utilise un Trait qui nous permet d'avoir accès à la fonction handle(), très pratique pour récupérer la valeur du dernier stamp, surtout quand on en a besoin.

Pourquoi, dans notre __construct(), utilise-t-on MessageBusInterface ? Tout simplement parce que dans notre HandleTrait on utilise un private MessageBusInterface $messageBus.

On a donc besoin de l'initialiser.

De cette manière dans notre Controller on va pouvoir remplacer notre Bus par notre Superbus.
Superbus

public function __construct(private readonly TransportBus $transportBus) {}
    
#[Route('/new', name: 'app_new_user', methods: ['POST'])]
public function new(#[MapRequestPayload] NewUserDTO $newUserDTO): Response
{
    $message = new NewUserCommand($newUserDTO);

    $userId = $this->transportBus->transport($message);

    dd($userId);
}

On retente une nouvelle insertion et obtient uniquement l'ID du nouveau User.

5. La Query

Nous venons de voir que la command est ce qui permet d'intenter une action d'écriture sur notre base de données.

À présent, on va voir la query, qui est exactement la même chose mais pour une intention de lecture.

En soi, rien de différent : c'est une question d'intention.

On va se créer notre message, qui transportera un ID provenant de notre contrôleur.

namespace App\Messenger\Message;

class UserIdQuery
{
    public function __construct(private string $userId) {}

    public function getUserId(): string
    {
        return $this->userId;
    }
}

Puis notre Handler :

namespace App\Messenger\QueryHandler;

use App\DTO\UserDTO;
use App\Messenger\Message\UserIdQuery;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityNotFoundException;
use Symfony\Component\ObjectMapper\ObjectMapperInterface;

#[AsMessageHandler]
class GetUserByIdQueryHandler
{
    public function __construct(private readonly UserRepository $userRepository, private readonly ObjectMapperInterface $objectMapper) {}

    public function __invoke(UserIdQuery $userIdQuery): UserDTO
    {
        $user = $this->userRepository->find($userIdQuery->getUserId());

        if(!$user)
            throw new EntityNotFoundException();

        return $this->objectMapper->map($user, UserDTO::class);
    }
}

On remarque que j'ai utilisé un D.T.O qui va transporter notre retour de Query

namespace App\DTO;

final class UserDTO
{
    public function __construct(
        public string $id,
        public string $email,
        public string $firstname,
        public string $lastname,
        public bool $gender
    ) {}
}

Il ressemble énormément à notre NewUserDTO, mais même si ça paraît redondant, il est très bien de séparer les D.T.O par rôle. J'aurais pu étendre mon UserDTO avec le NewUserDTO, mais les rôles sont différents. Mieux vaut garder les deux D.T.O distincts.

Ce n'est pas vraiment au niveau d'une clean architecture type D.D.D, mais autant prendre de bonnes habitudes quand on commence à toucher au C.Q.R.S.

Petit test de récupération d’un User depuis /user :

#[Route('/{id}', name: 'app_get_user', requirements: ['id'=>'(?!new$).+'], methods: ['GET'])]    
public function get(string $id): Response
{
    $message = new UserIdQuery($id);

    $user = $this->transportBus->transport($message);

    dd($user);
}

De cette manière on à bien notre User sous forme de D.T.O comme le veut la convention C.Q.R.S.

Avec les deux types d'intentions : écriture (Command) et lecture (Query) on peut maintenant enchaîner, dans un handler, les messages au besoin et gérer tous les cas de C.R.U.D en mode C.Q.R.S.

On va voir à présent la partie middleware, qui est une section intéressante pour automatiser certaines actions.

6. Les middlewares

Un middleware, c'est un service qui va agir avant ou après notre bus afin de réaliser certaines actions en fonction de la nature de ce middleware. On va pouvoir les utiliser pour faire intervenir Doctrine ou le Validator de Symfony.

Pour ça, on va se placer dans notre fichier config/packages/messenger.yaml :

framework:
    messenger:
        # Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
        # failure_transport: failed

        transports:
            # https://symfony.com/doc/current/messenger.html#transport-configuration
            # async: '%env(MESSENGER_TRANSPORT_DSN)%'
            # failed: 'doctrine://default?queue_name=failed'
            sync: 'sync://'

        routing:
            # Route your messages to the transports

On va se créer un Bus personnalisé pour nos Command afin d’y mettre nos middlewares :

        transports:
            # https://symfony.com/doc/current/messenger.html#transport-configuration
            # async: '%env(MESSENGER_TRANSPORT_DSN)%'
            # failed: 'doctrine://default?queue_name=failed'
            sync: 'sync://'
            
        default_bus: messenger.bus.default

        buses:
            messenger.bus.default: []
            doctrine_bus:
                middleware:
                    - doctrine_ping_connection
                    - doctrine_close_connection
                    - doctrine_transaction

On a ajouté trois middlewares qui sont tous relatifs à notre ORM Doctrine :

  • doctrine_ping_connection : permet à Doctrine de garder la connexion ouverte et de la réouvrir si elle se ferme par erreur.

  • doctrine_close_connection : permet à Doctrine de fermer la connexion à la base de données quand le handler a terminé.

  • doctrine_transaction : permet à Doctrine de tout wrapper dans une transaction SQL (utile, car on peut rollback automatiquement en cas d'erreur) et évite de flush manuellement.

Pour tester notre nouveau type de bus, on va spécifier à notre handler de l'utiliser spécifiquement avec un attribut :

#[AsMessageHandler('doctrine_bus')]
class NewUserCommandHandler

Puis on va tester la transaction en enlevant de notre CommandHandler notre flush() :

# Avant
$this->entityManager->persist($newUser);
$this-entityManager->flush();
return $newUser->getId();

# Après
$this->entityManager->persist($newUser);
return $newUser->getId();

Avec une tentative d'insertion depuis notre retour POST :

On a bien notre User enregistré et notre UserDTO, qui nous revient en cas de Query.

Cela nous permet de nous soustraire du flush et de tout gérer dans une transaction afin de garder les données cohérentes entre elles.

Je ne vais pas m'attarder sur les middlewares plus en détail pour ne pas rallonger l'article, mais je te laisse aller voir dans la doc Symfony tous les cas possibles !

7. L'interface des Handlers

Revevons du côté de notre messenger.yaml :

        default_bus: messenger.bus.default

        buses:
            messenger.bus.default: []
            doctrine_bus:
                middleware:
                    - doctrine_ping_connection
                    - doctrine_close_connection
                    - doctrine_open_transaction_logger
                    - doctrine_transaction

Ici, on a nommé un type précis de handler, qui s'appelle doctrine_bus, et notre CommandHandler avait été catégorisé avec ce type.

Ce type nous permet d'avoir les middlewares, par exemple, ce qui signifie que notre QueryHandler ne possède pas ces middlewares. De cette façon, on peut catégoriser nos handlers en fonction des tâches qu'ils ont à accomplir.

Quand on a plusieurs handlers, c'est toujours pratique d'avoir un seul endroit où gérer le type de handler. Pour ça on va créer une interface qui auto-configure ce type de handler et l'implémenter dans notre handler, qui va gérer ces middlewares :

namespace App\Messenger;

use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

#[AutoconfigureTag('messenger.message_handler', ['bus' => 'doctrine_bus'])]
interface CommandHandlerInterface
{

}

Puis on l’implémente dans notre Handler : class NewUserCommandHandler implements CommandHandlerInterface.

Grâce à ça, on gagne du temps et on centralise ce type de handler dans notre interface.

On pourra faire la même chose pour les QueryHandler si on commence à y mettre des middlewares par la suite.

Attaquons nous à la dernière partie des transports : l'asynchrone, qui nous permettent de rendre notre code PHP bien plus performant ! (Oui, oui, tu as bien entendu : asynchrone ! PHP n'est pas mort, hein 😉)

8. L'Asynchrone

Quand on réalise de grosses opérations en PHP, comme de la génération de PDF, du traitement d'images ou même un appel à un service externe dont on ne connaît pas le temps de réponse, ça peut vite prendre du temps. Pour ça, on a deux solutions :

  • Attendre avec l'utilisateur (c'est souvent une mauvaise idée)

  • Laisser l'utilisateur avec une réponse partielle et continuer le traitement

Évidemment, ici, on va choisir la seconde option, puisque d'un point de vue utilisateur, il vaut mieux ne pas le faire attendre.

Pour simuler notre traitement long, on va ajouter un sleep(10) dans un handler pour l'exemple et utiliser un Worker pour prendre en charge l'asynchrone.

On se crée notre LongMessageCommand (attention… très simple !) :

namespace App\Messenger\Message;

class LongTreatmentCommand
{
    public function __construct() {}
}

Ah baaaaaah ... je t'avais prévenu que c'était simple !

Puis on fait notre handler :

namespace App\Messenger\CommandHandler;

use App\Messenger\Message\LongTreatmentCommand;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
class LongTreatmentCommandHandler
{
    public function __construct() {}
    public function __invoke(LongTreatmentCommand $message): void
    {
        sleep(10);
    }
}

Et on se crée une route dans notre controller juste pour ça :

#[Route('/new/long', name: 'app_new_long_user', methods: ['GET'])]
public function test(MessageBusInterface $bus): Response
{
    $message = new LongTreatmentCommand();

    // Pour le test on ne fait avec le TransportBus Sync que l'on a customisé
	// car pas compatible avec les messages asynchrones
    $stamps = $bus->dispatch($message);

    dd($stamps);
}

Si tu essaies de lancer ça, ton script va se lancer en mode synchrone et tu vas attendre 10 s avant d'avoir le retour de $stamp.

On va le passer en mode async. Pour ça, direction la section transports du messenger.yaml :

        transports:
            sync: 'sync://'
            async: "%env(MESSENGER_TRANSPORT_DSN)%"
        routing:
            App\Messenger\Message\LongTreatmentCommand: async

Le mode async s'appuie sur Doctrine par défaut.

Le dispatch() du bus va envoyer les messages dans une table messenger_messages, créée lors de la migration.

Dès qu'on appelle la route, les messages vont s'entasser dans la table :

La file d'attente
La Queue (ou file d'attente)

Pour prendre en charge la file d'attente, on va maintenant démarrer l'exécution asynchrone avec le worker du Messenger, en utilisant la commande suivante : symfony console messenger:consume async -vvv

Le worker
Le Worker en action

Tu remarqueras qu'au moment de lancer ta requête, tu obtiens tout de suite une réponse HTTP, car Symfony n'attend pas le handler. Il dispatch tout de suite et te retourne le stamp sans patienter les 10 secondes (c'est aussi pour ça qu'on n'utilise pas le bus custom avec le HandleTrait, car il est spécifique au transport synchrone avec sa fonction handle(), qui est censée nous retourner un résultat).

Pense à éteindre le worker avec la commande : symfony console messenger:stop-worker.

Et un dernier petit schéma pour te résumer l'async avec les workers :

L'asynchrone et les workers
L'asynchrone et les workers

L'async n'est pas si complexe que ça, il faut juste comprendre le principe de la queue et de la prise en charge par le worker, qui va dispatcher vers le handler correspondant.

Si le service tiers, qui prend du temps, ne répond plus ou qu'une exception survient à cause d'un cas métier, alors le message peut être en erreur et il sera placé dans la queue failed. Il faudra aussi gérer ce cas en production pour le retraiter derrière, en queue async.

 

Voilà pour les grandes lignes du C.Q.R.S et son implémentation dans Symfony, qui nous aide beaucoup avec son bus et Messenger.

J'espère que ça te donnera envie d'essayer ce pattern d'architecture, à la place ou en complément du M.V.C. Tu peux très bien aussi l'utiliser en complément selon le cas métier. Par exemple, si l'on admet une génération d'un rapport PDF de comptabilité, qui prend souvent du temps et implique des appels externes à des services avec un traitement lourd, la mise en place d'un message async peut être une bonne idée pour éviter de surcharger le serveur et de faire attendre l'utilisateur.

Ave C.Q.R.S !