Couverture de l'article CQRS avec Symfony Messenger : Domptez la complexité de vos applications
Retour aux articles

L'agence

WanadevStudio

CQRS avec Symfony Messenger : Domptez la complexité de vos applications

Vous êtes-vous déjà retrouvé face à un controller Symfony surchargé qui gère à la fois la validation, la logique métier, la persistence et les réponses HTTP ? Si oui, le CQRS est fait pour vous !

Le CQRS (Command Query Responsibility Segregation) est un pattern architectural qui sépare clairement les opérations d'écriture (Commands) et de lecture (Queries). Combiné avec Symfony Messenger, il vous permet de :

  • Organiser votre code de manière claire et maintenable
  • Séparer les responsabilités pour respecter les principes SOLID
  • Valider vos données avant même qu'elles n'atteignent votre logique métier
  • Gérer les transactions de base de données de manière élégante
  • Préparer votre application pour l'asynchrone sans effort

Dans cet article, nous allons explorer les Commands (écriture) et les Queries (lecture) à travers un exemple concret de gestion de bibliothèque.

ℹ️ Prérequis : Cet article nécessite que vous ayez déjà des connaissances sur le composant Messenger. Si ce n'est pas le cas, je vous invite d'abord à lire cet article : Symfony Messenger : Gestion des Messages en file d'attente

Sommaire


🏗️ Étape 1 : Création de l'entité Book

Commençons par poser les fondations de notre application. L'entité Book représente un livre dans notre bibliothèque virtuelle.

L'entité Book

namespace App\Domain\Book\Entity;

use Doctrine\ORM\Mapping as ORM;
use Doctrine\DBAL\Types\Types;

#[ORM\Entity]
class Book
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: Types::INTEGER)]
    private int $id;

    #[ORM\Column(type: Types::STRING, length: 255)]
    private ?string $title = null;

    #[ORM\Column(type: Types::STRING, length: 255)]
    private ?string $author = null;

    public function getId(): int
    {
        return $this->id;
    }

    public function getTitle(): ?string
    {
        return $this->title;
    }

    public function getAuthor(): ?string
    {
        return $this->author;
    }

    public function setTitle(?string $title): static
    {
        $this->title = $title;
        return $this;
    }

    public function setAuthor(?string $author): static
    {
        $this->author = $author;
        return $this;
    }
}

Points clés

  • Attributs Doctrine : Les annotations #[ORM\...] permettent à Doctrine de mapper cette classe vers une table SQL
  • Types stricts : L'utilisation de Types::STRING et Types::INTEGER garantit la cohérence des types de données
  • Fluent Setters : Le retour de type static permet le chaînage des méthodes ($book->setTitle(...)->setAuthor(...))
  • ID auto-généré : Doctrine gère automatiquement l'incrémentation de l'identifiant

📦 Étape 2 : Création du DTO (Data Transfer Object)

Le DTO est le messager de notre application. Il transporte les données entre les différentes couches tout en assurant leur validité.

BookDto - Notre objet de transfert

namespace App\Domain\Book\Dto;

use App\Domain\Book\Entity\Book;
use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;

#[Map(target: Book::class)]
class BookDto
{
    #[Groups(['book:read'])]
    #[Map(target: 'id')]
    public ?int $id = null;

    #[Groups(['book:read', 'book:write'])]
    #[Map(target: 'title')]
    #[Assert\NotBlank]
    #[Assert\Length(max: 255)]
    public ?string $title = null;

    #[Groups(['book:read', 'book:write'])]
    #[Map(target: 'author')]
    #[Assert\NotBlank]
    #[Assert\Length(max: 255)]
    public ?string $author = null;
}

Description des attributs

Attribut Rôle
#[Map(target: Book::class)] 🔗 Lie automatiquement le DTO à l'entité Book
#[Groups(['book:read'])] 📖 L'ID n'est visible qu'en lecture (généré par la BDD)
#[Groups(['book:write'])] ✍️ Le titre et l'auteur sont utilisables en écriture
#[Assert\NotBlank] ✅ Validation : le champ est obligatoire
#[Assert\Length(max: 255)] 📏 Validation : limite de longueur

⚙️ Étape 3 : Configuration de Symfony Messenger

