Article
Le write model, sanctuaire transactionnel
Le write model, sanctuaire transactionnel : agrégat, invariants, et la règle non négociable — aucun système externe pendant une commande. L'intention de propager s'acte dans la transaction.
Une commande, c'est une décision — et une décision se prend à l'abri. Le write model n'est pas seulement l'endroit où l'on écrit : c'est le lieu où les règles du domaine ont le dernier mot, dans une transaction qui ne laisse rien fuir. Le prix de cette garantie, c'est une discipline stricte : la commande ne sort jamais de son enceinte.
Dans le volet précédent, nous avons posé que le write model est le domaine : la source de vérité, porteuse des règles. Reste à comprendre ce qui lui permet de tenir ce rôle. Car protéger un invariant — « on ne vend pas deux fois le dernier billet » — n'est pas une intention pieuse ; c'est une propriété qu'il faut garantir, y compris quand deux clients cliquent au même instant. Cette garantie a un nom et un périmètre : la transaction.
L'agrégat : l'unité qui a le droit de dire non
Une commande ne s'applique pas « à la base » ; elle s'applique à un agrégat — un petit groupe d'objets qu'on traite comme un tout, avec une frontière nette. L'agrégat est l'unité de cohérence : c'est à l'intérieur de ses bords que les invariants sont vérifiés et tenus. « Cette réservation ne peut pas dépasser la capacité de la chambre », « ce panier ne peut pas contenir un article épuisé » : autant de règles qui portent sur un agrégat, et qu'il fait respecter au moment où la décision se prend.
Ce périmètre n'est pas décoratif. Il détermine ce qu'une commande peut garantir d'un seul coup, de façon atomique : tout ce qui tient dans un agrégat, et rien de plus. C'est le premier arbitrage d'un modèle de décision — dessiner des agrégats assez larges pour porter leurs invariants, assez étroits pour ne pas se verrouiller mutuellement sous la charge. Un agrégat obèse devient un goulot ; un agrégat trop maigre ne peut plus garantir la règle qu'on attend de lui. Le bon découpage est celui qui fait coïncider une décision et sa règle dans une même frontière.
Un exemple rend la mécanique tangible. Deux clients veulent le dernier billet, au même instant ; chacun envoie une commande « acheter le billet ». Sans frontière transactionnelle, les deux lisent « un billet disponible », les deux décident « vendu », et le stock tombe à moins un — l'invariant est violé. Avec l'agrégat de stock du spectacle pour unité de cohérence, les deux commandes se présentent à la même porte : la première entre, décrémente, valide ; la seconde, en tentant de valider à son tour, se heurte à un stock déjà nul et se voit refusée. Ce n'est pas un coup de chance, c'est la transaction qui a mis en séquence deux décisions concurrentes sur un même agrégat, et l'invariant qui a tenu parce qu'il était vérifié dedans. Retirez l'agrégat ou la transaction, et il faut réinventer cette garantie à la main — mal, le plus souvent.
La règle non négociable : ne pas sortir de la transaction
C'est la règle la plus importante de ce volet, et sans doute la plus enfreinte : pendant l'exécution d'une commande, on n'appelle aucun système externe. Pas d'appel à un autre service, pas de requête à une API tierce, pas d'envoi de mail, pas de publication directe sur un bus de messages. Rien qui sorte du contexte transactionnel.
La raison est mécanique, pas dogmatique. Le write model est responsable de la cohérence des données ; cette cohérence tient parce que la transaction est atomique — tout réussit, ou tout est annulé. Or on ne peut pas enrôler de façon fiable une base de données et un service distant dans une même transaction — les transactions distribuées (le fameux « 2PC ») existent, mais elles sont notoirement fragiles et déconseillées. Comme le résume Chris Richardson, « envoyer un message au milieu d'une transaction n'est pas fiable : rien ne garantit que la transaction sera validée ». Appeler le dehors depuis une commande, c'est parier que deux choses non liées réussiront ensemble — et un jour l'une aboutit quand l'autre a échoué. Un mail parti pour une réservation finalement annulée ; un paiement déclenché sans trace locale. La cohérence a fui par la porte qu'on a laissée ouverte.
On objectera qu'un appel « juste pour vérifier » semble inoffensif. C'est souvent là que la brèche s'ouvre : la vérification lit un état distant qui aura changé au moment où la transaction validera. Le contrôle rassure sans rien garantir. Mieux vaut une règle claire — la commande reste chez elle — qu'une exception « raisonnable » qu'on ne saura plus borner ensuite.
Décider ici, propager ailleurs
Mais un système utile doit parler au dehors — notifier, indexer, propager. Comment concilier cette nécessité avec une commande qui ne sort pas de sa transaction ? En séparant la décision de sa diffusion.
La commande fait une seule chose, atomiquement : elle enregistre le nouvel état et l'intention de le propager. Puis, une fois la transaction validée, un porteur distinct — un worker — relève cette intention et effectue les appels vers le dehors. Ce worker est idempotent et rejouable : s'il retente après une panne, l'effet ne se produit qu'une fois — concrètement, il reconnaît un identifiant déjà traité et ignore le doublon. Le dehors n'est appelé que si la décision a bel et bien été prise ; l'appel, lui, arrive après, hors de l'enceinte, là où un échec se rattrape sans entacher la cohérence du domaine.
Reste à choisir sous quelle forme cette intention est actée. La plus connue est le pattern outbox : un événement déposé dans la même transaction, comme une lettre dans une boîte d'envoi, qu'un relais vient lever après le commit. Ce n'est pas la seule — on peut aussi bien porter l'intention comme un simple attribut de l'agrégat (« reste à notifier ») qu'un balayage périodique vient relever, ou laisser un agent lire le journal de transactions de la base pour en déduire ce qui a changé. Ces variantes ne diffèrent que sur un point de détail au regard de ce volet — où l'intention est écrite — et elles se valent toutes ici, dès lors qu'elle est écrite dans la transaction de la décision. Les départager relève d'un autre débat, celui de la propagation, que nous ne traitons pas ici (voir les articles rassemblés sous le tag résilience).
C'est la logique d'un système qui traite les événements comme des signaux : le write model tranche, émet, et ce qu'il émet devient le déclencheur de tout le reste — projections, intégrations, réactions d'autres domaines — sans jamais tirer ces effets à l'intérieur de la décision. C'est d'ailleurs ainsi qu'on peut bâtir un système entier : un canal de commandes qui décide et valide, des événements qui propagent en aval. La commande décide ; le signal voyage.
Un garde-fou suffit à rendre ce relais fiable, et c'est celui qu'on vient de poser : l'état et l'intention de propager s'écrivent ensemble, si bien qu'aucun signal n'existe sans la décision qui le justifie, et aucune décision ne reste sans son signal. Vient ensuite une contrepartie qu'il faut connaître : un relais qui retente livre « au moins une fois », et peut donc publier en double s'il redémarre au mauvais moment. C'est acceptable précisément parce que les consommateurs en aval sont idempotents — rejouer le même signal ne produit son effet qu'une seule fois. On préfère un doublon rattrapable à un message perdu en silence, car un message perdu, lui, ne se voit pas jusqu'au jour où il manque.
Cela dit, ce garde-fou ne protège que le premier mètre du trajet. Un signal peut encore se perdre en chemin, ou n'être jamais appliqué à l'arrivée ; et comme il part souvent vers plusieurs destinations à la fois, certaines routes peuvent aboutir quand d'autres échouent. Ce sont de vraies questions — mais elles ne se jouent plus dans la transaction, et donc plus dans ce volet : elles appartiennent au côté lecture, qui a ses propres garanties et ses propres réparations.
Valider ce qu'on peut, laisser le reste au read
Un modèle de décision doit valider — mais pas tout, ni n'importe où. La distinction est fine et rend service.
Ce qui relève de l'intégrité interne de l'agrégat — existence, unicité, respect d'un invariant local, absence de conflit de révision (deux modifications concurrentes sur la même version) — se vérifie dans la transaction, au moment de la commande. C'est là, et nulle part ailleurs : un pré-contrôle fait « avant », dans une transaction séparée, ne garantit rien, puisque l'état peut changer entre le contrôle et l'écriture. La validation qui compte est celle qui partage l'atomicité de la décision.
Cela a une conséquence concrète sur l'endroit où le code contrôle. La tentation est de vérifier « en amont », au point d'entrée qui reçoit la requête, avant même de lancer la commande. Mais ce contrôle-là vit dans une transaction séparée, en lecture seule : entre le moment où il dit « c'est bon » et celui où la commande écrit, l'état a pu changer — c'est le classique décalage entre le contrôle et l'action. Le seul contrôle qui garantisse quelque chose est celui qui s'exécute dans la transaction de la décision. Le point d'entrée, lui, a un autre travail, tout aussi légitime : porter l'autorisation — cet utilisateur a-t-il le droit de déclencher cette commande ? Autorisation et intégrité ne se jouent pas au même endroit ; la première garde la porte, la seconde tient l'invariant.
Mais dès qu'une règle dépend d'un autre agrégat, d'un autre domaine — « ce client existe-t-il vraiment ? », « ce compte est-il encore actif ? » — la vérifier au moment de la commande obligerait à sortir de la transaction, ou à verrouiller la moitié du système. On s'en abstient délibérément. Cette invalidité-là n'est pas ignorée pour autant : elle est calculée plus tard, à la lecture, et rendue visible comme un statut. Nous y reviendrons au volet consacré aux décisions qui traversent plusieurs modèles — c'est là que se joue l'intégrité entre domaines, et elle ne se joue pas dans une commande.
Point contesté. Une commande doit-elle ne rien renvoyer — doctrine « pure » : elle agit, un point c'est tout, quitte à être traitée de façon asynchrone — ou peut-elle retourner un identifiant, un statut de validation ? La tradition penche pour la commande muette ; en pratique, beaucoup renvoient au moins de quoi accuser réception. Aucune source ne l'interdit formellement, et l'on peut légitimement préférer le pragmatisme. L'essentiel n'est pas là : c'est que la décision, elle, reste dans la transaction.
Un sanctuaire, à quel prix
Faire du write model un sanctuaire transactionnel a un coût, et mieux vaut le nommer franchement : on renonce à la commodité d'appeler le dehors « tant qu'on y est », on ajoute un relais de propagation, on accepte que certaines validations se fassent en différé. En échange, on obtient une propriété rare : quand une commande réussit, on sait ce qui est vrai — l'état et l'intention de le propager ont été actés ensemble, ou pas du tout. Le reste du système peut alors s'appuyer sur cette certitude.
Le write model tient donc son rôle à une condition : rester chez lui. Il décide, il valide, il émet — et confie au dehors, par des signaux, le soin de propager. Reste à savoir ce que devient ce qui est propagé : le read model, cette vue dérivée qui, elle, a précisément vocation à parler au monde extérieur. C'est le sujet du prochain volet.
Laisser un commentaire
Vous devez être connecté pour pouvoir laisser un commentaire.