CQRS, ou les deux métiers d'un même modèleCQRS, or the two jobs of one model
Le Repository (#1) rangeait lecture et écriture derrière une frontière, en supposant un seul modèle. CQRS questionne ce postulat : écrire exige un agrégat riche et cohérent, lire un tableau de bord exige des lignes plates et rapides — un seul objet ne peut être bon aux deux. La réponse est un spectre : sa forme la moins chère est deux modèles dans la même base, presque gratuite ; sa forme radicale — deux stockages, projection asynchrone — n'achète l'échelle qu'en signant la cohérence à terme. On monte d'un cran sous une mesure, jamais sous une mode.The Repository (#1) tucked reads and writes behind a boundary, assuming one model. CQRS questions that: writing demands a rich, consistent aggregate, reading a dashboard demands flat, fast rows — one object can't be good at both. The answer is a spectrum: its cheapest form is two models in one database, nearly free; its radical form — two stores, async projection — buys scale only by signing for eventual consistency. You climb one notch under a measurement, never a fashion.
AudienceAudience
Dev dont un seul modèle sert à la fois les règles métier et les tableaux de bordDev whose single model serves both business rules and dashboards
Un modèle, deux maîtres. Lire et écrire ne veulent pas la même chose.One model, two masters. Reading and writing don't want the same thing.
Le Repository (#1) a rangé lecture et écriture derrière une frontière — en supposant un seul modèle. Ce numéro questionne ce postulat. Le même Order sert deux besoins de natures opposées : écrire exige un agrégat riche avec ses invariants (on n'expédie que payé), cohérent, modifié une instance à la fois ; lire un tableau de bord exige des lignes plates, dénormalisées, rapides, par milliers, sans aucun comportement. Forcer un seul objet à être bon aux deux, c'est le condamner à être médiocre aux deux. Ce qui varie : la forme que les données doivent prendre selon qu'on décide ou qu'on affiche.The Repository (#1) tucked reads and writes behind a boundary — assuming a single model. This issue questions that. The same Order serves two needs of opposite natures: writing demands a rich aggregate with its invariants (only ship if paid), consistent, modified one instance at a time; reading a dashboard demands flat, denormalized, fast rows, by the thousand, with no behavior at all. Forcing one object to be good at both condemns it to be mediocre at both. What varies: the shape data must take depending on whether you decide or display.
Le code naïf — un modèle écartelé entre écrire et lireThe naive code — one model torn between writing and reading
# UN seul modèle Order, tiré par deux besoins opposésclass Order: # le modèle d'écriture : invariants, comportementdef ship(self):
if self.status != "paid": raise CannotShip()
self.status = "shipped"# … et le même Order doit servir le tableau de bord :def orders_dashboard():
orders = repo.all() # 5000 agrégats Order hydratés…return [(o.customer.name, o.total(), o.status, len(o.lines)) # … pour 4 colonnesfor o in orders] # N+1 sur customer, sur lines, lent# écrire veut un agrégat riche et cohérent ; lire veut des lignes plates et rapides.# le même objet ne peut pas être bon aux deux à la fois.
Écrire vs lireWriting vs reading
commande → expédiéecommand → shipped
agrégat · invariants · 1 à la foisaggregate · invariants · one at a time
↔
même objetsame object
tableau de borddashboard
lignes plates · dénormalisé · par milliersflat rows · denormalized · by the thousand
Le modèle d'écriture veut peu d'objets riches et cohérents ; le modèle de lecture veut beaucoup de lignes plates et rapides. Tant qu'ils sont le même objet, chaque colonne d'affichage alourdit l'agrégat, et chaque invariant complique la requête.The write model wants few rich, consistent objects; the read model wants many flat, fast rows. As long as they're the same object, every display column bloats the aggregate, and every invariant complicates the query.
Le réflexe du volumeThe volume's reflex
« Est-ce que je lis et j'écris le même modèle — et est-ce que ça me coûte ? » Le #1 a répondu « range les deux derrière une frontière ». CQRS pousse la question plus loin : et si lire et écrire avaient des besoins si divergents qu'un seul modèle ne sert bien ni l'un ni l'autre ? La réponse : séparer. Deux modèles — un pour commander, un pour interroger — possiblement deux stockages. Le mot fait peur ; sa version la moins chère est juste deux objets dans la même base.'Do I read and write the same model — and does it cost me?' #1 answered 'put both behind a boundary'. CQRS pushes further: what if reading and writing had needs so divergent that one model serves neither well? The answer: separate. Two models — one to command, one to query — possibly two stores. The word is scary; its cheapest version is just two objects in the same database.
L'hypothèse que le #1 laissait dormir.The assumption #1 left sleeping.
Le Repository supposait un modèle unique derrière sa frontière : on trouve un Order, on le modifie, on le sauve. C'est juste tant que lire ressemble à écrire. Mais un Repository qui doit aussi servir des listes agrégées finit par exposer des find_by_… qui prolifèrent, ou à rendre des entités lourdes pour de l'affichage. CQRS est l'aveu que ce n'était pas un seul besoin déguisé en deux méthodes, mais deux besoins qui méritent deux modèles. C.Q.R.S. : Command Query Responsibility Segregation.The Repository assumed a single model behind its boundary: find an Order, modify it, save it. That's right as long as reading resembles writing. But a Repository that must also serve aggregated lists ends up sprouting proliferating find_by_… methods, or returning heavy entities for display. CQRS is the admission that this wasn't one need disguised as two methods, but two needs that deserve two models. C.Q.R.S.: Command Query Responsibility Segregation.
№ 02La douleurThe pain4 min
L'agrégat gonfle pour l'affichage, et les lectures traînent l'écriture.The aggregate bloats for display, and reads drag the write model.
Forcer un seul modèle a trois coûts qui se renforcent. L'agrégat d'écriture se met à porter des champs d'affichage — un total dénormalisé, le nom du client, un compteur — qui n'ont rien à voir avec ses invariants : la pièce qui doit rester cohérente se charge de choses qui n'engagent aucune règle. Les lectures, elles, paient le prix de la richesse : afficher une liste oblige à hydrater des agrégats entiers, ligne par ligne, avec leurs N+1. Et les deux besoins se disputent à chaque changement : ajouter une colonne au tableau de bord rouvre l'entité métier ; optimiser une requête risque un invariant.Forcing one model has three costs that reinforce each other. The write aggregate starts carrying display fields — a denormalized total, the customer name, a counter — that have nothing to do with its invariants: the piece that must stay consistent gets loaded with things no rule governs. Reads, in turn, pay for richness: showing a list forces hydrating whole aggregates, row by row, with their N+1. And the two needs fight on every change: adding a dashboard column reopens the domain entity; tuning a query risks an invariant.
№ 1 · L'agrégat gonfle pour l'affichageThe aggregate bloats for display
On ajoute total_cached, customer_name, item_count à Order pour éviter des jointures à l'affichage. La pièce censée garder les invariants se met à porter des données dérivées qu'aucune règle ne protège — et qu'il faut maintenir à jour à la main.You add total_cached, customer_name, item_count to Order to dodge joins at display time. The piece meant to hold invariants starts carrying derived data no rule protects — and that must be kept in sync by hand.
№ 2 · Lire coûte le prix d'écrireReading pays the write model's price
Afficher une liste de 5000 commandes hydrate 5000 agrégats riches, avec leurs lignes et leur client : N+1, mémoire, lenteur. On charge tout un modèle d'écriture pour afficher quatre colonnes qui n'ont besoin d'aucun comportement.Showing a list of 5000 orders hydrates 5000 rich aggregates, with their lines and customer: N+1, memory, slowness. You load a whole write model to display four columns that need no behavior at all.
№ 3 · Les deux besoins se disputentThe two needs fight
Ajouter une colonne au tableau de bord touche l'entité métier ; dénormaliser pour la vitesse fragilise un invariant ; un index pour la lecture contraint le schéma d'écriture. Chaque optimisation d'un côté est une régression de l'autre.Adding a dashboard column touches the domain entity; denormalizing for speed weakens an invariant; an index for reads constrains the write schema. Each optimization on one side is a regression on the other.
La liste qui hydrate tout un modèle d'écriture pour quatre colonnesThe list that hydrates a whole write model for four columns
def orders_dashboard():
orders = repo.all() # 💥 5000 agrégats Order…for o in orders:
o.customer # … N+1 : une requête par client…
o.lines # … N+1 : une requête par lot de lignes…return [(o.customer.name, o.total(), o.status) for o in orders] # 3 colonnes.
Le diagnostic.The diagnosis.
Le détecteur : « mon modèle d'écriture porte-t-il des champs qui n'existent que pour l'affichage, ou mes lectures hydratent-elles des agrégats dont elles n'utilisent que trois champs ? ». Si oui, un seul modèle sert deux besoins qui tirent en sens inverse. Le réflexe : séparer la responsabilité — les commandes passent par le modèle riche d'écriture (Repository, invariants) ; les requêtes lisent un modèle plat dédié (une vue, un DTO, un read model) qui n'a aucun comportement et qu'on peut dénormaliser librement. Pas forcément deux bases : d'abord deux modèles.The detector: 'does my write model carry fields that exist only for display, or do my reads hydrate aggregates of which they use just three fields?'. If yes, one model serves two needs pulling opposite ways. The reflex: separate the responsibility — commands go through the rich write model (Repository, invariants); queries read a dedicated flat model (a view, a DTO, a read model) with no behavior, free to denormalize. Not necessarily two databases: first, two models.
№ 03La ligne de partageThe dividing line5 min
On scinde le modèle : une voie pour commander, une voie pour lire.Split the model: one path to command, one path to read.
CQRS trace une ligne entre ce qui change l'état (les commandes) et ce qui le lit (les requêtes). L'animation part du modèle unique écartelé, le sépare en deux — un modèle d'écriture qui garde les invariants, un modèle de lecture plat et libre —, puis montre l'écriture projeter ses changements vers la lecture, et enfin la lecture déménager sur son propre stockage. À chaque marche, le bénéfice grandit ; à la dernière, le prix apparaît : la cohérence à terme.CQRS draws a line between what changes state (commands) and what reads it (queries). The animation starts from the torn single model, splits it in two — a write model keeping the invariants, a flat free read model —, then shows the write side projecting its changes to the read side, and finally the read side moving to its own store. Each step grows the benefit; at the last, the price appears: eventual consistency.
Animation — la ligne entre commander et interrogerAnimation — the line between commanding and querying
Départ — un modèle, deux voiesStart — one model, two paths · 1/4
CommandesCommands
écrirewrite
▶
Order
modèle unique · écartelésingle model · torn
◀
RequêtesQueries
lireread
deux besoins opposés tirent le même objettwo opposite needs pull the same object
Commandes et requêtes tapent le même modèle Order. Il est tiré dans deux directions : riche et cohérent pour écrire, plat et rapide pour lire. Il ne peut pas être bon aux deux.Commands and queries hit the same Order model. It's pulled two ways: rich and consistent to write, flat and fast to read. It can't be good at both.
Deux voies, deux modèles — 3 lignes à retrouverTwo paths, two models — 3 lines to recall
# la voie d'écriture — agrégat riche, invariants (le Repository du #1)class ShipOrder(repo: OrderRepository):
def run(order_id):
order = repo.find(order_id) # modèle d'écriture# la voie de lecture — modèle plat, zéro comportement, dénormaliséclass OrderSummary: # un read model, pas une entitédef dashboard() -> list[OrderSummary]: # une requête, des lignes plates — n'hydrate aucun agrégat.
3 ligne(s) à retrouver — écris-la de tête avant de cliquer3 line(s) to recall — write it in your head before clicking
Le motif transférable.The transferable pattern.
« Sépare la voie qui change l'état de la voie qui le lit. » CQRS n'exige pas deux bases de données — c'est une idée fausse répandue. Sa forme minimale est deux modèles dans la même base : un agrégat pour écrire, une vue ou un DTO pour lire. La forme moyenne : un read model dénormalisé tenu à jour par projection. La forme radicale : un stockage de lecture séparé (réplica, vue matérialisée, index), nourri en asynchrone. Trois points sur un spectre — on monte d'un cran seulement quand la douleur le réclame.'Separate the path that changes state from the path that reads it.' CQRS doesn't require two databases — a widespread misconception. Its minimal form is two models in one database: an aggregate to write, a view or DTO to read. The middle form: a denormalized read model kept current by projection. The radical form: a separate read store (replica, materialized view, index), fed asynchronously. Three points on a spectrum — you climb one notch only when the pain demands it.
Modèle mentalMental model
L'imageThe image
Une cuisine et son passe. Derrière, on compose chaque plat dans les règles, une commande à la fois — l'écriture : riche, cohérente, lente s'il le faut. Devant, le passe aligne des assiettes déjà prêtes que le service emporte d'un geste, sans rien recomposer — la lecture : plate, rapide, en série. Même restaurant, deux formes délibérément différentes.A kitchen and its pass. Behind, each dish is composed by the rules, one order at a time — the write: rich, consistent, slow if it must be. Up front, the pass lines up ready plates the servers grab in one move, recomposing nothing — the read: flat, fast, in bulk. Same restaurant, two deliberately different shapes.
La règleThe rule
Sépare la voie qui change l'état (les commandes, par l'agrégat et ses invariants) de la voie qui le lit (les requêtes, par un modèle plat). Le moins cher : deux modèles dans une seule base. On ne monte le prix — projection, stockage séparé — que sous une mesure.Separate the path that changes state (commands, through the aggregate and its invariants) from the path that reads it (queries, through a flat model). The cheapest: two models in one database. You raise the price — projection, separate store — only under a measurement.
Où l'analogie casseWhere the analogy breaks
Au passe, une assiette prête est déjà servie ; personne ne la « recuisine ». Un modèle de lecture séparé, si : dès qu'il vit dans son propre stockage nourri en asynchrone, il affiche un état en retard sur l'écriture — la cohérence à terme. L'analogie du passe suppose le plat prêt à l'instant ; le read model distant, lui, arrive avec un délai. C'est là que « presque gratuit » cesse de l'être.At the pass, a ready plate is already served; nobody 're-cooks' it. A separate read model does: the moment it lives in its own store fed asynchronously, it shows a state lagging behind the write — eventual consistency. The pass analogy assumes the plate is ready right now; the remote read model arrives with a lag. That's where 'nearly free' stops being free.
№ 04Le piègeThe trap4 min
Séparer les modèles est presque gratuit. Séparer les stockages se paie en cohérence.Splitting models is nearly free. Splitting stores is paid in consistency.
Le piège n'est pas CQRS — c'est sauter directement à sa forme radicale. On lit « CQRS » et on entend « deux bases de données + un pipeline d'événements », alors qu'on n'a aucune asymétrie de charge à justifier. On hérite immédiatement de la cohérence à terme : un délai entre écrire et voir. Le bug canonique apparaît — l'utilisateur crée quelque chose, est redirigé vers la liste, et ne s'y voit pas, parce que la projection n'a pas encore tourné. On a payé deux stockages et un pipeline pour empirer l'expérience. Bascule le toggle : cherche le moment où écrire et voir cessent d'être atomiques.The trap isn't CQRS — it's jumping straight to its radical form. You read 'CQRS' and hear 'two databases + an event pipeline', when you have no load asymmetry to justify it. You inherit eventual consistency at once: a lag between writing and seeing. The canonical bug appears — the user creates something, is redirected to the list, and doesn't see it there, because the projection hasn't run yet. You paid for two stores and a pipeline to make the experience worse. Flip the toggle: hunt the moment writing and seeing stop being atomic.
# CQRS comme séparation de MODÈLES — une seule base transactionnelleclass ShipOrder(repo): # commande : agrégat riche, invariantsdef run(id): o = repo.find(id); o.ship(); repo.save(o)
def order_dashboard(): # requête : modèle plat, même basereturn db.query("SELECT id, customer_name, total, status FROM order_summary")
# order_summary est une VUE de la même base → lecture cohérente, immédiate.# bénéfice : modèle d'écriture propre + lectures taillées, SANS cohérence à terme.# on déménagera vers un stockage séparé le jour — mesuré — où la charge l'exige.
Pourquoi c'est justeWhy it's right
On a pris la séparation qui coûte presque rien : deux modèles, une seule base. Les commandes passent par l'agrégat riche et ses invariants ; les requêtes lisent une vue plate de la même base — donc cohérente et immédiate, aucun délai, aucun pipeline. On gagne l'essentiel de CQRS (un modèle d'écriture propre, des lectures taillées) sans signer la cohérence à terme. Et le jour — mesuré — où la lecture écrase l'écriture, on déménage la vue sur un stockage séparé : on monte d'un cran sur le spectre, pas trois d'un coup.You took the separation that costs almost nothing: two models, one database. Commands go through the rich aggregate and its invariants; queries read a flat view of the same database — hence consistent and immediate, no lag, no pipeline. You get the essence of CQRS (a clean write model, tailored reads) without signing for eventual consistency. And the day — measured — reads crush writes, you move the view to a separate store: you climb one notch on the spectrum, not three at once.
La règle qui tranche.The rule that settles it.
Sépare les modèles dès que lire et écrire ont des formes divergentes ; sépare les stockages seulement quand une charge mesurée l'exige. Le test : « ai-je une pression de lecture que mon stockage d'écriture ne tient pas ? ». Tant que non, garde une seule base — la vue reste cohérente. Et dès que tu sépares les stockages, tu as acheté la cohérence à terme : l'interface doit tolérer une lecture en retard (afficher l'écriture en optimiste, attendre un accusé, ou prévenir l'utilisateur). Pas de stockage séparé sans une UI qui assume le délai.Split the models as soon as reading and writing have divergent shapes; split the stores only when measured load demands it. The test: 'do I have read pressure my write store can't hold?'. As long as no, keep one database — the view stays consistent. And the moment you split the stores, you've bought eventual consistency: the UI must tolerate a stale read (show the write optimistically, wait for an ack, or warn the user). No separate store without a UI that owns the lag.
L'amalgame le plus courant : croire que CQRS impose de stocker des événements. Faux. CQRS ne dit que « sépare lire d'écrire » ; il marche très bien au-dessus d'un CRUD classique, avec une simple vue. Event Sourcing (le #3) est un choix orthogonal — stocker les faits plutôt que l'état — qu'on combine parfois à CQRS, mais qui apporte sa propre facture, bien plus lourde. Prendre les deux d'un coup « parce que ça va ensemble », c'est doubler la dette pour un seul besoin. On les garde séparés ; le #3 plantera le panneau danger.The commonest conflation: believing CQRS requires storing events. False. CQRS only says 'separate reading from writing'; it works fine over a classic CRUD, with a plain view. Event Sourcing (#3) is an orthogonal choice — storing facts rather than state — sometimes combined with CQRS, but carrying its own, far heavier bill. Taking both at once 'because they go together' doubles the debt for a single need. We keep them apart; #3 will plant the danger sign.
№ 05Bilan & éditoWrap-up & editorial2 min
Un modèle pour tout, ou deux modèles pour deux métiers.One model for everything, or two models for two jobs.
Les trois points du spectre, côte à côteThe three points of the spectrum, side by side
modèle uniquesingle model
2 modèles · 1 base2 models · 1 store
2 stockages2 stores
Modèle d'écritureWrite model
gonflé pour l'affichagebloated for display
propre, invariantsclean, invariants
propre, invariantsclean, invariants
LecturesReads
hydratent l'agrégathydrate the aggregate
vue plate, immédiateflat view, immediate
stockage taillé, à l'échelletailored store, at scale
CohérenceConsistency
immédiateimmediate
immédiateimmediate
à terme (délai)eventual (lag)
Coût d'infraInfra cost
nulnone
quasi nulnear none
pipeline + 2 storespipeline + 2 stores
QuandWhen
lire ≈ écrireread ≈ write
formes divergentesdivergent shapes
charge de lecture mesuréemeasured read load
Quand ne pas séparerWhen not to separate
CQRS a un loyer qui grimpe vite avec la radicalité : deux modèles à tenir alignés, et — dès qu'on sépare les stockages — un pipeline de projection et la cohérence à terme. On reste sur un modèle unique — et c'est correct — quand :CQRS has rent that climbs fast with radicality: two models to keep aligned, and — once you split the stores — a projection pipeline and eventual consistency. Stay with a single model — and that's fine — when:
lire ressemble à écrire : un CRUD où l'on affiche à peu près ce qu'on saisit n'a pas d'asymétrie à exploiter. Deux modèles identiques, c'est de la duplication sans bénéfice — exactement la cérémonie que YAGNI (Vol 0 #3) refuse.reading resembles writing: a CRUD where you display roughly what you enter has no asymmetry to exploit. Two identical models is duplication with no benefit — exactly the ceremony YAGNI (Vol 0 #3) refuses.
le volume de lecture est calme : si une vue de la même base répond en quelques millisecondes, séparer les stockages n'achète qu'une cohérence à terme dont tu ne veux pas. La séparation des stockages se mérite par une mesure, jamais par anticipation.the read volume is calm: if a view of the same database answers in a few milliseconds, splitting the stores buys only an eventual consistency you don't want. Splitting stores is earned by a measurement, never by anticipation.
l'utilisateur doit voir son écriture immédiatement : un formulaire qui redirige vers une liste, un panier, un solde — partout où « read-your-own-write » est non négociable, la cohérence à terme est un bug, pas un compromis. Reste cohérent tant que tu peux.the user must see their write immediately: a form redirecting to a list, a cart, a balance — anywhere 'read-your-own-write' is non-negotiable, eventual consistency is a bug, not a tradeoff. Stay consistent as long as you can.
Le réflexe n'est donc pas « fais du CQRS, c'est scalable » ni « n'en fais jamais, c'est compliqué ». C'est : « lire et écrire ont-ils des formes divergentes ? Alors deux modèles. Et seulement si la charge le force, deux stockages — en sachant que je signe alors la cohérence à terme. »So the reflex isn't 'do CQRS, it scales' nor 'never do it, it's complex'. It's: 'do reading and writing have divergent shapes? Then two models. And only if load forces it, two stores — knowing I then sign for eventual consistency.'
Mot de l'éditeurFrom the editor
CQRS souffre d'une réputation que ce numéro voudrait corriger : ce n'est pas « deux bases de données », et ce n'est pas non plus Event Sourcing. C'est, à la racine, l'aveu que lire et écrire sont deux métiers — l'un protège des invariants, l'autre sert de l'information vite — et qu'un seul objet ne peut pas exceller aux deux. La forme la moins chère de cet aveu coûte presque rien : deux modèles, une base, une vue. Tout le reste — projection, stockage séparé, cohérence à terme — n'est qu'une montée graduelle sur un spectre, qu'on gravit une marche à la fois, sous la pression d'une mesure, jamais d'une mode.CQRS suffers from a reputation this issue wants to correct: it's not 'two databases', and it's not Event Sourcing either. At root it's the admission that reading and writing are two jobs — one protects invariants, the other serves information fast — and one object can't excel at both. The cheapest form of that admission costs almost nothing: two models, one store, one view. Everything else — projection, separate store, eventual consistency — is just a gradual climb on a spectrum, taken one step at a time, under the pressure of a measurement, never a fashion.
Le #1 a séparé le métier de sa base ; le #2 a séparé lire d'écrire. Reste une dernière hypothèse à questionner, la plus profonde du volume : on a toujours stocké l'état présent — le statut actuel de la commande. Et si l'on stockait plutôt les faits qui l'ont produit : payée, réservée, expédiée ? L'état devient alors un calcul, pas une donnée. C'est Event Sourcing, le #3 — le pattern le plus séduisant et le plus sur-employé de la série. On y installera le réflexe ET, en grand, le panneau danger.#1 separated the domain from its database; #2 separated reading from writing. One last assumption remains to question, the volume's deepest: we've always stored present state — the order's current status. What if we stored instead the facts that produced it: paid, reserved, shipped? State then becomes a computation, not a datum. That's Event Sourcing, #3 — the series' most seductive and most over-applied pattern. We'll install the reflex AND, in large letters, the danger sign.
Les voisins que ce numéro débloqueThe neighbors this issue unlocks
Vue matérialiséeMaterialized view — le read model rendu concret par la base elle-même : une requête dont le résultat est stocké et rafraîchi, plutôt que recalculé à chaque lecture. La forme la plus simple d'un modèle de lecture séparé, sans pipeline à écrire — la base s'en charge.the read model made concrete by the database itself: a query whose result is stored and refreshed, rather than recomputed on each read. The simplest form of a separate read model, with no pipeline to write — the database handles it.
Read replica — le même schéma, répliqué en lecture seule pour absorber la charge des requêtes sans toucher au primaire d'écriture. Le premier cran d'échelle, presque transparent — et la première fois où la cohérence à terme s'invite, par le délai de réplication.the same schema, replicated read-only to absorb query load without touching the write primary. The first scaling notch, nearly transparent — and the first time eventual consistency shows up, via replication lag.
Event Sourcing — stocker la suite des faits plutôt que l'état courant, et recalculer l'état (ou des read models) en les rejouant. Souvent associé à CQRS, mais bien plus lourd : audit gratuit, migration douloureuse. Le sujet — et l'avertissement — du #3.store the sequence of facts rather than current state, and recompute state (or read models) by replaying them. Often paired with CQRS, but far heavier: free audit, painful migration. The subject — and the warning — of #3.
Le kata — écris-le, ne le relis pasThe kata — write it, don't reread it
Au chapitre 3, la lecture s'est mise à se nourrir de l'écriture par projection. On l'écrit ici, dans sa forme la moins chère : une seule base, un modèle d'écriture avec ses invariants, un modèle de lecture plat tenu à jour à chaque commande. Copie le fichier et fais-le passer au vert — projection SYNCHRONE, donc cohérence immédiate, aucun pipeline. Le dernier test est le sournois : le modèle de lecture est dénormalisé (il copie le nom du client), donc renommer un client doit reprojeter toutes ses lignes — sinon la copie ment. C'est exactement le prix caché d'un read model séparé.In chapter 3, the read side began feeding on the write side by projection. We write it here, in its cheapest form: one database, a write model with its invariants, a flat read model kept current on every command. Copy the file and turn it green — SYNCHRONOUS projection, hence immediate consistency, no pipeline. The last test is the sneaky one: the read model is denormalized (it copies the customer's name), so renaming a customer must reproject all their rows — otherwise the copy lies. That's exactly the hidden price of a separate read model.
test_order_projection.py
# test_order_projection.py — la spec EST le harnais : fais-le passer au vert.# une voie d'écriture (l'agrégat), une projection qui tient à jour un modèle de# lecture plat. UNE seule base — pas d'événements, pas de cohérence à terme.def test_ecrire_projette_une_ligne_plate():
app = OrderApp()
app.place_order(id=1, customer="Ada", total=90)
row = app.dashboard()[0] # la lecture ne lit QUE le résumé
assert row == {"id": 1, "customer_name": "Ada", "total": 90, "status": "pending"}
def test_le_dashboard_n_hydrate_aucun_agregat():
app = OrderApp()
app.place_order(id=1, customer="Ada", total=90)
assert isinstance(app.dashboard()[0], dict) # des lignes plates, pas des Orderdef test_expedier_reprojette_le_statut():
app = OrderApp()
app.place_order(id=1, customer="Ada", total=90)
app.pay(1); app.ship(1) # commandes → l'agrégat, invariants
assert app.dashboard()[0]["status"] == "shipped"# la projection a suividef test_lecture_immediate_read_your_own_write():
app = OrderApp()
app.place_order(id=2, customer="Bob", total=10)
assert any(r["id"] == 2for r in app.dashboard()) # projection SYNCHRONE : vu aussitôtdef test_ship_refuse_si_pas_paye():
app = OrderApp()
app.place_order(id=1, customer="Ada", total=90)
with raises(CannotShip): app.ship(1) # l'invariant reste côté écrituredef test_renommer_le_client_rafraichit_toutes_ses_lignes():
app = OrderApp()
app.place_order(id=1, customer="Ada", total=90)
app.place_order(id=2, customer="Ada", total=15)
app.rename_customer("Ada", "Ada Lovelace") # le read model est DÉNORMALISÉ…
names = {r["customer_name"] for r in app.dashboard()}
assert names == {"Ada Lovelace"} # …chaque ligne copiée doit être reprojetée,# sinon la copie ment. C'est le prix caché d'un modèle de lecture séparé.
Chez toi, cette semaine.In your codebase, this week.
Cherche ta pire lecture : un tableau de bord ou une liste qui hydrate des agrégats entiers pour n'afficher que quatre colonnes. Un point de départ : grep -rn "\.all()\|find_all\|\.objects\.all" src/ | grep -iE "dashboard|list|report|summary" — chaque résultat est un candidat au modèle de lecture plat. Puis la question du chapitre 4, avant de séparer les stockages : « ai-je une charge de lecture mesurée que ma base d'écriture ne tient pas ? » Si non, une vue de la même base suffit — et reste cohérente.Find your worst read: a dashboard or list that hydrates whole aggregates just to show four columns. A starting point: grep -rn "\.all()\|find_all\|\.objects\.all" src/ | grep -iE "dashboard|list|report|summary" — every hit is a candidate for a flat read model. Then chapter 4's question, before splitting stores: 'do I have a measured read load my write database can't hold?' If not, a view of the same database is enough — and stays consistent.