Les quatre articles précédents de la série ont posé les briques pour qu’un système distribué reste cohérent : Saga pour orchestrer des workflows, Outbox pour publier des événements fiables, Inbox pour les consommer sans doublon, Idempotency Keys pour protéger l’API. Il manque une question : que fait-on des événements une fois publiés ?
La réponse la plus fréquente est : on les utilise pour construire des vues de lecture. C’est exactement ce que propose le pattern CQRS (Command Query Responsibility Segregation) : séparer le modèle d’écriture du modèle de lecture, quand les deux divergent suffisamment pour que les forcer dans une même structure coûte plus cher que les dédoubler.
Cet article ferme la série en gardant la même ligne directrice : du concret, pas de dogme, et surtout pas d’Event Sourcing. Le CQRS pragmatique sur Django se résume à “deux modèles, deux chemins, un pont d’événements”, et ça suffit dans la grande majorité des cas.
Le problème : reads et writes ne veulent pas la même chose
Une commande e-commerce, côté écriture, est un objet métier riche avec des invariants : un statut qui ne peut pas régresser, des lignes qui doivent correspondre à du stock disponible, un montant calculé à partir de règles. Le modèle Django reflète ces contraintes.
class Commande(models.Model):
statut = models.CharField(choices=STATUTS_VALIDES)
client = models.ForeignKey(Client, on_delete=PROTECT)
adresse_livraison = models.ForeignKey(Adresse, on_delete=PROTECT)
def confirmer(self):
if self.statut != "panier":
raise InvalidTransition(...)
...
Côté lecture, la même donnée prend une autre forme. L’écran “Mes commandes” du client veut afficher : numéro, date, total, statut lisible (“En préparation”), nombre d’articles, image du premier produit. Pour produire cet écran, le serveur joint Commande, LigneCommande, Produit, ImageProduit, StatutLabel, sur des dizaines de milliers de lignes, à chaque ouverture du dashboard.
Quand l’application grossit, deux pressions opposées émergent. Le write model veut rester normalisé, contraint, intègre. Le read model veut être plat, rapide, parfois en avance sur la base au prix d’une cohérence éventuelle. Tenter de servir les deux besoins depuis les mêmes tables finit par sacrifier l’un des deux.
Le principe : deux modèles, deux chemins
CQRS dit : arrêtez de chercher le compromis, dédoublez. Les commands modifient l’état dans le modèle d’écriture. Les queries lisent dans un modèle de lecture, structurellement différent, mis à jour en arrière-plan à partir des événements émis par les commands.
Client → API command → Write model (Django ORM) → événement (Outbox)
↓
Broker (Kafka)
↓
Client → API query ← Read model (table plate) ← Inbox + denormalizer
Les deux chemins n’ont plus rien à voir. Le write model reste un domaine DDD propre avec ses invariants. Le read model est une projection optimisée pour les écrans réels de l’application. Aucune jointure complexe sur le chemin de lecture, aucune contrainte du modèle de lecture qui remonte polluer la logique métier.
Le read model : une table plate alimentée par events
Un exemple concret pour l’écran “Mes commandes” :
class CommandeReadModel(models.Model):
commande_id = models.UUIDField(primary_key=True)
client_id = models.BigIntegerField(db_index=True)
numero = models.CharField(max_length=32)
cree_le = models.DateTimeField()
statut = models.CharField(max_length=32)
statut_label = models.CharField(max_length=64)
total_cents = models.IntegerField()
nombre_articles = models.IntegerField()
image_apercu_url = models.URLField(blank=True)
derniere_maj = models.DateTimeField()
class Meta:
indexes = [
models.Index(fields=["client_id", "-cree_le"]),
]
Aucune ForeignKey. Aucune relation. Aucune logique. C’est volontairement une “vue dénormalisée à plat” qui ne sert qu’un usage : afficher la liste des commandes d’un client, triée par date. La requête de lecture devient triviale :
def liste_commandes(client_id):
return CommandeReadModel.objects.filter(client_id=client_id)[:50]
Un seul SELECT indexé, pas de JOIN, pas de N+1. Performance constante quel que soit le nombre de lignes ou de produits par commande.
Le pont : un consumer qui dénormalise
Le read model est construit et maintenu par un consumer qui écoute les événements émis par le write model via l’Outbox. Chaque événement déclenche une mise à jour du read model, protégée par le pattern Inbox pour éviter les doublons.
def handle_commande_confirmee(event: dict) -> None:
try:
with transaction.atomic():
InboxEvent.objects.create(
event_id=event["event_id"],
consumer="commandes_read_model",
)
CommandeReadModel.objects.update_or_create(
commande_id=event["commande_id"],
defaults={
"client_id": event["client_id"],
"numero": event["numero"],
"cree_le": event["cree_le"],
"statut": "confirmee",
"statut_label": "Confirmée",
"total_cents": event["total_cents"],
"nombre_articles": event["nombre_articles"],
"image_apercu_url": event["image_apercu_url"],
"derniere_maj": timezone.now(),
},
)
except IntegrityError:
return
Le update_or_create est intentionnel : le même événement peut concerner une commande qui a déjà été insérée dans le read model par un événement précédent (par exemple CommandeCreee puis CommandeConfirmee). On veut l’état le plus récent, pas un échec sur la clé primaire.
Le payload de l’événement doit contenir tout ce dont le read model a besoin. C’est un choix structurant : on évite que le consumer aille re-requêter le write model, parce que ça recouple les deux modèles et perd l’intérêt de la séparation.
Trois manières de matérialiser le read model
Le read model n’est pas forcément une table Django classique. Trois options coexistent selon le volume et la fraîcheur exigée.
Table dénormalisée gérée par le consumer. L’approche par défaut, comme ci-dessus. Simple, transactionnelle, lisible avec l’ORM Django. C’est le bon choix pour la grande majorité des cas.
Vue matérialisée PostgreSQL. Au lieu de maintenir la table à la main via des events, on déclare une MATERIALIZED VIEW qui agrège les données du write model. Un REFRESH MATERIALIZED VIEW CONCURRENTLY périodique met à jour la vue. Pas besoin d’Outbox ni de consumer. La contrepartie : la fraîcheur est limitée par la fréquence du refresh, et le coût du refresh croît avec le volume.
Index Elasticsearch ou OpenSearch. Quand les requêtes deviennent du full-text search ou de l’agrégation analytique, un read model dans un moteur dédié devient pertinent. Le consumer écrit dans Elasticsearch à la place d’une table SQL. Le pattern est identique, seule la destination change.
Le choix dépend de la requête à servir, pas d’un principe abstrait. Sur un même projet, plusieurs read models peuvent coexister, chacun optimisé pour son usage.
La cohérence éventuelle, et ce qu’elle implique
Le read model est en retard sur le write model. Entre le moment où une commande est confirmée et celui où elle apparaît dans le read model, il s’écoule quelques millisecondes à quelques secondes, selon le débit du broker et la charge des consumers. C’est la cohérence éventuelle, et c’est le compromis fondamental du CQRS.
Trois conséquences pratiques.
Après une commande, le client doit voir sa commande. Si la page de confirmation lit le read model, elle peut afficher “Aucune commande” pendant les 200 ms où l’événement n’a pas encore été propagé. La solution : lire le write model juste après une command, et basculer sur le read model pour les pages génériques. Le compromis se gère page par page, pas globalement.
Les rapports administratifs peuvent vivre avec quelques secondes de retard. C’est le cas idéal du read model : un dashboard “ventes du jour” qui se rafraîchit toutes les 30 secondes ne souffre pas d’avoir 2 secondes de décalage par rapport au write model.
Le read model peut être reconstruit depuis zéro. Si la projection est buggée ou si on veut ajouter un champ, on vide la table et on rejoue tous les événements depuis le début. Ce mécanisme demande une rétention longue côté broker, ou une seconde source pour rejouer. Quand c’est possible, ça transforme le read model en cache reconstructible plutôt qu’en source de vérité fragile.
CQRS n’est pas Event Sourcing
La confusion la plus fréquente. CQRS dit “deux modèles, deux chemins”. Event Sourcing dit “ne stocke plus l’état, stocke la suite des événements qui ont produit cet état”. Les deux peuvent se combiner, mais ce n’est pas obligatoire.
Le CQRS pragmatique présenté ici garde un write model Django classique avec une table Commande qui contient l’état courant. Les événements émis vers le read model décrivent ce qui a changé, mais ne sont pas la source de vérité. La source de vérité reste l’ORM, comme dans n’importe quel projet Django.
Event Sourcing implique de stocker les événements eux-mêmes comme état primaire, et de reconstruire toute information par replay. C’est une approche radicalement différente, avec ses propres défis (versioning des événements, snapshots, performances de replay). Elle mérite son propre article, voire sa propre série.
Quand ne pas faire de CQRS
Le pattern ajoute une infrastructure : Outbox, broker, consumer, table dénormalisée, gestion de l’eventual consistency. Pour une application qui sert quelques milliers d’utilisateurs avec des requêtes simples, c’est de la sur-ingénierie.
CQRS devient pertinent dès que :
- les requêtes de lecture coûtent significativement plus que les écritures (jointures lourdes, agrégations)
- les modèles d’écriture et de lecture divergent au point que les
SerializerDjango REST Framework deviennent illisibles - on veut pouvoir scaler horizontalement les lectures sans toucher au write model
- on a déjà adopté l’Outbox pour d’autres raisons, et le read model devient une consommation gratuite des événements existants
À l’inverse, pour un CRUD classique avec des écrans qui mappent presque 1-1 sur les modèles, l’ORM Django avec quelques select_related et prefetch_related reste largement supérieur en simplicité et en cohérence.
Conclusion
CQRS pragmatique est un pattern d’optimisation, pas un dogme architectural. On l’introduit là où la divergence read/write fait mal, pas par principe. Le bénéfice est de pouvoir donner à chaque côté ce qu’il veut : un write model intègre et contraint, un read model rapide et plat.
La série sur les patterns d’architecture distribuée se termine ici, avec un système où chaque frontière a sa protection et où chaque chemin de lecture est optimisé pour son usage. Saga, Outbox, Inbox, Idempotency Keys et CQRS forment ensemble une boîte à outils qu’on assemble selon les besoins réels, pas selon une vision idéale d’architecture.