C'est le cœur de notre architecture CQRS ! La configuration de Messenger va nous permettre de séparer clairement les responsabilités entre les opérations d'écriture (Commands) et de lecture (Queries).

Le concept des Bus multiples

Dans une architecture CQRS, nous utilisons plusieurs bus de messages pour séparer les intentions :

  • Command Bus : Traite les opérations qui modifient l'état de l'application
  • Query Bus : Gère les opérations de lecture sans effet de bord

Cette séparation permet d'appliquer des middlewares différents selon le type d'opération. Par exemple, les Commands nécessitent une validation stricte et une gestion transactionnelle, tandis que les Queries peuvent être optimisées pour la lecture.

Cela permet également de respecter le principe S (Single Responsibility Principle) des principes SOLID, en séparant clairement les responsabilités entre les opérations d'écriture et de lecture.

Configuration des bus (config/packages/messenger.yaml)

framework:
    messenger:
        # Définit le bus par défaut utilisé par l'autowiring
        default_bus: command.bus

        # Déclaration de nos différents bus
        buses:
            # Bus dédié aux Commands (écriture)
            command.bus:
                middleware:
                    - doctrine_ping_connection
                    - validation
                    - dispatch_after_current_bus
                    - doctrine_transaction

            # Bus dédié aux Queries (lecture)
            query.bus:
                middleware:
                    - doctrine_ping_connection
                    - dispatch_after_current_bus

Les middlewares expliqués en détail

Les middlewares sont exécutés dans l'ordre de leur déclaration. Chaque middleware ajoute une couche de traitement autour de votre handler.

Middleware Rôle Bus Pourquoi ?
doctrine_ping_connection 🏓 Vérifie que la connexion BDD est active Command & Query Évite les erreurs si la connexion a expiré (utile pour les workers longue durée)
validation ✅ Valide automatiquement les messages Command uniquement Garantit que les données sont valides avant d'atteindre le handler
dispatch_after_current_bus 📮 Met en file d'attente les messages dispatchés Command & Query Permet de dispatcher des événements qui seront traités après la transaction
doctrine_transaction 💾 Encapsule le handler dans une transaction Command uniquement Commit automatique si succès, rollback si exception

Focus sur les middlewares clés

1. Middleware validation

Ce middleware valide automatiquement vos messages grâce aux contraintes Symfony Validator. Si la validation échoue, une exception est levée avant que le handler ne soit appelé.

// Dans votre Command
class AddBookCommand
{
    public function __construct(
        #[Assert\NotBlank]  // ← Validé automatiquement
        #[Assert\Length(max: 255)]
        public ?string $title = null,
    ) {}
}
2. Middleware doctrine_transaction

C'est le middleware le plus important pour les Commands. Il :

  • Démarre une transaction avant l'appel du handler
  • Appelle flush() automatiquement si tout se passe bien
  • Fait un rollback si une exception est levée
// Dans votre Handler - Pas besoin de flush() !
public function __invoke(AddBookCommand $command): BookDto
{
    $book = (new Book())->setTitle($command->title);
    $this->entityManager->persist($book);
    // ✅ Le flush() est automatique grâce au middleware !
    return $this->objectMapper->map($book, BookDto::class);
}
3. Middleware dispatch_after_current_bus

Permet de dispatcher des événements qui seront traités après la transaction en cours. Très utile pour déclencher des actions secondaires sans affecter la transaction principale.

// Dans votre Handler
public function __invoke(AddBookCommand $command): BookDto
{
    // ... création du livre ...

    // Cet événement sera dispatché APRÈS le commit de la transaction
    $this->eventBus->dispatch(new BookAddedEvent($book->getId()));

    return $this->objectMapper->map($book, BookDto::class);
}

Point clé : Le middleware doctrine_transaction vous évite d'appeler manuellement flush() dans vos handlers ! Cela rend votre code plus propre et évite les oublis.

Interfaces dédiées

Pour que Symfony route automatiquement vos handlers vers le bon bus, nous utilisons des interfaces dédiées :

src/MessageHandler/CommandHandlerInterface.php

namespace App\MessageHandler;

use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

/**
 * Les handlers implémentant cette interface seront automatiquement
 * enregistrés sur le command.bus
 */
 #[AutoconfigureTag('messenger.message_handler', ['bus' => 'command.bus'])]
interface CommandHandlerInterface
{
}

