Passer un monolithe Go à des échanges par événements, étape par étape, sans perdre ni dupliquer de messages.
Pourquoi ce livre
Deux services qui s'appellent en direct finissent collés l'un à l'autre. On doit les déployer ensemble, et la panne de l'un arrête l'autre. Michael Stack, l'auteur, nomme le résultat un monolithe distribué : un monolithe auquel on a ajouté de la complexité inutile (préface).
Son remède est l'EDA (event-driven architecture, architecture pilotée par les événements) : les services ne s'appellent plus, ils publient des événements. Un événement est un fait passé que personne ne défait, par exemple « commande créée ». Le livre le dit ainsi : « L'événement lui-même est dans le passé et c'est un fait immuable » (p. 8).
Le livre suit un seul fil rouge, MallBots, une application de centre commercial. Des bornes permettent de choisir des articles dans plusieurs boutiques, et des robots vont les chercher (p. 12). On part d'un monolithe modulaire : un seul programme à déployer, découpé en modules qui ont chacun leur périmètre métier. Chapitre après chapitre, les appels directs entre modules deviennent des messages. Ils passent par NATS JetStream, un broker (un serveur qui reçoit les messages et les redistribue), avec PostgreSQL pour les données.
Cette fiche suppose la base acquise : stocker des événements, séparer lecture et écriture. Sur le blog, j'ai déjà consacré des articles à l'event store PostgreSQL (idée 1), aux sagas chorégraphiées (idée 7) et à l'idempotence : un traitement rejoué deux fois donne le même résultat qu'une fois. Je ne retiens donc que ce que le livre ajoute. Les deux derniers chapitres, qui déploient le résultat sur AWS avec Terraform puis le supervisent, sortent du sujet et je les laisse de côté.
Les idées qui restent
Les neuf idées forment trois familles :
- les idées 1 et 2 posent le décor : le vocabulaire et le point de départ.
- les idées 3 à 7 sont les mécanismes de fiabilité d'un système à événements. Ils servent à publier sans trou, charger vite, ne rien perdre ni doubler, et annuler quand une étape échoue.
- les idées 8 et 9 servent à lire les données et à prouver que l'ensemble marche.
1Trois sens du mot EDA, trois durées de vie d'un événement
En équipe, « on fait de l'événementiel » peut recouvrir trois pratiques différentes. Le livre les sépare dès le départ : « Trois usages ou patterns différents peuvent s'appeler EDA, seuls ou ensemble » (p. 4).
- notification : l'événement ne porte presque rien, parfois que l'identifiant. Le livre montre
PaymentReceivedavec le seulPaymentID. Qui veut le détail doit rappeler le service des paiements (p. 5). - transfert d'état porté par l'événement (event-carried state transfer, ECST) : le même événement transporte aussi le client, la commande et le montant. Personne n'a plus à rappeler. Le livre l'appelle « un cousin asynchrone » de REST (p. 5). REST désigne les appels HTTP habituels, où l'on va chercher la donnée à la demande et où l'on attend la réponse : c'est synchrone. L'ECST la pousse à tous ceux qui s'y intéressent sans attendre de réponse : c'est asynchrone.
- event sourcing : on ne stocke plus l'état d'un objet mais la suite de ses changements. Ils vont dans un event store, une table où l'on ne fait qu'ajouter. L'état courant se recalcule en rejouant les événements (p. 7).
Une seconde grille mesure la durée de vie de l'événement et la distance qu'il parcourt (p. 143). Un agrégat est ici un groupe d'objets métier traité comme un tout, la commande avec ses lignes par exemple.
- événement de domaine : le plus court, il ne quitte jamais l'application et vit en mémoire, le temps du traitement en cours. Comme il n'est ni stocké ni envoyé, il n'a pas de version (pas de numéro de format à suivre) et il est synchrone. Il prévient le reste du code qu'un agrégat a changé (pp. 91, 143).
- événement d'event sourcing : le plus long, il reste stocké tant qu'on en a besoin. Il ne franchit jamais la frontière du service, le module qui possède les données. Il est versionné : on ne modifie jamais un événement déjà écrit, on en crée une nouvelle version. Il est aussi synchrone. C'est le journal des changements de l'agrégat (pp. 91, 116, 143).
- événement d'intégration : sa durée de vie et son nombre de consommateurs sont inconnus, il est versionné et asynchrone. Il informe les autres composants (p. 143).
Le livre raccorde les deux listes en une phrase : « Les événements de notification et ceux de transfert d'état sont tous deux des événements d'intégration » (p. 143). Notification et ECST sont donc deux façons de remplir un événement d'intégration. L'asynchrone commence là : « Les événements d'intégration sont strictement asynchrones » (p. 87).
Deux lignes séparent ensuite l'EDA de ses voisins. D'abord, l'event sourcing n'est pas de la diffusion d'événements entre modules. Il garde l'historique d'un seul module et reste « un détail d'implémentation » plutôt qu'un choix d'architecture pour toute l'application (p. 101). Ensuite, le CQRS, qui sépare les écritures des lectures, n'en fait pas partie : « Le CQRS n'est pas, à mon avis, un pattern événementiel » (p. 39).
2Un monolithe modulaire d'abord, l'asynchrone ensuite
Une fois le vocabulaire posé, reste à choisir le point de départ. Le livre ouvre sur un cas de chaîne d'appels synchrones. Imaginez sept personnes qui se passent un message à la chaîne : chacune attend la réponse de la suivante, et une seule absente bloque tout le monde. Quand un client valide son panier, le module Baskets appelle le module Ordering, qui gère les commandes (p. 86). La création de la commande appelle ensuite quatre autres modules : Customers, Payments, Depot, où les robots apportent les articles, et Notifications. Depot interroge à son tour le module Stores, deux fois (p. 86). Au total, « sept modules interviennent » (p. 86).
L'auteur assume l'exagération : exemple extrême, pour la démonstration. Il ajoute que de telles chaînes « peuvent apparaître dans de vraies applications » (p. 86). Sa réponse (ch. 2) n'est pas de couper le programme en microservices, des services indépendants dont la gestion est lourde (p. 41). Sa recommandation tient en une phrase : « Un monolithe modulaire est l'architecture recommandée pour démarrer tout projet d'une complexité raisonnable » (p. 41). Le programme reste un seul bloc, et la chaîne d'appels s'y remplace pas à pas par des événements. C'est le même immeuble, mais la chaîne téléphonique laisse place à un panneau d'affichage où chacun vient lire ce qui le concerne.
Chaque module est un bounded context, un périmètre du métier avec son propre vocabulaire (p. 76). Les dossiers s'appellent baskets, stores, depot, ordering, pas controllers ni models. Chaque module a son répertoire internal, que le compilateur Go protège des imports venus d'un autre module (pp. 76-77). Les modules s'appellent entre eux en gRPC, un protocole d'appel de fonctions à distance (p. 84). Dans un seul programme, un simple appel de fonction suffirait. Le livre veut pourtant que chaque module n'expose qu'une interface fixe aux autres. Ce contrat survivra si le module devient un jour un service à part (pp. 41-42).
Avant d'ajouter le moindre message, le livre pose une grille de choix. Deux raisons poussent un contexte à en appeler un autre : il lui faut une donnée, ou il lui faut une action (p. 84). L'action est la plus simple : on envoie la demande au point d'entrée (l'API) du module visé, la voie la plus courante (p. 85). Pour la donnée, trois options existent :
- partager la base : tout le monde écrit dans le même carnet d'adresses. À éviter, surtout si plusieurs endroits la modifient (p. 84).
- push : le propriétaire envoie la donnée à une liste d'abonnés connus, comme une liste de diffusion. L'auteur y voit « un cauchemar de maintenance » (p. 84), car la liste grossit avec les composants.
- pull : le demandeur va chercher la donnée quand il en a besoin, comme on ouvre l'annuaire au moment d'appeler.
Le verdict surprend dans un livre d'EDA : « Parmi ces options, tirer la donnée est le meilleur choix dans la plupart des cas » (p. 84). Le pull coûte plus d'appels. Garder la donnée en cache allège ces appels, mais le cache s'invalide mal (p. 84). Reste le risque qu'un service appelé tombe en panne. L'appelant s'y prépare : il réessaie, et il pose un disjoncteur, un garde qui cesse d'appeler un service en panne pendant quelques instants (p. 84). Même principe que le disjoncteur d'un tableau électrique, qui coupe avant que tout grille.
Le premier pas du livre n'a d'ailleurs rien d'asynchrone. Il remplace par des événements de domaine en mémoire les effets de bord d'un handler, la fonction qui traite une demande. L'exemple : CreateOrder sauvait la commande puis appelait les notifications. Avec chaque règle nouvelle, il aurait fallu le renommer CreateOrderAndNotifyAndReward… (p. 88). L'auteur l'admet à la fin du chapitre : « cela ne change pas grand-chose et n'ajoute aucune méthode de communication asynchrone » (p. 97). Ce premier pas sert de fondation : le livre en fait ensuite le point de départ pour rendre toute l'application réactive (p. 97). Un détail utile : ces événements restent dans le contexte, donc ils peuvent embarquer l'agrégat entier (p. 91). Seuls les événements qui sortent du module doivent être réduits au strict nécessaire (p. 91).
3Le bug muet : Save vide les événements avant leur publication
Les événements de domaine de l'idée 2 restent en mémoire. Plus loin (ch. 5), le livre fait passer des agrégats à l'event sourcing, et un bug apparaît sans aucune erreur. Imaginez un guichet de courrier. Le commis range la lettre dans le classeur, puis vide le bac de départ. Le facteur passe après, et il trouve le bac vide. Le repository (AggregateRepository) est l'objet qui charge et sauve un agrégat. Il s'appuie sur un store, l'adaptateur qui parle à PostgreSQL. Son Save termine par CommitEvents(). Cet appel met à jour la version de l'agrégat (le numéro de son dernier événement). Il vide aussi la liste des événements en attente (pp. 117, 131).
Or le handler de la commande « ajouter un produit » (au sens CQRS, une commande est une demande de changement) appelait ensuite Publish(product.GetEvents()...). La liste est déjà vide, donc rien ne part : le facteur repart les mains vides. Le read model de catalogue, la table de lecture alimentée par ces événements, n'est jamais mis à jour. Rien ne plante, et les lignes de log attendues n'apparaissent pas (p. 131).
// handler de commande, d'après le livre (p. 131)
if err = h.products.Save(ctx, product); err != nil { // Save vide les événements
return err
}
if err = h.domainPublisher.Publish(ctx, product.GetEvents()...); err != nil { // liste vide
return err
}
Le livre compare trois remèdes :
- publier avant de sauver. Cela marche si « tout le monde se souvient que Publish() doit toujours précéder Save() » (p. 131). Il faut aussi que
Saven'échoue jamais pour cause d'événement mal appliqué : des événements seraient alors publiés sans être enregistrés. - injecter le publieur dans le repository. Le repository dépend alors du publieur. Pour s'en passer, on lui donne un
nilqu'il doit tester avant chaque usage (p. 132). - envelopper le store dans un middleware, une fonction qui entoure un objet pour ajouter un comportement sans changer son interface. C'est la solution retenue (p. 132).
// d'après le livre (pp. 133-134), abrégé
type AggregateStoreMiddleware func(AggregateStore) AggregateStore
func AggregateStoreWithMiddleware(store AggregateStore, mws ...AggregateStoreMiddleware) AggregateStore {
s := store
for i := len(mws) - 1; i >= 0; i-- { s = mws[i](s) } // store, A, B, C donne A(B(C(store)))
return s
}
type EventPublisher struct { AggregateStore; publisher ddd.EventPublisher }
func (p EventPublisher) Save(ctx context.Context, a EventSourcedAggregate) error {
if err := p.AggregateStore.Save(ctx, a); err != nil { return err } // sauve d'abord
return p.publisher.Publish(ctx, a.Events()...) // publie ensuite
}
Le middleware enveloppe le store, que le repository appelle dans son propre Save. Il publie donc pendant store.Save, avant que le repository n'appelle CommitEvents() : la liste n'est pas encore vide. C'est un sas qui photocopie la lettre avant que le bac soit vidé. La garantie tient en une phrase : « Les événements de domaine seront toujours publiés après avoir été persistés avec succès dans la base » (p. 134). Les handlers de commande n'ont plus besoin du publieur.
Deux règles d'usage ressortent. L'ordre de la chaîne compte peu. Seul un middleware qui rattrape les paniques doit venir en tête de la liste. Il est alors le plus extérieur (A dans A(B(C(store)))) et couvre toute la chaîne, comme un filet de sécurité (p. 133). Seconde règle : les anciens appels Publish laissés dans les handlers sont un piège. Normalement ils ne font rien, la liste étant vide, mais quand les choses tournent mal, ils « peuvent encore publier des événements qu'on ne voudrait pas voir publiés » (p. 135). Le livre ne dit pas dans quel cas.
Le livre précise une limite : tout cela reste synchrone, dans un seul contexte (p. 134). Un plantage entre la sauvegarde et un message vers un autre service reste possible. L'idée 6 s'en occupe.
4Ne sérialisez jamais l'agrégat dans un snapshot, sérialisez une copie versionnée
Reste un second coût de l'event sourcing : relire tous les événements d'un agrégat à chaque chargement. Le livre distingue deux sortes d'agrégats. Les courts, comme une commande ou un panier, voient peu d'événements. Les longs, comme un magasin ou un client, en accumulent beaucoup (p. 135). Pour les longs, on prend des snapshots, des photos de l'état à une version donnée.
Le livre pose la définition et une interdiction : « Un snapshot est la sérialisation de l'agrégat et de la version dont il vient » (p. 136). Il ne faut pourtant pas sérialiser l'agrégat lui-même, car cela « limiterait la liberté de changer sa structure plus tard » (p. 136). Renommer un champ de l'agrégat rendrait en effet illisibles les photos déjà enregistrées. On sérialise une représentation versionnée, ProductV1. C'est un formulaire numéroté : un ancien formulaire V1 reste lisible même quand le métier passe à une V2.
L'agrégat implémente deux méthodes, ToSnapshot() et ApplySnapshot(). La seconde relit toutes les versions connues :
// ApplySnapshot du Product, d'après le livre (p. 137)
switch ss := snapshot.(type) {
case *ProductV1:
p.StoreID, p.Name, p.Description, p.SKU, p.Price = ss.StoreID, ss.Name, ss.Description, ss.SKU, ss.Price
// exemple imaginé d'après le texte : ProductV2 ajoute Weight int
case *ProductV2:
/* mêmes champs que V1, plus p.Weight = ss.Weight */
default:
return errors.ErrInternal.Msgf("%T received the unexpected snapshot %T", p, snapshot)
}
Le texte donne la règle d'évolution. On ajoute Weight, donc on crée ProductV2 avec tous les champs de ProductV1 plus le poids. ToSnapshot() renvoie désormais la V2, et ApplySnapshot() gère les deux (p. 137). La partie V1 de ApplySnapshot ne change pas. Tant qu'aucun événement ne fixe le poids d'un produit, Weight garde 0, la valeur par défaut d'un entier en Go. Il faut pourtant garder le code de lecture de la V1. En effet, « un ancien snapshot peut continuer d'exister » en base tant que la stratégie n'en prend pas de nouveau (p. 137).
Le livre liste trois stratégies de fréquence, à choisir selon le métier (p. 137) :
- tous les N événements, par exemple tous les 50.
- à chaque période, une fois par jour ou par heure.
- à un événement pivot, par exemple quand un magasin change de marque.
Son propre code prend un snapshot tous les trois événements, et il le dit sans détour : « Tous les trois événements n'est pas une bonne stratégie hors démonstration » (p. 138).
La table snapshots a pour clé (stream_id, stream_name), sans la version. Un stream est ici la suite des événements d'un agrégat, repérée par un id et un nom (p. 120). Un UPDATE écrase donc l'ancienne photo, et l'on peut changer la clé pour garder un historique (p. 138). Au chargement, le middleware cherche un snapshot et l'applique. Le store ne lit ensuite que les événements de version plus haute. Il saute ceux dont la version est « égale ou inférieure » à celle du snapshot (p. 139). C'est un marque-page : on reprend la lecture là où la photo a été prise.
L'auteur déconseille l'abus : « Un snapshot est un type de cache, et comme les autres caches, il a des inconvénients, comme la duplication et l'invalidation des données » (p. 139). Plus loin, il en fait une optimisation de performance « à n'utiliser que lorsque c'est absolument nécessaire » (p. 139). Le livre ne donne aucune mesure de performance et aucun seuil d'événements à partir duquel la photo se justifie.
5Une seule transaction par requête, portée par un conteneur d'injection
Dans les idées 3 et 4, le code écrit plusieurs choses à la suite (événements, version, snapshot). Il suppose que le tout réussit ou échoue d'un bloc. Entre services, cette hypothèse tombe. Le livre l'illustre avec les paiements : le module Payments marque une facture payée et prévient le module Ordering. Si la sauvegarde de la facture échoue après l'appel, Ordering a déjà avancé (pp. 230-231). Avec un événement à la place de l'appel, c'est pire : on ne sait plus qui l'a consommé ni ce qu'il a déclenché, donc on ne sait pas quoi corriger (p. 231).
Le livre nomme le problème : le dual write (double écriture). Il apparaît partout où un changement d'état doit s'écrire « en deux endroits ou plus » (p. 232). Ici, ces deux endroits sont la base et le broker. C'est payer le pain à une caisse et le lait à une autre, avec la seconde qui plante. Une transaction est un ensemble d'écritures en base qui réussissent ou échouent ensemble : une seule caisse pour tout le panier. La thèse du chapitre : « Plus important encore qu'une écriture unique, il faut une seule frontière transactionnelle autour de ce qu'on écrit » (p. 232). Le livre écarte aussi une mini-saga à deux étapes (une saga est une suite d'étapes annulables, voir l'idée 7), qui serait « une masse pour écraser une mouche » (p. 232).
Créer une transaction est facile. Le défi, c'est de « s'assurer que la même transaction sert à chaque interaction avec la base pendant toute la vie de la requête » (p. 233). Le livre écarte trois pistes :
- une variable globale : un cauchemar à tester et à faire évoluer (p. 233).
- un paramètre
txajouté à chaque méthode : l'application serait couplée à la base, ce que l'auteur juge « totalement hors de question » (p. 233). - une transaction glissée dans le
contextet lue par chaque repository. Il faut les modifier tous, et le code tiers ne saura pas la lire (p. 233).
La piste retenue crée les repositories à la demande, avec la transaction injectée. L'injection de dépendances consiste à donner à un objet ce dont il a besoin au lieu de le lui laisser créer. Le livre ajoute un petit paquet di, un conteneur : un registre de fabriques d'objets. Il connaît deux durées de vie (pp. 233-236) :
- singleton : un seul exemplaire pour toute l'application, comme la machine à café de l'étage. Par exemple le logger.
- scoped : un exemplaire par scope, c'est-à-dire par requête. La transaction et les dépôts qui s'en servent sont de ce type. C'est le plateau du client : chaque requête reçoit le sien.
Le livre signale un piège. Prenons NewShoppingListRepository(..., mono.DB().Begin()), le repository des listes de courses du module Depot, branché sur la base du monolithe. Il donne bien une transaction par scope, mais seul ce repository la voit (p. 238). Chaque rayon aurait son propre plateau. Il faut un scoped "tx" unique, que tous les repositories reçoivent. Une interface DB remplace *sql.DB pour accepter une base ou une transaction (p. 239).
Chaque point d'entrée ouvre alors un scope, puis ferme la transaction. Pour gRPC, le livre enveloppe le serveur d'origine dans un serverTx. À chaque requête, celui-ci ouvre un scope, fabrique un serveur neuf qui reçoit la transaction du scope, lui passe la requête, puis valide ou annule :
// serverTx, d'après le livre (pp. 241-242), abrégé
func (s serverTx) CreateShoppingList(ctx context.Context, req *depotpb.CreateShoppingListRequest) (resp *depotpb.CreateShoppingListResponse, err error) {
ctx = s.c.Scoped(ctx) // nouveau scope dans un nouveau contexte
defer func(tx *sql.Tx) {
err = s.closeTx(tx, err) // commit ou rollback, retours nommés pour remplacer err
}(di.Get(ctx, "tx").(*sql.Tx))
next := server{app: di.Get(ctx, "app").(application.App)} // application neuve, même tx
return next.CreateShoppingList(ctx, req)
}
func (s serverTx) closeTx(tx *sql.Tx, err error) error {
if p := recover(); p != nil { _ = tx.Rollback(); panic(p) } // on ne rattrape pas la panique
if err != nil { _ = tx.Rollback(); return err }
return tx.Commit()
}
Les événements de domaine, eux, tournent dans un scope déjà ouvert. Ils retrouvent son conteneur dans le context Go de la requête. Ils n'en créent pas un autre, sinon ils auraient leur propre transaction (pp. 243-244). Les handlers de messages et de commandes ouvrent un scope comme serverTx (p. 245).
Le livre décrit le gain sans le survendre : « Chaque requête et chaque insertion se fait dans une seule transaction, ce qui transforme plusieurs écritures en une » (p. 245). Il ajoute aussitôt que le dual write n'est pas résolu (p. 245). La base est cohérente, le message reste à traiter. Le prix est avoué aussi. Le paquet d'injection ajoute de la complexité. Le conteneur rend des valeurs sans type précis, et le code leur affirme le type (.(*sql.Tx), une « type assertion ») sans que le compilateur le vérifie : si la promesse est fausse, l'erreur n'arrive qu'à l'exécution. Enfin Get panique si la clé n'existe pas (pp. 234-235).
6L'outbox écrit le message dans la transaction, l'inbox absorbe ses doublons
La transaction de l'idée 5 entoure la base. Il reste à y faire entrer les messages. L'outbox (boîte d'envoi) est une table de la même base. Au lieu de publier, on y écrit le message, dans la transaction de la requête. Un processus séparé le publie ensuite. L'inbox (boîte de réception) est la table des messages déjà reçus, pour reconnaître un doublon. L'outbox, c'est la corbeille de départ d'un bureau : l'employé y glisse le courrier en même temps qu'il enregistre la commande, et le facteur passe la vider plus tard. Si la commande est annulée, le courrier l'est avec elle. L'inbox, c'est le registre du réceptionniste : il coche le numéro de chaque colis et refuse le second du même numéro.
Côté envoi, le livre ne touche pas aux handlers. Il pose un middleware sur le flux brut de messages. Ce flux est le canal qui transporte des octets, sans rapport avec le stream de l'idée 4. Le middleware écrit dans depot.outbox au lieu de publier (pp. 251-252). Les flux typés (événements, commandes, réponses) sont reconstruits par-dessus, dans le scope de la requête (p. 252). La table a une colonne published_at vide tant que rien n'est parti, et un index partiel qui ne couvre que ces lignes : WHERE published_at IS NULL (pp. 250-251).
Sans processus d'envoi, « tout s'arrête vite » (p. 252), puisque plus aucun message ne quitte la base locale. Le processeur interroge la table par polling, c'est-à-dire en la relisant à intervalle fixe. Il prend un lot de messages non publiés, les publie un à un, puis renseigne published_at. Dans la démonstration, il lit 50 messages au plus et attend une demi-seconde entre deux lectures (p. 254). Il n'attend pas s'il vient de publier. Il tourne en goroutine dans chaque module, sans transaction : une transaction dure le temps d'une requête, et lui tourne en continu (pp. 253-254). Le livre cite une option plus performante : lire le journal de transactions de PostgreSQL, le WAL (p. 252).
Le processeur a lui-même un dual write : il publie, puis il note. S'il plante entre les deux, le message part deux fois. Le livre en tire la conclusion : ces plantages publient parfois plusieurs fois, mais « nous avons déjà la déduplication grâce à notre inbox » (p. 253). Un traitement exactly-once, une seule exécution par message, s'obtient en « ajoutant la déduplication à la livraison at-least-once » (p. 149). At-least-once veut dire « au moins une fois » : le broker réessaie jusqu'à l'accusé de réception, donc les doublons sont possibles.
Côté réception, la déduplication devient atomique. La table depot.inbox a pour clé l'identifiant du message. Le middleware tente d'y insérer chaque message reçu. S'il y a conflit, le message est acquitté sans être traité. Sinon il est traité, et son identifiant est enregistré dans la même transaction que le reste des changements (p. 246).
L'inbox travaille sur des messages bruts, car le côté abonnement des flux typés ne peut pas être dans le scope d'une requête (p. 247). Le livre ne l'explique pas, mais le dépôt en donne la raison : l'abonnement est créé une seule fois au démarrage, avant toute requête. Le livre écrit donc un NewEventMessageHandler qui reconvertit chaque message brut en événement Go (pp. 247-248).
Les schémas sont minimaux, et le livre le sait. La table d'inbox pourrait porter la version de l'agrégat, la date de publication ou l'ordre des messages (p. 246). La table d'outbox pourrait recevoir des colonnes d'ordre ou de partitionnement (p. 251). Le livre ne pose aucun verrou sur les lignes lues par le processeur, et ne cite jamais SKIP LOCKED. C'est une option SQL qui laisse un lecteur sauter les lignes déjà verrouillées par un autre. Sans elle, deux instances d'un même module pourraient lire les mêmes lignes et publier les mêmes messages deux fois. D'après la logique du livre, seule l'inbox des consommateurs absorberait ces doublons.
Plantage par plantage : ce qui reste, et qui le rattrape.
| Le plantage | Ce qui reste | Qui rattrape |
|---|---|---|
| Avant le commit | Rien, ni en base ni sur le broker | Personne : il n'y a rien à rattraper |
| Après le commit, avant la publication | La ligne d'outbox, published_at vide | Le processeur la publiera à son prochain passage |
Après la publication, avant published_at | Un message déjà parti, qui repartira | L'inbox du consommateur ignore le doublon |
| Deux instances lisent les mêmes lignes | Le même message publié deux fois | L'inbox, d'après la logique du livre (il ne cite pas SKIP LOCKED) |
7Une saga orchestrée, c'est une ligne en base qui avance d'une étape à chaque réponse
Les messages circulent maintenant sans trou. Reste une opération qui traverse plusieurs modules et doit pouvoir s'annuler. Ici, la création d'une commande : autoriser le client, créer la liste de courses au dépôt, confirmer le paiement, lancer les courses, approuver la commande. Si le paiement échoue, il faut annuler la liste et rejeter la commande.
Une saga est une suite d'étapes, chacune avec une action et une compensation, l'action inverse qui défait l'étape si une suivante échoue. C'est un voyage réservé chez plusieurs fournisseurs : si la voiture échoue, on annule l'hôtel puis le vol, dans l'ordre inverse. On ne remonte pas le temps, on fait l'action contraire. Le livre la compare à la validation en deux phases (2PC, two-phase commit). Un coordinateur y garde des transactions ouvertes, et donc des ressources bloquées, jusqu'à son ordre final (pp. 200-201). La saga évite ce blocage, mais elle perd l'isolation : « Les sagas abandonnent la garantie d'isolation, ce qui en fait des transactions ACD » (p. 201). Une transaction ACID offre quatre garanties : atomique, cohérente, isolée, durable. Une saga garde A, C et D mais pas le I : les effets d'une étape sont visibles des opérations concurrentes pendant l'exécution, comme l'hôtel réservé que le client voit avant la confirmation du vol.
Deux façons de la conduire. En chorégraphie, chaque module écoute l'événement qui annonce son tour. Le livre en compte six pour la création de commande, de OrderCreated à OrderApproved (pp. 202-203). Le piège apparaît à la compensation. Si le paiement échoue, Payments publie UnauthorizedPayment. Depot annule sa liste, Ordering rejette la commande, et Customers n'écoute pas l'événement : soit il n'a rien à y faire, soit on a oublié de l'abonner, et rien ne permet de le savoir. L'auteur ne cache pas l'ambiguïté : « Les compensations chorégraphiées peuvent être délicates ainsi » (p. 203).
En orchestration, un orchestrateur envoie des commandes, des messages qui demandent à un module d'agir, et reçoit des réponses de succès ou d'échec. La création de commande donne dix messages, cinq commandes et cinq réponses (pp. 204-205). Une réponse d'échec déclenche la compensation. Pour un paiement refusé, elle demande cinq messages. D'abord la réponse d'échec, puis l'annulation de la liste et sa réponse, enfin le rejet de la commande et sa réponse (p. 205). La règle du livre : dès qu'il y a plus d'une étape et plus d'une compensation, mieux vaut un orchestrateur qu'une chorégraphie (p. 205).
Les deux façons, côte à côte.
| Chorégraphie | Orchestration | |
|---|---|---|
| Qui décide la suite | Chaque module, sur l'événement qui annonce son tour | Un orchestrateur qui envoie des commandes |
| Où est le déroulé | Réparti entre les abonnements des modules | Dans l'orchestrateur et la ligne en base |
| La compensation | Chacun réagit à l'événement d'échec | Une réponse d'échec déclenche les commandes d'annulation |
| Le piège | Un module oublié dans les abonnements, et rien ne le signale | Le livre suppose que chaque compensation réussit |
| Quand la choisir | Le livre ne la recommande plus dès qu'il y a plus d'une étape et plus d'une compensation (p. 205) | Dès qu'il y a plus d'une étape et plus d'une compensation (p. 205) |
Le module cosec (Create-Order-Saga-Execution-Coordinator) l'implémente (p. 211). Il démarre sur l'événement d'intégration OrderCreated, et l'identifiant de l'événement devient l'identifiant de saga (p. 225). L'astuce est que l'orchestrateur ne garde rien en mémoire : « L'orchestrateur ne garde pas les sagas en cours d'exécution ni en mémoire » (p. 225). Tout l'état vit dans une ligne PostgreSQL :
-- d'après le livre (p. 221)
CREATE TABLE cosec.sagas (
id text NOT NULL, name text NOT NULL, data bytea NOT NULL,
step int NOT NULL, done bool NOT NULL, compensating bool NOT NULL,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id, name)
);
Le curseur est step plus compensating, et done marque la fin. Après chaque interaction, l'orchestrateur écrit en base les données de la saga (colonne data), par exemple l'identifiant de la liste de courses créée. Une réponse arrive, il charge la saga par l'identifiant corrélé, applique la réponse, avance, sauve. C'est un employé qui oublie tout entre deux appels : il relit la fiche de suivi du dossier, agit, puis la met à jour. Une commande porte en en-tête l'adresse où renvoyer les réponses, et ces en-têtes sont recopiés sur la réponse. C'est ainsi que la saga est retrouvée (p. 208). Le bénéfice : la saga ne gêne pas un service arrêté, et « pourvu qu'il finisse par revenir, la saga finira aussi par reprendre les étapes à exécuter » (p. 228).
Les étapes se déclarent avec un constructeur. L'étape 0 n'a pas d'action, car la commande existe déjà, en attente, quand la saga démarre. Elle n'a que la compensation qui la rejette, et comme les compensations se jouent à l'envers, elle s'exécute en dernier lors d'un retour arrière :
// les six étapes, d'après le livre (pp. 223-224)
saga.AddStep().Compensation(saga.rejectOrder) // 0. -RejectOrder
saga.AddStep().Action(saga.authorizeCustomer) // 1. AuthorizeCustomer
saga.AddStep().Action(saga.createShoppingList). // 2. CreateShoppingList
OnActionReply(depotpb.CreatedShoppingListReply, saga.onCreatedShoppingListReply).
Compensation(saga.cancelShoppingList) // -CancelShoppingList
saga.AddStep().Action(saga.confirmPayment) // 3. ConfirmPayment
saga.AddStep().Action(saga.initiateShopping) // 4. InitiateShopping
saga.AddStep().Action(saga.approveOrder) // 5. ApproveOrder
Une étape sans compensation est sautée au retour arrière (p. 211). Suivons donc un paiement refusé à l'étape 3. L'orchestrateur passe en compensating et recule. Il envoie CancelShoppingList pour l'étape 2, saute l'étape 1, puis envoie RejectOrder pour l'étape 0. Cela correspond aux cinq messages vus plus haut (p. 205). La réponse de l'étape 2 sert aussi : onCreatedShoppingListReply copie l'identifiant de la liste dans les données de la saga, pour la commande InitiateShopping (p. 224).
Les trous sont réels. Le livre se contente de poser que l'orchestrateur « s'attend à ce que chaque action compensatoire réussisse » (p. 205). Il ne dit rien de plus. Nulle part il n'évoque une compensation qui échoue, un délai maximal de saga ou une saga bloquée. Aucun moteur de workflow n'est cité non plus. Temporal, par exemple, est un logiciel qui exécute des suites d'étapes durables et les reprend après une panne. Enfin, le livre montre l'orchestrateur par des diagrammes de types. Ils donnent les signatures, pas les corps de fonctions, donc rien sur une réponse inattendue.
Hors livre, lue dans le code d'exemple (ch. 8). Les figures ne donnent que les signatures (pp. 209-211), j'ai donc lu le dépôt, internal/sec/orchestrator.go. Il confirme le trou : si une annulation échoue, il renvoie une erreur interne et ne réessaie jamais. Il jette aussi en silence une réponse sans en-tête de saga, car il ne sait pas à quelle saga elle appartient. J'y ai trouvé un défaut : si la première action échoue, Start ne renvoie pas l'erreur, et l'appelant croit que tout va bien (ch. 8, 9 et 12). Le dernier exemple du dépôt ajoute la transaction scoped, l'outbox et l'inbox des idées 5 et 6, mais le trou des annulations reste.
8Un read model peut se nourrir de trois modules, joints à l'arrivée de la commande
Chaque module reçoit des événements des autres. Que fait-il de ces messages ? La première réponse du livre est une copie locale. Le module Baskets garde une table des magasins et des produits, alimentée par les événements de Stores. Le livre l'appelle un « cache repository » pour rappeler que la donnée ne change pas de propriétaire (p. 176). Il joue le rôle de couche anticorruption, un sas entre deux modèles : si Store ou Product changent de forme, seul le code qui reçoit l'événement bouge (p. 176).
Chaque consommateur taille sa copie. Le module Depot n'a pas besoin du prix d'un produit, mais il garde l'emplacement du magasin (p. 180). Un appel gRPC sert de secours quand la ligne manque, mais il ne détecte pas une copie périmée : « ne nous aidera pas à savoir si notre cache est périmé » (p. 177). L'ajout ignore aussi la violation d'unicité, car l'événement entrant et le secours peuvent insérer la même ligne en même temps (p. 178).
Une mise en garde ferme la partie : « quand nous transférons l'état par événements, nous ne transférons pas avec lui les responsabilités du domaine » (p. 181). Si le module Customers porte l'authentification, c'est toujours lui qu'on appelle pour cela.
Le module Search pousse le principe plus loin. Il répond à cinq recherches : par client, par magasin et produit, par plage de dates, par plage de total, par statut (p. 192). Le livre refuse de le greffer sur le module Ordering, qui gère les commandes. Gérer une commande et chercher parmi les commandes partagent des données, pas toute leur logique. Ajouter la recherche à un module existant risquerait d'y causer des bugs ou de le ralentir (p. 192). Il le justifie aussi par un argument d'équipe : monter un consommateur d'événements ne dérange pas les autres équipes (p. 192).
Le modèle de lecture est une seule table, search.orders. Elle contient le nom du client, les lignes de la commande et le statut. Deux colonnes en tableau, product_ids et store_ids, servent à chercher les commandes passées pour tel produit ou tel magasin (p. 193). Deux choix sont à retenir. Les colonnes de filtrage vivent dans l'implémentation du repository, pas dans le modèle, pour pouvoir changer de base (p. 193). Les identifiants et le statut restent en text, pour que les types entrants puissent changer sans casser la table (p. 194).
La construction se fait en deux temps. Search écoute trois modules : Customers (les clients), Stores (les magasins et les produits) et Ordering (les commandes) (p. 189). Il stocke à l'arrivée les événements des clients, des magasins et des produits. À OrderCreated, on les joint à ce qui est déjà en base pour fabriquer la ligne riche. Les statuts mettent ensuite la ligne à jour (pp. 194-195). C'est une secrétaire qui reçoit des notes de trois services : la fiche client, le catalogue, la commande. Elle les classe, et quand la commande arrive, elle écrit une fiche récapitulative complète. Chercher devient un coup d'œil, au lieu d'interroger trois services. L'auteur assume la cohérence à terme : « Notre read model sera cohérent à terme, et, en conditions normales, personne ne le remarquera peut-être » (p. 195).
Une question reste ouverte : que devient la jointure si OrderCreated arrive avant l'événement du client ? Le livre décrit le montage sans traiter ce cas (pp. 194-195).
9Un contrat Pact se vérifie des deux côtés, même pour les messages
Le livre pose quatre niveaux de tests : unitaires, d'intégration, de contrat et de bout en bout (pp. 260-261). Les tests unitaires et d'intégration ressemblent à du Go standard. Le contrat est la partie neuve. Un test de contrat vérifie qu'un fournisseur d'API honore ce qu'un consommateur attend, sans lancer les deux ensemble. Le livre le dit rapide : « Bien qu'ils soient assez hauts dans la pyramide des tests, ces tests de contrat devraient tourner aussi vite que des tests unitaires » (p. 262).
L'outil est Pact, et le livre choisit le contrat piloté par le consommateur (p. 278). Le mécanisme se fait en deux moitiés (p. 279) :
- le consommateur appelle un faux fournisseur avec ses vraies requêtes. L'outil enregistre un fichier de contrat.
- le fournisseur rejoue ces requêtes contre le vrai fournisseur, dont les dépendances sont remplacées par des doubles, de fausses versions écrites pour le test. Il compare les réponses réelles à l'attendu.
Une seule moitié ne prouve rien. Image : un restaurant écrit sur un bon ce qu'il utilise vraiment de la livraison, par exemple cinq kilos de tomates rouges, sans exiger une variété précise. Le fournisseur vérifie qu'il peut livrer exactement cela. Sans le bon, le fournisseur vérifie dans le vide, et sans la vérification, le bon n'est qu'un souhait. Les attentes ne couvrent que ce que le consommateur utilise : un sous-ensemble de routes et de champs (p. 279). Dans l'exemple REST du livre, une interface web appelle l'API des paniers. Le contrat annonce l'ajout d'une quantité négative et attend un code 400. Pour la réponse, il ne vérifie que le champ message, avec un matcher : une règle qui accepte toute valeur de même type plutôt qu'une valeur exacte. La vraie réponse contient d'autres champs (p. 284).
Le fournisseur fabrique l'état de départ avec des StateHandlers. Cette table relie le nom d'un état (« a basket exists ») à une fonction qui monte cet état dans les doubles. Le livre prévient : « Utiliser des états du fournisseur demandera de la communication et de la collaboration entre les équipes » (p. 286). Un Pact Broker, serveur qui conserve les contrats et les résultats de vérification, peut les partager en CI (pp. 280-282).
La vraie nouveauté du chapitre est Pact pour les messages asynchrones, pas seulement pour le REST. Il n'y a pas de requête, donc pas de faux fournisseur. Le consommateur déclare le message qu'il attend (ExpectsToReceive, WithJSONContent) et un ConsumedBy qui le passe à ses vrais handlers, avec des doubles derrière (pp. 287-289). Côté producteur, le vérificateur lit chaque message décrit dans le contrat (« a StoreCreated message »), et le livre écrit pour chaque description un handler. Ce handler monte juste assez du module pour fabriquer l'événement, par exemple en appelant CreateStore(). Un faux publieur garde le dernier message produit, que le vérificateur compare au contrat (pp. 290-292). Une limite est avouée : « Les outils Pact gèrent mieux le JSON et les matchers ne fonctionnent qu'avec le JSON » (p. 290). Le contrat des messages est donc en JSON, alors que le transport réel est en protobuf, le format binaire compact des messages du livre.
Les deux cas côte à côte.
| REST | Messages | |
|---|---|---|
| Ce que le consommateur déclare | Ses vraies requêtes, jouées contre un faux fournisseur | Le message attendu (ExpectsToReceive, WithJSONContent), passé à ses vrais handlers |
| Ce que fait le fournisseur | Rejoue les requêtes contre le vrai fournisseur | Un handler par description monte juste assez du module pour produire l'événement |
| Ce qu'on compare | La réponse réelle à l'attendu | Le dernier message gardé par un faux publieur à l'attendu |
| Format | JSON | JSON, alors que le transport réel est en protobuf (p. 290) |
Le livre garde les tests de bout en bout pour la fin. Ils s'écrivent en Gherkin, un langage de scénarios lisibles (« Given… When… Then… »), exécutés par godog, la bibliothèque Cucumber officielle pour Go, qui relie chaque phrase à une fonction Go (pp. 294-295). L'auteur conseille la sobriété : on commence par les flux critiques du métier (p. 295). Le chapitre ne nomme jamais la saga ni l'outbox : il ne teste ni l'une ni l'autre.
Mon avis, honnêtement
Deux questions comptaient pour moi. Ce livre m'apprend-il quelque chose que mon blog ne dit pas déjà ? Oui, sur quatre sujets précis. Ses mécanismes sont-ils fiables tels quels ? À moitié : le fond est bon, les garde-fous manquent par endroits. Je l'ai lu comme un chantier de refactoring raconté par quelqu'un qui n'enjolive rien, et c'est son charme.
Sa force : il montre le chemin complet. Un monolithe qui marche devient, étape par étape, un système asynchrone qui marche encore, et chaque étape est un dossier du dépôt public. La saga orchestrée garde son état en base. L'inbox et l'outbox vivent dans une transaction par requête. Les snapshots sont versionnés, et les tests de contrat couvrent les messages. L'auteur avoue même ses raccourcis (p. 84).
Deux réserves. L'idempotence tient en une page (pp. 149-150), sans clé d'idempotence (un identifiant joint à la demande, pour reconnaître un rejeu) ni erreurs partielles : si c'est votre sujet, lisez plutôt mes articles. Et la saga suppose que les annulations réussissent (p. 205), sans délai maximal ni reprise : vous ajouterez vous-même ce qui la rend exploitable la nuit.
Mon conseil : lisez d'abord les pages 131 à 139 du chapitre 5 (la publication et les snapshots). Enchaînez sur les chapitres 8 et 9 (la saga, la transaction, l'outbox). Finissez par le chapitre 10 à partir de la page 277, pour les tests de contrat. Le chapitre 3, long, se lit en diagonale. Le chapitre 11 (AWS et Terraform) est un autre livre pour un développeur web. Le chapitre 12 vaut le détour pour l'observabilité (pp. 325-345). Si l'event sourcing est neuf pour vous, passez d'abord par mes fiches Exploring CQRS and Event Sourcing et Understanding Eventsourcing.
Odilon
Toujours valable en 2026 ?
Les patterns durent : transaction par requête, inbox et outbox, saga avec état persisté, snapshots versionnés, contrats Pact. Le dépôt public compile aussi : go build ./... passe dans Docker avec Go 1.26 sur les dossiers des chapitres 5, 8, 9 et 12. Sept points sont à surveiller :
- L'API JetStream. Le livre utilise
nats.JetStreamContext(pp. 161-164), l'API historique. Hors livre, d'après la documentation denats.go(pkg.go.dev/github.com/nats-io/nats.go/jetstream) : un paquetjetstreamse présente comme un nouveau client, censé « remplacer » l'implémentation JetStream du paquetnats. Le livre note aussi l'absence de files partitionnées dans NATS « au moment de l'écriture » (p. 153) : cette affirmation est datée, je ne l'ai pas revérifiée. - La version de Go. La page 4 demande Go 1.17, le tableau de la préface 1.18 ou plus. Les génériques utilisés partout exigent 1.18 (p. 108).
- Les outils.
mockeryv2.14.0 (p. 265) etpact-gov2.x (p. 282) sont de l'époque, comme Terraform, EKS et Docker des deux derniers chapitres.testcontainers-goetgodogont sorti des versions depuis 2022 : je n'ai pas comparé leur API. - Les interfaces. Les figures du livre sont des diagrammes de types Go, et je les ai lues. Elles donnent les signatures de l'orchestrateur, du dépôt de sagas, de l'inbox et de l'outbox. Elles ne montrent pas les corps de fonctions, que j'ai lus dans le dépôt (encadré de l'idée 7). Deux figures portent une coquille d'impression, que le dépôt corrige. Je n'ai pas lu toutes les figures, notamment celles du conteneur d'injection de l'idée 5.
- Le test de contrat du chapitre 12. Le dépôt est en retard sur le texte.
go vetéchoue surdepot/internal/handlers/integration_events_contract_test.go: le test appelleNewIntegrationEventHandlersavec deux arguments, le constructeur en demande trois. Les tests n'ont pas été lancés, car ils veulent PostgreSQL et NATS. - Ce que le livre esquive. Le droit à l'effacement (RGPD) est classé parmi les tâches difficiles et laissé de côté (p. 116). Le livre ne nomme jamais le crypto-shredding : chiffrer les données de chaque personne avec sa propre clé, puis détruire la clé pour les effacer d'un event store où l'on ne supprime rien. Il ne nomme pas non plus l'upcasting : convertir à la lecture un ancien événement vers le format récent. Pour changer un événement déjà écrit, le livre dit seulement d'en créer un nouveau (p. 116).
- Ce que le livre ne traite pas. Reconstruire un read model n'apparaît nulle part. La file des messages en échec (dead-letter queue) tient en une phrase (p. 152), sans plafond de tentatives ni alerte. Une saga restée à mi-chemin n'est pas surveillée, bien que sa table garde un état et une date de mise à jour (p. 221). Le chapitre 12 mesure le nombre et la durée des messages par consommateur (pp. 337-338), mais pas son retard : le dernier message traité contre le dernier écrit.
À qui s'adresse-t-il ?
Lisez-le si
- vous connaissez déjà CQRS et l'event sourcing en Go, et vous voulez voir la suite : la saga, l'outbox, l'inbox.
- vous cherchez un exemple complet de refactoring d'un monolithe modulaire vers des échanges asynchrones.
- vous voulez écrire des tests de contrat pour des messages, pas seulement pour du REST.
- vous maîtrisez Go intermédiaire et vous avez un Docker local pour suivre le dépôt.
Passez si
- vous découvrez l'event sourcing : commencez par un livre de fondations.
- l'idempotence et les erreurs partielles sont votre sujet principal.
- vous attendez un déploiement sur AWS : le chapitre 11 est à part et hors de cette fiche.
Pour aller plus loin
- Sur le blog, le socle : un event store CQRS en Go et PostgreSQL, les sagas par chorégraphie d'événements, et les transitions et le Clone d'un agrégat.
- L'idempotence, que le livre traite en une page : les bases puis le cas avancé.
- L'event sourcing sans event store, par CDC et le pont Pub/Sub avec audit atomique, deux variantes de l'outbox.
- Les livres sur l'event sourcing et le CQRS, dans quel ordre les lire.
- Les fiches voisines : Exploring CQRS and Event Sourcing, Understanding Eventsourcing et Domain-Driven Design.
- Le Grand livre du développeur, chapitres 5 (fiabilité des échanges) et 7 (patterns d'architecture).
- Le dépôt public du livre, sous licence MIT : un dossier par chapitre.
Fiche fondée sur : chapitres 1 à 10 et chapitre 12 lus en entier, chapitre 11 seulement survolé. Les encadrés « Ma lecture » et les vérifications dans le dépôt sont de moi, pas du livre.
Commentaires (0)