CQRS (Command Query Responsibility Segregation)
- architecture
- design-patterns
- scalabilité
Imaginez un supermarché. D'un côté, la réserve : des palettes empilées, un système d'inventaire complexe, des procédures de réception et de contrôle qualité. De l'autre, les rayons : des produits bien rangés, des étiquettes claires, un parcours optimisé pour que le client trouve ce qu'il cherche en quelques secondes.
Personne ne demanderait au client de naviguer dans la réserve pour trouver son paquet de pâtes. Et personne ne ferait l'inventaire en scannant les rayons du magasin. Les deux mondes ont des besoins différents et des organisations différentes. CQRS applique exactement ce principe au logiciel : séparer le modèle d'écriture du modèle de lecture.
Le problème : un modèle unique pour tout
Dans une architecture classique, le même modèle de données sert à la fois pour les lectures et les écritures. Le même objet Commande est utilisé pour créer une commande, la modifier, et l'afficher dans un tableau de bord.
// Un seul modèle pour tout
class Commande {
id: string;
clientId: string;
lignes: LigneDeCommande[];
statut: string;
dateCreation: Date;
adresseLivraison: Adresse;
montantHT: number;
montantTVA: number;
montantTTC: number;
codePromo?: string;
remise?: number;
}Ça fonctionne pour les cas simples. Mais quand le système grandit, les problèmes apparaissent :
- Les lectures sont lentes — pour afficher un tableau de bord, il faut joindre 5 tables et calculer des agrégats à la volée
- Les écritures sont complexes — la validation d'une commande implique des règles métier qui n'ont rien à voir avec l'affichage
- Le modèle est un compromis — il n'est optimal ni pour la lecture, ni pour l'écriture
- Le scaling est impossible à cibler — vous ne pouvez pas scaler les lectures indépendamment des écritures
CQRS : deux modèles séparés
CQRS (Command Query Responsibility Segregation) sépare le système en deux côtés distincts :
Le côté Command (écriture) — reçoit des intentions de changement, valide les règles métier, et modifie l'état du système. Le modèle est optimisé pour la cohérence et les invariants métier.
Le côté Query (lecture) — répond aux requêtes en retournant des données. Le modèle est optimisé pour la performance et la forme des données attendues par le client.
┌──────────────────────────────────────────────────┐
│ Client │
│ │
│ Commandes (écriture) Requêtes (lecture) │
└───────┬──────────────────────────┬───────────────┘
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ Command Side │ │ Query Side │
│ │ │ │
│ Validations │ │ Vues │
│ Règles métier│ ──► │ Projections │
│ Aggregates │ sync │ Dénormalisé │
│ │ │ │
│ Base write │ │ Base read │
└───────────────┘ └───────────────┘
En pratique
Le côté Command
Les commandes sont des intentions : CréerCommande, ValiderCommande, AnnulerCommande. Chaque commande est traitée par un handler qui contient la logique métier.
// La commande — une intention
interface ValiderCommande {
commandeId: string;
utilisateurId: string;
}
// Le handler — la logique métier
class ValiderCommandeHandler {
constructor(private commandes: CommandeRepository) {}
async execute(cmd: ValiderCommande): Promise<void> {
const commande = await this.commandes.findById(cmd.commandeId);
if (!commande) throw new Error("Commande introuvable");
commande.valider(); // Vérifie les invariants métier
await this.commandes.save(commande);
}
}Le côté Query
Les requêtes retournent des vues optimisées pour l'affichage. Pas d'objets métier complexes — des structures plates, pré-calculées, prêtes à consommer.
// La vue — optimisée pour l'affichage
interface CommandeListeVue {
id: string;
clientNom: string;
nombreArticles: number;
montantTTC: number;
statut: string;
dateCreation: string;
}
// Le query handler — lecture simple
class ListerCommandesHandler {
constructor(private readDb: ReadDatabase) {}
async execute(clientId: string): Promise<CommandeListeVue[]> {
// Une seule requête sur une table dénormalisée
return this.readDb.query(
"SELECT * FROM commandes_vue WHERE client_id = $1 ORDER BY date_creation DESC",
[clientId]
);
}
}La table commandes_vue est une projection : une table dénormalisée, mise à jour à chaque écriture, qui contient exactement les données nécessaires à l'affichage. Pas de jointures, pas de calculs à la volée.
Les bénéfices
Scaling indépendant. 90% des requêtes sont des lectures ? Ajoutez des replicas de lecture sans toucher au serveur d'écriture. Le ratio lecture/écriture n'est plus un problème.
Modèles optimisés. Le modèle d'écriture se concentre sur les invariants et la cohérence. Le modèle de lecture se concentre sur la performance et la forme des données. Chacun est optimal pour son rôle.
Requêtes simples et rapides. Plus besoin de jointures complexes sur 5 tables pour afficher un dashboard. La projection contient déjà les données dans le bon format.
Évolution indépendante. Ajouter un nouveau champ dans le dashboard n'impacte pas la logique d'écriture. Ajouter une règle de validation n'impacte pas les vues.
CQRS et Event Sourcing
CQRS est souvent associé à l'Event Sourcing, mais ce sont deux concepts distincts. L'Event Sourcing stocke l'état du système comme une séquence d'événements (CommandeCréée, ArticleAjouté, CommandeValidée) plutôt que comme un état final. Les projections de lecture sont reconstruites en rejouant ces événements.
La combinaison est puissante mais complexe. Vous pouvez — et devriez souvent — utiliser CQRS sans Event Sourcing. Une simple table de projection synchronisée par des triggers ou des événements de domaine suffit dans la majorité des cas.
Quand utiliser CQRS
| Situation | CQRS utile ? |
|---|---|
| Ratio lecture/écriture très déséquilibré | Oui |
| Besoins de lecture complexes (dashboards, rapports) | Oui |
| Logique métier d'écriture riche et complexe | Oui |
| Besoin de scaler lectures et écritures indépendamment | Oui |
| Application CRUD simple | Non |
| Petit projet avec peu d'utilisateurs | Non |
| Équipe peu expérimentée en architecture | Non |
Les erreurs courantes
Appliquer CQRS partout. CQRS ajoute de la complexité — deux modèles à maintenir, une synchronisation à gérer. Pour un module CRUD basique, c'est de l'over-engineering.
Ignorer la cohérence éventuelle. Si la projection de lecture est mise à jour de manière asynchrone, il y a un délai entre l'écriture et la lecture. L'utilisateur crée une commande et ne la voit pas immédiatement dans la liste. Il faut gérer cette latence (optimistic UI, messages de confirmation).
Complexifier la synchronisation. Commencez simple : une projection mise à jour de manière synchrone dans la même transaction que l'écriture. Passez à l'asynchrone uniquement si la performance l'exige.
CQRS n'est pas une architecture à adopter par défaut. C'est un outil pour les systèmes où les besoins de lecture et d'écriture divergent suffisamment pour justifier deux modèles séparés. Comme le supermarché : la réserve est organisée pour l'efficacité logistique, les rayons sont organisés pour l'expérience client. Quand ces deux objectifs entrent en conflit dans votre code, CQRS est la réponse.