src/MessageHandler/QueryHandlerInterface.php

namespace App\MessageHandler;

use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
/**
 * Les handlers implémentant cette interface seront automatiquement
 * enregistrés sur le query.bus
 */
#[AutoconfigureTag('messenger.message_handler', ['bus' => 'query.bus'])] 
interface QueryHandlerInterface
{
}

Résultat : Vous n'avez plus besoin de configurer manuellement chaque handler ! Il suffit d'implémenter l'interface correspondante.

// ✅ Automatiquement enregistré sur command.bus
class AddBookCommandHandler implements CommandHandlerInterface
{
    public function __invoke(AddBookCommand $command): BookDto
    {
        // ...
    }
}

// ✅ Automatiquement enregistré sur query.bus
class GetBookQueryHandler implements QueryHandlerInterface
{
    public function __invoke(GetBookQuery $query): BookDto
    {
        // ...
    }
}

Grâce à cette configuration, Symfony route automatiquement vos handlers vers le bon bus ! 🎯


📝 Étape 4 : Création de votre première Command

Passons à l'action ! Créons une commande pour ajouter un livre à notre bibliothèque.

La Command : AddBookCommand

Une Command est un simple objet PHP qui contient les données nécessaires à l'opération.

namespace App\Domain\Book\Message\Command;

use Symfony\Component\Validator\Constraints as Assert;

class AddBookCommand
{
    public function __construct(
        #[Assert\NotBlank]
        #[Assert\Length(max: 255)]
        public ?string $title = null,

        #[Assert\NotBlank]
        #[Assert\Length(max: 255)]
        public ?string $author = null
    ) {}
}

🎯 Bonne pratique : Une Command est un message immuable et ne doit contenir que les données nécessaires à l'opération.

Le Handler : AddBookCommandHandler

Le handler contient la logique métier pour traiter la commande.

namespace App\Domain\Book\Message\Command;

use App\Domain\Book\Entity\Book;
use App\Domain\Book\Dto\BookDto;
use App\MessageHandler\CommandHandlerInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\ObjectMapper\ObjectMapperInterface;

class AddBookCommandHandler implements CommandHandlerInterface
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        private readonly ObjectMapperInterface $objectMapper
    ) {}

    public function __invoke(AddBookCommand $command): BookDto
    {
        // 1️⃣ Création de l'entité
        $book = (new Book())
            ->setTitle($command->title)
            ->setAuthor($command->author);

        // 2️⃣ Persistence (le flush est automatique grâce au middleware)
        $this->entityManager->persist($book);

        // 3️⃣ Conversion de l'entité en DTO pour la réponse
        return $this->objectMapper->map($book, BookDto::class);
    }
}

Le flux d'exécution

📥 Request HTTP
    ↓
🎯 Controller → Crée AddBookCommand
    ↓
📮 CommandBus → Envoie la commande
    ↓
✅ Middleware Validation → Valide la commande
    ↓
🎪 AddBookCommandHandler → Traite la commande
    ↓
💾 Middleware Transaction → Flush automatique
    ↓
📤 Response HTTP (BookDto)

Points clés

  • Pas de flush() : Le middleware doctrine_transaction s'en charge
  • Validation automatique : Les contraintes sont vérifiées avant le handler
  • Retour typé : Le handler retourne un BookDto propre et validé
  • ObjectMapper : Conversion automatique Entity → DTO

📝 Étape 5 : Simplification de l'envoi des commandes avec CommandBus

Pour simplifier l'utilisation de Messenger, créons une classe dédiée qui encapsule la logique d'envoi des commandes.

CommandBus

namespace App\MessageBus;

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

class CommandBus
{
    use HandleTrait;

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

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

Avantages

  • Interface claire : Méthode command() explicite au lieu de dispatch()
  • Typage fort : Le HandleTrait permet de récupérer directement la valeur de retour du handler
  • Simplicité : Plus besoin d'utiliser $envelope->last(HandledStamp::class)

Utilisation dans le controller :

// Sans CommandBus (verbeux)
$envelope = $this->messageBus->dispatch($command);
$bookDto = $envelope->last(HandledStamp::class)->getResult();

// Avec CommandBus (simple et clair)
$bookDto = $this->commandBus->command($command);

🎮 Étape 6 : Création du Controller pour les commandes

Le controller est ultra-simple : il reçoit les données, crée une commande et la dispatche. C'est tout !

BookController

namespace App\Controller;

use App\Domain\Book\Dto\BookDto;
use App\Domain\Book\Message\Command\AddBookCommand;
use App\MessageBus\CommandBus;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Serializer\Context\Normalizer\ObjectNormalizerContextBuilder;

class BookController
{
    public function __construct(
        private readonly CommandBus $commandBus
    ) {}

