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
- Étape 2 : Création du DTO (Data Transfer Object)
- Étape 3 : Configuration de Symfony Messenger
- Étape 4 : Création de votre première Command
- Étape 5 : Simplification de l'envoi des commandes avec CommandBus
- Étape 6 : Création du Controller pour les commandes
- Étape 7 : Création de votre première Query
- Étape 8 : Simplification de l'envoi des queries avec QueryBus
- Étape 9 : Création du Controller pour les queries
- Conclusion Finale
🏗️ É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::STRINGetTypes::INTEGERgarantit la cohérence des types de données - Fluent Setters : Le retour de type
staticpermet 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
rollbacksi 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_transactionvous évite d'appeler manuellementflush()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 middlewaredoctrine_transactions'en charge - Validation automatique : Les contraintes sont vérifiées avant le handler
- Retour typé : Le handler retourne un
BookDtopropre 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 dedispatch() - ✅ Typage fort : Le
HandleTraitpermet 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
HandleTraitpermet de récupérer directement la valeur de retour du handler - ✅ Cohérence : Même pattern que le
CommandBuspour 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
NotFoundExceptionpersonnalisée au lieu de retournernull. Assurez-vous que leQueryHandlerne 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
HandleTraitpermet de récupérer directement la valeur de retour du handler - ✅ Cohérence : Même pattern que le
CommandBuspour 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
- UpdateBookCommand - Modifier un livre existant
- DeleteBookCommand - Supprimer un livre
Queries
- 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 !
Commentaires
Il n'y a actuellement aucun commentaire. Soyez le premier !