Blog / · 9 min de lecture
CQRS : à quoi ça sert, quand l'utiliser, et quand surtout l'éviter
CQRS sépare les écritures et les lectures. Ce n'est pas une architecture miracle, ni forcément de l'Event Sourcing. Voici les vrais cas d'usage, les coûts, les pièges et une implémentation Symfony Messenger pragmatique.
CQRS est souvent présenté comme une architecture de senior.
Dans beaucoup de projets, c’est surtout une manière très chère de faire un CRUD. On ajoute des commands, des queries, des handlers, des bus, parfois deux bases de données, puis l’équipe passe plus de temps à naviguer dans les fichiers qu’à livrer du comportement.
Mais le pattern n’est pas mauvais. Il est juste mal placé. CQRS devient utile quand le modèle d’écriture et le modèle de lecture ne veulent plus la même chose.
La question n’est donc pas “est-ce que CQRS est propre ?”. La question est : “est-ce que mes lectures et mes écritures ont réellement divergé ?”.
La définition simple
CQRS signifie Command Query Responsibility Segregation.
Tu sépares deux responsabilités :
- command : une intention qui modifie l’état du système;
- query : une demande de lecture qui ne modifie rien.
Une command ressemble à :
final readonly class PlaceOrderCommand
{
public function __construct(
public string $customerId,
public array $lines,
) {}
}
Une query ressemble à :
final readonly class GetOrderSummaryQuery
{
public function __construct(public string $orderId) {}
}
La command protège les invariants. La query optimise la lecture. Les deux peuvent utiliser la même base de données au début. CQRS ne veut pas dire “deux bases”, “event sourcing”, “Kafka” ou “microservices”.
CQS, CQRS et Event Sourcing : ne mélange pas tout
Il y a trois notions souvent confondues.
Command Query Separation : une méthode doit soit changer l’état, soit retourner une donnée, mais pas les deux. C’est un principe de design local.
CQRS : tu appliques cette séparation au niveau applicatif. Les écritures et les lectures passent par des modèles et des chemins séparés.
Event Sourcing : tu stockes les changements sous forme d’événements et tu reconstruis l’état à partir de l’historique.
Tu peux faire CQRS sans Event Sourcing. C’est même le choix le plus pragmatique dans la majorité des applications web.
Le vrai problème que CQRS résout
Dans un CRUD classique, tu utilises souvent le même modèle pour écrire et lire.
Exemple : une entité Order sert à valider une commande, calculer un total, persister les lignes, afficher l’historique client, générer un dashboard admin et nourrir une export comptable.
Au début, ça marche. Puis les besoins divergent :
- côté écriture, tu veux des invariants stricts;
- côté lecture, tu veux une vue dénormalisée rapide;
- côté admin, tu veux filtrer, trier, agréger;
- côté client, tu veux une réponse compacte;
- côté reporting, tu veux des données historisées.
CQRS dit : arrête de forcer le même modèle à satisfaire des besoins opposés.
Exemple : tunnel de commande
Côté écriture :
final readonly class PlaceOrderHandler
{
public function __construct(
private OrderRepository $orders,
private StockGateway $stock,
private PaymentGateway $payments,
) {}
public function __invoke(PlaceOrderCommand $command): OrderId
{
$order = Order::place($command->customerId, $command->lines);
$this->orders->save($order);
$this->stock->reserve($order->id());
$this->payments->authorize($order->id());
return $order->id();
}
}
Côté lecture :
final readonly class GetOrderSummaryHandler
{
public function __construct(private Connection $connection) {}
public function __invoke(GetOrderSummaryQuery $query): OrderSummaryView
{
$row = $this->connection->fetchAssociative(
<<<'SQL'
SELECT o.id, o.status, o.total, c.email
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.id = :id
SQL,
['id' => $query->orderId],
);
if ($row === false) {
throw new OrderSummaryNotFound($query->orderId);
}
return OrderSummaryView::fromRow($row);
}
}
Le handler d’écriture travaille avec le domaine. Le handler de lecture travaille avec une projection adaptée à l’écran. Il n’a pas besoin d’hydrater tout l’agrégat Order.
Dans un système critique, les appels stock et payment ne devraient pas être une simple suite synchrone après save(). Il faut les rendre idempotents, ou les sortir via un outbox/message après commit. Le snippet sert à montrer la séparation write/read, pas à régler toute la chorégraphie distribuée.
Symfony Messenger : une implémentation naturelle
Symfony Messenger documente explicitement l’usage de plusieurs bus pour séparer commands, queries et events. Le détail important : ajoute un bus seulement s’il a un comportement différent.
Configuration pragmatique :
# config/packages/messenger.yaml
framework:
messenger:
default_bus: command.bus
buses:
command.bus:
middleware:
- doctrine_transaction
query.bus: ~
event.bus:
default_middleware: allow_no_handlers
Le command.bus peut être transactionnel. Le query.bus reste léger. L’event.bus accepte plusieurs listeners.
Handler :
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class PlaceOrderHandler
{
public function __invoke(PlaceOrderCommand $command): OrderId
{
// write-side workflow
}
}
Query :
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetOrderSummaryHandler
{
public function __invoke(GetOrderSummaryQuery $query): OrderSummaryView
{
// read-side projection
}
}
Si les deux bus ont exactement les mêmes middlewares, la séparation est peut-être conceptuelle mais pas encore utile. Tu peux commencer avec des classes command/query sans multiplier l’infrastructure.
Autre détail Messenger : MessageBusInterface::dispatch() retourne une Envelope, pas directement la valeur du handler. Pour un query bus synchrone qui doit retourner une vue, utilise HandleTrait ou un petit bus applicatif qui encapsule ce détail.
use Symfony\Component\Messenger\HandleTrait;
use Symfony\Component\Messenger\MessageBusInterface;
final class QueryBus
{
use HandleTrait;
public function __construct(MessageBusInterface $queryBus)
{
$this->messageBus = $queryBus;
}
public function ask(object $query): mixed
{
return $this->handle($query);
}
}
Pour les commands, retourner un identifiant peut être pragmatique en synchrone. Si la command part en async, ne promets pas de retour immédiat : retourne plutôt 202 Accepted, un identifiant de suivi, ou laisse l’état être lu via une query.
Et Laravel ?
Laravel n’a pas besoin d’un bus dédié pour profiter de CQRS.
Tu peux commencer avec des actions séparées :
final readonly class PlaceOrderAction
{
public function execute(PlaceOrderCommand $command): OrderId
{
// write-side workflow
}
}
final readonly class GetOrderSummaryQueryHandler
{
public function handle(string $orderId): OrderSummaryView
{
// read-side projection
}
}
Si tu veux formaliser davantage, tu peux ajouter un package de command bus ou utiliser le container Laravel. Mais le pattern ne dépend pas d’un outil. Il dépend de la séparation entre intention d’écriture et modèle de lecture.
Quand CQRS est justifié
CQRS devient intéressant dans ces situations.
1. Lecture et écriture n’ont plus le même modèle
Si ton écran a besoin d’une vue agrégée, paginée, filtrée, enrichie avec plusieurs tables, mais que ton écriture doit protéger un agrégat métier strict, sépare.
Le modèle de lecture peut devenir un DTO, une vue SQL, une table de projection, un document Elasticsearch ou une requête optimisée. Le modèle d’écriture reste centré sur les invariants.
2. Les lectures dominent fortement
Beaucoup de produits lisent beaucoup plus qu’ils n’écrivent : catalogues, dashboards, historiques, annonces, reporting.
CQRS permet d’optimiser les lectures sans dégrader le modèle d’écriture. Microsoft cite notamment l’optimisation indépendante des modèles et le scaling séparé comme bénéfices possibles.
3. Les écritures ont de vraies règles métier
CQRS a peu d’intérêt si écrire signifie “mettre à jour trois colonnes”.
Il devient utile quand une command doit orchestrer :
- validation d’état;
- autorisation métier;
- transaction;
- side effects contrôlés;
- événement;
- intégration externe;
- retry ou async.
4. Tu as plusieurs interfaces sur le même cas d’usage
Un paiement peut venir d’une API, d’un webhook, d’un back-office, d’une commande CLI ou d’un job de rattrapage. Si tout passe par le même command handler, tu évites de dupliquer la règle.
5. Tu veux introduire l’async proprement
Une command peut être synchrone au début, puis routée vers un transport async si le coût utilisateur devient trop élevé. Mais attention : async veut dire gestion d’échec, idempotence, retries et observabilité.
Quand CQRS est une mauvaise idée
Évite CQRS si :
- ton module est un CRUD simple;
- la même structure suffit pour formulaire, persistance et affichage;
- l’équipe ne sait pas encore maintenir les abstractions;
- tu n’as pas de tests autour des handlers;
- personne ne possède les projections;
- l’eventual consistency serait un problème produit;
- tu ajoutes CQRS pour “faire propre”.
Martin Fowler insiste sur le fait que beaucoup de systèmes rentrent très bien dans un modèle CRUD, et que CQRS ajoute un saut mental important. C’est le meilleur avertissement à garder en tête.
Le coût caché : cohérence et duplication
Séparer les modèles implique souvent de dupliquer des données ou des transformations.
Tu dois répondre à des questions :
- qui met à jour le read model ?
- que voit l’utilisateur entre l’écriture et la projection ?
- comment rejouer une projection cassée ?
- comment tester une command et sa lecture associée ?
- où vit la logique de filtre ?
- quel monitoring signale un retard de projection ?
Si tu n’as pas besoin de projection séparée, ne crée pas ce problème.
CQRS progressif : la version raisonnable
Tu peux appliquer CQRS en trois niveaux.
Niveau 1 : séparation logique
Même base, pas de bus obligatoire.
CreateInvoiceCommandCreateInvoiceHandlerGetInvoiceSummaryQueryGetInvoiceSummaryHandler
Ce niveau clarifie déjà les intentions.
Niveau 2 : bus et middlewares séparés
Commands avec transaction, queries sans transaction, events avec plusieurs handlers.
C’est le niveau naturel avec Symfony Messenger.
Niveau 3 : modèles physiques séparés
Read database, projection, index de recherche, cache matérialisé.
Ce niveau se justifie seulement avec un besoin fort : performance, reporting, scalabilité, isolation d’équipe ou audit.
Commence au niveau 1. Monte seulement si la douleur est réelle.
Checklist de décision
Avant d’introduire CQRS dans un module, réponds franchement :
- Est-ce que le modèle de lecture diverge du modèle d’écriture ?
- Est-ce que la lecture a des besoins de performance spécifiques ?
- Est-ce que l’écriture contient des invariants métier importants ?
- Est-ce que le module va durer assez longtemps pour amortir l’abstraction ?
- Est-ce que l’équipe sait tester des handlers isolés ?
- Est-ce que l’eventual consistency est acceptable si on introduit des projections ?
- Est-ce qu’un service layer simple suffirait ?
Si tu as moins de trois oui solides, reste simple.
Le bon résumé
CQRS sert à arrêter de forcer un seul modèle à faire deux métiers opposés.
Il est utile quand les lectures veulent aller vite, se dénormaliser, agréger, filtrer, et quand les écritures veulent protéger des règles strictes. Il est inutile quand ton application fait du CRUD classique avec peu de logique.
Le pattern n’est pas complexe en lui-même. Ce qui est complexe, c’est ce qu’il autorise ensuite : bus, projections, async, cohérence éventuelle, observabilité. Utilise-le module par module, à partir d’une douleur précise, et garde le CRUD là où le CRUD suffit.
Sources
Continuer la lecture