    #[Route('/api/books', name: 'api_add_book', methods: ['POST'])]
    public function add(
        #[MapRequestPayload(
            serializationContext: ['groups' => ['book:write']]
        )] BookDto $bookDto
    ): JsonResponse {
        // 1️⃣ Création de la commande depuis le DTO
        $command = new AddBookCommand(
            title: $bookDto->title,
            author: $bookDto->author
        );

        // 2️⃣ Exécution de la commande
        $resultDto = $this->commandBus->command($command);

        // 3️⃣ Retour de la réponse avec le livre créé (groupe book:read)
        return new JsonResponse(
            data: $resultDto,
            status: Response::HTTP_CREATED,
            context: ['groups' => ['book:read']]
        );
    }
}

Ce que le controller NE fait PAS

  • ❌ Pas de logique métier
  • ❌ Pas de validation manuelle
  • ❌ Pas d'accès direct à la base de données
  • ❌ Pas de gestion d'erreur (Symfony s'en charge)

Résultat : Un controller de 10 lignes, clair et testable ! 🎉


📖 Étape 8 : Simplification de l'envoi des queries avec QueryBus

Pour simplifier l'utilisation des Queries dans Messenger, nous allons créer une classe dédiée qui encapsule la logique d'envoi des queries.

QueryBus

namespace App\MessageBus;

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

class QueryBus
{
    use HandleTrait;

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

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

Avantages

  • Interface claire : Méthode query() explicite pour la lecture
  • Typage fort : Le HandleTrait permet de récupérer directement la valeur de retour du handler
  • Cohérence : Même pattern que le CommandBus pour une API uniforme

📝 Étape 7 : Création de votre première Query

Créons une query pour récupérer un livre par son ID.

La Query : GetBookQuery

Une Query est un objet simple qui contient les critères de recherche.

namespace App\Domain\Book\Message\Query;

class GetBookQuery
{
    public function __construct(
        public int $id
    ) {}
}

🎯 Bonne pratique : Une Query est un message immuable qui contient uniquement les critères de recherche nécessaires.

Le Handler : GetBookQueryHandler

Le handler contient la logique pour récupérer les données.

namespace App\Domain\Book\Message\Query;

use App\Domain\Book\Dto\BookDto;
use App\Domain\Book\Entity\Book;
use App\MessageHandler\QueryHandlerInterface;
use App\Services\Exception\NotFoundException;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\ObjectMapper\ObjectMapper;

class GetBookQueryHandler implements QueryHandlerInterface
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        private readonly ObjectMapper $objectMapper
    ) {}

    public function __invoke(GetBookQuery $query): BookDto
    {
        // 1️⃣ Recherche de l'entité
        $book = $this->entityManager
            ->getRepository(Book::class)
            ->find($query->id);

        // 2️⃣ Si non trouvé, lance une exception
        if (!$book instanceof Book) {
            throw new NotFoundException(Book::class, $query->id);
        }

        // 3️⃣ Conversion de l'entité en DTO
        return $this->objectMapper->map($book, BookDto::class);
    }
}

Le flux d'exécution pour une Query

📥 Request HTTP GET
    ↓
🎯 Controller → Crée GetBookQuery
    ↓
📮 QueryBus → Envoie la query
    ↓
🎪 GetBookQueryHandler → Récupère les données
    │                      (ou lance NotFoundException)
    ↓
📤 Response HTTP (BookDto ou 404)

Points clés

  • Pas de transaction : Les queries ne modifient pas les données
  • Exception si non trouvé : Lance NotFoundException personnalisée au lieu de retourner null. Assurez-vous que le QueryHandler ne renvoie pas une exception HTTP (comme celles de Symfony), mais bien une exception spécifique à l'application. C'est la responsabilité de celui qui appelle le handler de capturer cette exception et de l'adapter au contexte prévu (HTTP, console, etc.).
  • ObjectMapper : Conversion automatique Entity → DTO
  • Read-only : Optimisé pour la lecture

