CQRS avec Messenger sur Symfony : Architecture Claire, Intentions Nettes
Le pattern CQRS couplé au composant Messenger de Symfony offre une séparation radicale entre lecture et écriture, un use case par fichier, et un code qui lit comme les spécifications métier. Voici pourquoi c'est l'une des pratiques les plus saines en backend moderne.
Avant CQRS : le problème du service fourre-tout
Tout développeur backend a écrit — ou subi — ce genre de service :
class OrderService
{
public function createOrder(array $data): Order { ... }
public function cancelOrder(int $id, string $reason): void { ... }
public function getOrdersByCustomer(int $customerId): array { ... }
public function getOrderStatistics(DateRange $range): array { ... }
public function refundOrder(int $id, Money $amount): void { ... }
public function getOrderDetails(int $id): array { ... }
}
Six méthodes, six préoccupations différentes. Des lectures et des écritures mélangées. Des dépendances qui s’accumulent dans le constructeur. Et inévitablement, les tests qui deviennent impossibles à isoler parce que cancelOrder a besoin du même contexte que getOrderStatistics.
CQRS — Command Query Responsibility Segregation — pose une règle radicalement simple : une opération qui modifie l’état n’a pas le droit de retourner des données, et une opération qui lit n’a pas le droit de modifier quoi que ce soit.
Ce n’est pas une contrainte technique. C’est une discipline de conception.
Le principe fondateur : séparer intention et interrogation
Greg Young, qui a formalisé CQRS, l’a exprimé simplement :
“A method should either change the state of an object, or return a result — but not both.”
Bertrand Meyer avait déjà posé cette idée dans le Command-Query Separation (CQS) au niveau des méthodes. CQRS l’élève au niveau architectural : deux modèles distincts, deux chemins distincts, deux ensembles de responsabilités qui n’interfèrent jamais.
En pratique :
- Un Command exprime une intention de changement —
RegisterUser,PlaceOrder,CancelSubscription. Il ne retourne pas de données métier, seulement une confirmation (ou une exception). - Un Query exprime une interrogation —
GetOrderDetails,FindActiveSubscriptions. Il retourne des données, ne touche à rien.
La conséquence immédiate : votre côté écriture peut être optimisé pour la cohérence transactionnelle et les invariants du domaine ; votre côté lecture peut être optimisé pour la performance et la forme exacte que l’UI attend.
Messenger comme bus de messages
Le composant Messenger de Symfony est un bus de messages conçu pour transporter des messages (commands, queries, events) vers leurs handlers. Son rôle dans CQRS est de décorréler l’émetteur d’un message de son handler.
Le résultat concret : un controller ne sait pas comment une commande est traitée. Il sait seulement qu’il l’envoie.
Configuration des buses
Messenger supporte nativement plusieurs buses. Pour CQRS, on en crée trois :
# config/packages/messenger.yaml
framework:
messenger:
buses:
command.bus:
middleware:
- doctrine_transaction # chaque command dans sa transaction
query.bus: ~
event.bus:
default_middleware:
enabled: true
allow_no_handlers: true # un event sans handler = OK
Trois buses, trois responsabilités, trois ensembles de règles middleware. Les commands sont enveloppées dans une transaction Doctrine automatiquement. Les queries ne le sont pas — inutile. Les events peuvent ne pas avoir de handler.
Structure d’un Command : une intention typée
Un Command est un objet PHP immuable qui porte uniquement les données nécessaires à l’exécution de l’intention.
// src/Application/Order/Command/PlaceOrder.php
final readonly class PlaceOrder
{
public function __construct(
public readonly string $customerId,
public readonly string $productId,
public readonly int $quantity,
public readonly string $shippingAddress,
) {}
}
final readonly : impossible d’hériter, impossible de muter après construction. C’est un message de données pur, sans comportement.
Son handler est une classe au périmètre exactement délimité : traiter cette intention, rien d’autre.
// src/Application/Order/Command/PlaceOrderHandler.php
#[AsMessageHandler(bus: 'command.bus')]
final class PlaceOrderHandler
{
public function __construct(
private readonly OrderRepository $orders,
private readonly ProductRepository $products,
private readonly CustomerRepository $customers,
private readonly EventBus $events,
) {}
public function __invoke(PlaceOrder $command): void
{
$customer = $this->customers->getById($command->customerId);
$product = $this->products->getById($command->productId);
$customer->assertCanPlaceOrder();
$product->assertSufficientStock($command->quantity);
$order = Order::place(
customer: $customer,
product: $product,
quantity: Quantity::of($command->quantity),
address: Address::parse($command->shippingAddress),
);
$this->orders->save($order);
foreach ($order->pullDomainEvents() as $event) {
$this->events->dispatch($event);
}
}
}
Un handler, une responsabilité. Pas de branchement conditionnel selon le type de données reçues. Pas de logique partagée avec un autre cas d’usage. Si PlaceOrder est cassé, vous savez exactement où regarder.
Structure d’une Query : lire sans contrainte
Le côté lecture est libéré de toutes les contraintes du domaine. Pas d’entités Doctrine, pas d’invariants à respecter, pas de transactions. Juste des données, dans la forme exacte qu’attend l’appelant.
// src/Application/Order/Query/GetOrderDetails.php
final readonly class GetOrderDetails
{
public function __construct(
public readonly string $orderId,
public readonly string $requestingUserId,
) {}
}
// src/Application/Order/Query/GetOrderDetailsHandler.php
#[AsMessageHandler(bus: 'query.bus')]
final class GetOrderDetailsHandler
{
public function __construct(
private readonly Connection $connection, // DBAL direct, pas d'ORM
) {}
public function __invoke(GetOrderDetails $query): OrderDetailsView
{
$row = $this->connection->fetchAssociative('
SELECT
o.id, o.status, o.total_amount, o.placed_at,
c.name AS customer_name,
c.email AS customer_email,
p.name AS product_name
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN products p ON p.id = o.product_id
WHERE o.id = :id AND o.customer_id = :customerId
', [
'id' => $query->orderId,
'customerId' => $query->requestingUserId,
]);
if ($row === false) {
throw OrderNotFound::withId($query->orderId);
}
return OrderDetailsView::fromRow($row);
}
}
La Query utilise DBAL directement, sans passer par l’ORM. La requête SQL retourne exactement les colonnes dont l’UI a besoin — ni plus, ni moins. Aucun Order::toArray() fragile, aucun serializer global. La forme des données de lecture est une décision explicite, pas un accident.
Le Controller : déclaratif et sans logique
Avec CQRS + Messenger, le controller devient une couche de traduction pure entre HTTP et les buses. Il ne connaît aucune règle métier.
#[Route('/orders', methods: ['POST'])]
public function place(Request $request): JsonResponse
{
$dto = $this->serializer->deserialize($request->getContent(), PlaceOrderInput::class, 'json');
$violations = $this->validator->validate($dto);
if (count($violations) > 0) {
return $this->json(['errors' => $this->formatViolations($violations)], 422);
}
$this->commandBus->dispatch(new PlaceOrder(
customerId: $this->getUser()->getId(),
productId: $dto->productId,
quantity: $dto->quantity,
shippingAddress: $dto->shippingAddress,
));
return $this->json(null, 201);
}
#[Route('/orders/{id}', methods: ['GET'])]
public function show(string $id): JsonResponse
{
$view = $this->queryBus->dispatch(new GetOrderDetails(
orderId: $id,
requestingUserId: $this->getUser()->getId(),
));
return $this->json($view);
}
Le controller place ne sait pas ce que fait PlaceOrderHandler. Il ne sait pas qu’il y a une transaction Doctrine. Il ne sait pas qu’un OrderPlaced event sera dispatché ensuite. Il pose une intention sur le bus et attend.
C’est ce qu’on cherche : un controller qui lit comme une spécification fonctionnelle.
Pourquoi c’est du Clean Code
1. Séparation des préoccupations au niveau structurel
L’arborescence du projet devient une carte de l’application :
src/Application/
Order/
Command/
PlaceOrder.php
PlaceOrderHandler.php
CancelOrder.php
CancelOrderHandler.php
Query/
GetOrderDetails.php
GetOrderDetailsHandler.php
ListOrdersByCustomer.php
ListOrdersByCustomerHandler.php
Naviguer dans le code, c’est naviguer dans les cas d’usage métier. Un développeur qui arrive sur le projet comprend ce que fait l’application sans lire une ligne de logique.
2. Un use case = un fichier
Le principe de Responsabilité Unique (SRP) de SOLID est appliqué à sa forme la plus nette. PlaceOrderHandler ne fait qu’une chose. CancelOrderHandler ne fait qu’une chose. Aucun branchement if ($action === 'place' || $action === 'cancel').
La conséquence directe : les tests sont triviaux à écrire.
public function test_places_order_successfully(): void
{
$handler = new PlaceOrderHandler(
orders: new InMemoryOrderRepository(),
products: new FakeProductRepository([ProductId::of('prod-1') => $this->availableProduct()]),
customers: new FakeCustomerRepository([CustomerId::of('cust-1') => $this->eligibleCustomer()]),
events: new SpyEventBus(),
);
$handler(new PlaceOrder('cust-1', 'prod-1', 2, '12 rue de la Paix, Paris'));
// Assert on repository state, not on HTTP response
}
Aucun bootstrap de kernel Symfony. Aucune base de données. Exécution en millisecondes.
3. Le code exprime le métier, pas la technique
Relisez PlaceOrderHandler::__invoke. Chaque ligne est une phrase métier :
- “Récupère le client”
- “Vérifie qu’il peut passer commande”
- “Vérifie que le stock est suffisant”
- “Crée la commande”
- “Sauvegarde”
- “Dispatche les événements”
Aucune ligne ne parle de HTTP, de sessions, de cache, de format JSON. C’est ce qu’Uncle Bob appelle le screaming architecture : le code crie ce que fait l’application.
4. Isolation naturelle du read model
Le côté lecture peut évoluer indépendamment du côté écriture. Demain vous ajoutez un index PostgreSQL sur orders.customer_id pour accélérer ListOrdersByCustomer — vous ne touchez pas au modèle d’écriture. Après-demain vous décidez de dénormaliser les données de lecture dans Redis — vous changez uniquement ListOrdersByCustomerHandler. Le domaine est intact.
C’est l’un des gains les plus concrets en production : les optimisations de performance ne polluent jamais le domaine.
L’objection classique : “trop de fichiers”
La critique revient souvent : CQRS crée beaucoup de fichiers. C’est vrai. Un OrderService avec six méthodes, c’est un fichier. Six handlers, c’est six fichiers.
Mais la question n’est pas combien de fichiers, elle est combien de raisons de changer chaque fichier. Un OrderService change quand n’importe lequel de ses six cas d’usage évolue. PlaceOrderHandler ne change que quand les règles de passage de commande changent.
Dans un projet qui grossit, l’isolation vaut bien le surplus de fichiers.
Ce que CQRS n’est pas
CQRS ne requiert pas d’Event Sourcing (même si les deux se combinent bien). Il ne requiert pas deux bases de données distinctes. Il ne requiert pas une architecture microservices. Dans sa forme la plus simple — un bus de commands et un bus de queries sur le même schéma de base de données — c’est accessible dans n’importe quel projet Symfony existant, sans migration monumentale.
Commencez par les cas d’usage qui en bénéficient le plus : les écritures complexes avec plusieurs invariants, les lectures fortement jointes que l’ORM traite mal. Migrez progressivement. Le bus Messenger est là dès l’installation de symfony/messenger.
Conclusion
CQRS avec Messenger n’est pas une over-engineering. C’est une application disciplinée de principes que tout développeur connaît déjà — SRP, séparation des préoccupations, code qui exprime l’intention — mais poussée au niveau architectural.
Le résultat : un codebase où chaque fichier a une raison d’exister, où les tests s’écrivent naturellement, où un nouveau développeur peut comprendre ce que fait l’application en lisant l’arborescence. Et un côté lecture libre de toute contrainte de domaine, prêt à être optimisé sans jamais compromettre la logique métier.
C’est ça, l’architecture propre en pratique.