🔧 Étape 8 : Simplification de l'envoi des queries avec QueryBus

Comme pour les commandes, créons une classe dédiée pour les queries.

QueryBus

namespace App\MessageBus;

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

class QueryBus
{
    use HandleTrait;

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

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

Avantages

  • Interface claire : Méthode query() explicite pour la lecture
  • Typage fort : Le HandleTrait permet de récupérer directement la valeur de retour du handler
  • Cohérence : Même pattern que le CommandBus pour une API uniforme

🎮 Étape 9 : Création du Controller pour les queries

Le controller pour une query est encore plus simple !

BookController - Méthode GET

namespace App\Controller;

use App\Domain\Book\Dto\BookDto;
use App\Domain\Book\Message\Query\GetBookQuery;
use App\MessageBus\QueryBus;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class BookController
{
    public function __construct(
        private readonly CommandBus $commandBus,
        private readonly QueryBus $queryBus
    ) {}

    // ... méthode addBook() vue précédemment ...

    #[Route('/api/books/{id}', name: 'api_show_book', methods: ['GET'])]
    public function show(int $id): JsonResponse
    {
        // 1️⃣ Création de la query
        $query = new GetBookQuery($id);

        // 2️⃣ Exécution de la query
        $bookDto = $this->queryBus->query($query);

        // 3️⃣ Retour de la réponse
        return new JsonResponse(
            data: $bookDto,
            context: ['groups' => ['book:read']]
        );
    }
}

Ce que le controller NE fait PAS (Query)

  • ❌ Pas de logique de recherche
  • ❌ Pas d'accès direct au repository
  • ❌ Pas de construction de requêtes SQL
  • ❌ Pas de mapping manuel

Résultat : Un controller de lecture ultra-simple ! 🎉


🎊 Conclusion Finale

Félicitations ! Vous maîtrisez maintenant CQRS avec Symfony Messenger.

Architecture complète

┌──────────────────────────────────────────────────────────┐
│                    ARCHITECTURE CQRS                     │
└──────────────────────────────────────────────────────────┘

         COMMANDS (Écriture)          QUERIES (Lecture)
                ↓                            ↓
         ┌──────────────┐             ┌──────────────┐
         │ CommandBus   │             │  QueryBus    │
         └──────┬───────┘             └──────┬───────┘
                │                            │
         ┌──────▼───────┐             ┌──────▼───────┐
         │ Middlewares  │             │ Middlewares  │
         │ - validation │             │ - ping DB    │
         │ - transaction│             │              │
         └──────┬───────┘             └──────┬───────┘
                │                            │
         ┌──────▼───────┐             ┌──────▼───────┐
         │   Handler    │             │   Handler    │
         │  (Create,    │             │   (Read)     │
         │   Update,    │             │              │
         │   Delete)    │             │              │
         └──────┬───────┘             └──────┬───────┘
                │                            │
                ▼                            ▼
         [Persist + Flush]              [Fetch Data]
                │                            │
                ▼                            ▼
            BookDto                       BookDto

Récapitulatif complet

Commands (Écriture)

  • ✅ Créer, modifier, supprimer des données
  • ✅ Validation automatique
  • ✅ Gestion transactionnelle
  • ✅ Retourne un DTO ou void

Queries (Lecture)

  • ✅ Récupérer des données
  • ✅ Pas de modification d'état
  • ✅ Optimisé pour la performance
  • ✅ Retourne des DTO ou collections

Allez plus loin

Maintenant que vous maîtrisez CQRS, essayez d'implémenter :

Commands

  1. UpdateBookCommand - Modifier un livre existant
  2. DeleteBookCommand - Supprimer un livre

Queries

  1. ListBooksQuery - Lister tous les livres avec filtrage et pagination

💡 Astuce : Chaque opération d'écriture = Command, chaque lecture = Query !


Merci d'avoir lu cet article ! 🙏

Dans un prochain article, nous explorerons l'Event Bus pour créer une architecture événementielle et découpler encore davantage vos domaines métier !

Des questions ? N'hésitez pas à partager votre expérience avec CQRS dans les commentaires !


📚 Ressources complémentaires

Commentaires

Il n'y a actuellement aucun commentaire. Soyez le premier !