Bibliothèque · Résumé et avis

Implementing DDD, CQRS and Event Sourcing

D'Alex Lawrence. Ce que je retiens de 445 pages sur le DDD, le CQRS et l'Event Sourcing codés à la main en JavaScript.

FR EN
Couverture de Implementing DDD, CQRS and Event Sourcing, Alex Lawrence

Implementing DDD, CQRS and Event Sourcing

Implementing DDD, CQRS and Event Sourcing

7.3 /10

« Prose sèche, code limpide, et un auteur qui dit lui-même où ses briques cèdent. »

  • AuteurAlex Lawrence
  • VOLeanpub (auto-édité), 2021 · 445 pages
  • NoteEn anglais uniquement, pas d'édition française
  • Fiche~34 min de lecture
Notation du livre sur 6 dimensionsIdées7/10Applicable7/10Lisibilité7/10Actualité6,5/10Exemples8/10Profondeur8/10

Un logiciel qui garde l'historique de ses changements, codé à la main en JavaScript sans framework : on voit ce que les outils cachent.

Pourquoi ce livre

Dans une équipe qui travaille avec des événements, on entend dès le premier jour un jargon qu'on n'a jamais appris. Un framework fait le travail en silence, et tout va bien jusqu'au jour où un message arrive deux fois ou où deux sauvegardes s'écrasent. Ce jour-là, on découvre qu'on ne savait pas ce que l'outil faisait à notre place.

Implementing DDD, CQRS and Event Sourcing, d'Alex Lawrence, prend le problème à l'envers. Ce livre autoédité sur Leanpub (version 1.3.3, avril 2021) reconstruit chaque brique à la main, en JavaScript et Node.js, sans aucune bibliothèque tierce. Une brique est un mécanisme de base. Même la base de données et le transport des messages reposent sur de simples fichiers. Il couvre trois méthodes qui vont souvent ensemble :

Sa thèse n'arrive qu'en conclusion, et elle tempère tout ce qui précède. « Chaque concept et chaque pattern décrit dans ce livre ne devrait être appliqué que lorsque cela a du sens » (p. 412). Il vise ensuite les frameworks qui implémentent ces méthodes, et avoue avoir lui-même travaillé sur l'un d'eux : « D'une manière générale, je ne recommanderais pas leur usage et je les trouve souvent problématiques » (p. 412). Il précise que c'est « seulement mon opinion personnelle » (p. 413). Écrire chaque brique une fois sert donc à comprendre, puis à ne garder que ce qui sert.

Je travaille dans la fintech et la crypto, en Go, avec de l'Event Sourcing. Je cherchais dans ce livre trois réponses. Pour un projet donné, faut-il séparer l'écriture de la lecture, garder l'histoire, ou rien de tout cela ? Comment empêcher qu'une sauvegarde et le message qui la suit se désynchronisent ? Et vaut-il mieux écrire ses briques soi-même ou prendre un outil ?

Les douze idées se rangent en deux familles, plus deux survols :

Les idées qui restent

1Un objet métier refuse de naître dans un état faux

Avant toute architecture, le livre regarde les plus petits objets du code. Son premier exemple est un logiciel de plan d'appartement, où un bureau s'écrit const furnitureWidth = 100, widthUnit = 'cm' (p. 82). Rien ne relie 100 et 'cm' : on peut changer l'une des deux variables sans l'autre.

La première parade regroupe ce qui va ensemble dans un petit objet, Measurement, qui porte une grandeur (magnitude) et une unité. Cet objet est immuable : il ne change jamais après sa création. Pour passer le bureau de 100 à 120 centimètres, on le remplace par une mesure neuve (p. 84). L'addition suit la même règle : elle refuse de mélanger deux unités et rend un nouvel objet au lieu de toucher l'ancien (p. 87).

plus(measurement) {
  if (this.unit !== measurement.unit)
    throw new Error('unit mismatch');
  const combinedMagnitude =
    this.magnitude + measurement.magnitude;
  return new Measurement(
    {magnitude: combinedMagnitude, unit: this.unit});
}

Le livre appelle ce genre d'objet un objet valeur (Value Object) : seul son contenu compte, et rien ne le distingue d'un autre objet au même contenu. Deux mesures de 60 cm sont donc égales, même si ce sont deux objets distincts en mémoire (p. 85). Puisque rien ne le modifie, il garde l'état vérifié à sa création. Le livre le dit ainsi : « un objet valide au départ reste toujours valide. Aucune vérification ni validation ultérieure n'est nécessaire » (p. 86).

Tout ne peut pas rester immuable, pourtant. Un contact de carnet d'adresses change d'adresse et reste le même contact. Le livre appelle entité (Entity) un objet qu'on suit pour qui il est, pas pour ce qu'il contient : il garde une identité stable pendant que ses valeurs changent (p. 88). Il conseille un identifiant artificiel plutôt qu'un e-mail, qui est unique mais pas constant (p. 88). Une entité garde peu de champs à elle : elle gère son identité et sa vie, et confie le détail à des objets valeur (p. 91). Pour changer l'adresse, on remplace l'adresse entière plutôt que de la modifier champ par champ.

Reste à dire ce que « faux » veut dire. Le livre appelle invariant une règle métier qui doit rester vraie à chaque instant de la vie d'un objet (p. 98). Son exemple est un planificateur de journée de travail, avec deux règles : la description d'une activité ne dépasse pas 50 caractères, et les heures restantes de la journée ne descendent jamais sous zéro (p. 99).

const Activity = function({description, requiredHours}) {
  if (description.length > 50)
    throw new Error('too long description');
  Object.freeze(Object.assign(this,
    {description, requiredHours}));
};

class WorkdayPlan {
  #activities = [];

  planActivity(activity) {
    if (activity.requiredHours > this.getRemainingHours())
      throw new Error('not enough time remaining');
    this.#activities.push(activity);
  }
  // ...
}

L'objection : « mon framework valide déjà les formulaires, pourquoi un concept de plus ? » Le livre répond par le moment de la vérification. Une validation faite après coup porte sur un objet qui existe déjà, alors qu'« un invariant empêche d'entrer dans un tel état dès le départ » (p. 99). Dans le code ci-dessus, Activity vérifie à sa naissance, puis Object.freeze interdit de la modifier. planActivity vérifie à chaque changement. La liste #activities est privée : de l'extérieur, aucune autre porte ne permet d'y ajouter une activité.

Ma lecture

En Go, rien n'oblige à passer par un constructeur, et n'importe quel paquet peut écrire Activity{}. Le même geste passe par des champs non exportés et une fonction qui renvoie une erreur. Piège au passage : len() compte des octets, si bien qu'une description accentuée atteint la limite trop tôt. En PHP, le pendant est mb_strlen().

func NewActivity(desc string, hours int) (Activity, error) {
	// count characters, not bytes
	if utf8.RuneCountInString(desc) > 50 {
		return Activity{}, errors.New("too long description")
	}
	return Activity{desc: desc, hours: hours}, nil
}

À retenir : un objet métier se protège lui-même, valide à sa naissance et vérifié à chaque changement. Il doit maintenant prévenir le reste du code de ce qui lui arrive, et c'est le rôle des événements.

2Un événement raconte un fait passé, et il peut arriver deux fois

Un objet bien protégé reste seul dans son coin. Or le métier demande souvent des réactions en chaîne. Sur une plateforme de petites annonces, le livre ajoute une option payante : l'utilisateur choisit un mot-clé, par exemple « chair » (chaise), et chaque nouvelle annonce qui le contient s'ajoute à sa liste. Il reçoit alors un e-mail (p. 123). L'annonce ne doit connaître ni les listes ni l'envoi de messages.

Le livre coupe ce lien en inversant le sens : l'annonce dit ce qui lui est arrivé, et d'autres parties du code écoutent. Une fois son titre vérifié et ses champs remplis, la classe ClassifiedAd publie un message ClassifiedAdCreated. Le livre insiste sur le moment de la publication : « l'événement ne se produit que pour les annonces créées avec succès, pas pour les tentatives qui échouent » (p. 126).

Ce message s'appelle un événement de domaine (Domain Event), que la suite de cette fiche appelle simplement événement : un fait du métier, au passé, que les gens du métier jugent important. On le nomme par le sujet puis l'action au passé : login() donne UserLoggedIn (p. 113). Un fait ne se discute plus. Le livre parle d'« occurrences », des faits survenus, qui « peuvent seulement être prises en compte, mais pas rejetées » (p. 112). Le message reste donc simple : des attributs immuables, sans fonctions, avec un type, l'identifiant du sujet, les données propres, un identifiant unique et souvent une heure de création (p. 114).

Pour livrer ces événements, le livre code un bus d'événements : un intermédiaire où l'émetteur dépose un événement et où chaque abonné reçoit les types qu'il a demandés (p. 118). Il écarte l'EventEmitter intégré à Node.js pour une raison précise : celui-ci ne permet pas d'attendre la fin d'un abonné asynchrone (p. 119), ce dont la livraison garantie ci-dessous a besoin (p. 121).

Le livre avoue que la théorie des événements et de leur diffusion est « assez triviale » (p. 120). La difficulté vient de la livraison. Pour ne rien perdre, il faut le plus souvent une file qui survit à un plantage. Elle peut se loger chez l'émetteur, le bus et l'abonné à la fois (p. 120). Chaque élément traité doit aussi être confirmé (p. 120). J'en déduis le piège : si une confirmation se perd, le message repart alors qu'il était arrivé. Le livre conclut qu'il est « techniquement presque impossible de garantir que chaque élément est livré exactement une fois » (p. 121). La plupart des systèmes promettent une livraison au moins une fois : l'événement arrive à coup sûr, parfois en double (p. 121).

L'exemple du livre rend le double concret. Une commande publie OrderCreated, et un abonné envoie l'e-mail de confirmation. Si l'événement arrive deux fois, le client reçoit deux e-mails. Un filtre garde les identifiants des événements déjà traités et jette le doublon (p. 122) :

const createSubscriberWithDeDuplication =
  originalSubscriber => {
    const processedEventIds = [];
    return event => {
      if (processedEventIds.includes(event.id)) return;
      originalSubscriber(event);
      processedEventIds.push(event.id);
    };
  };

Ce filtre ne fait que réduire le risque : l'identifiant n'est noté qu'après le traitement, et un plantage entre les deux laisse repasser le doublon. L'abonné doit donc rester capable de gérer les doublons (p. 121), si possible en étant idempotent : « traiter un événement plusieurs fois doit avoir le même effet que le traiter une fois » (p. 122). Mettre un stock à 5 est idempotent, ajouter 5 au stock ne l'est pas. Le livre concède que ce n'est pas toujours possible (p. 122). J'ajoute l'e-mail de confirmation : un message parti ne se rattrape pas.

Dernier piège, l'ordre. Les événements peuvent arriver dans un autre ordre que celui de leur publication, surtout entre deux programmes ou à travers le réseau (p. 123). Garantir un ordre global coûte du travail à l'émetteur, au bus et aux abonnés, et le livre tranche : « dans la plupart des cas, les bénéfices ne justifient pas la complexité associée » (p. 123). Quand l'abonné a besoin d'une séquence, c'est à lui de la rétablir juste avant de traiter (p. 123). Le livre ne montre pas ce geste. J'en déduis une piste : l'heure de création que porte souvent chaque événement (p. 114) suffit à trier.

Ma lecture

Hors livre. Le tableau processedEventIds vit en mémoire et disparaît au redémarrage, au moment précis où les renvois arrivent. La version qui tient range l'identifiant traité en base, sous une contrainte d'unicité qui refuse le second passage. Il est écrit dans la même transaction SQL que l'effet, un bloc d'écritures qui réussit en entier ou pas du tout. Mon article L'idempotence expliquée, partie 2 le montre en Go avec PostgreSQL.

À retenir : un événement est un fait au passé qu'on ne refuse pas, et tout abonné doit supporter de le recevoir deux fois ou en retard. Reste à savoir quels objets doivent changer ensemble, et lesquels peuvent attendre un événement.

3Ce qu'on modifie d'un seul bloc doit rester petit

Les événements relient des objets, mais tous ne peuvent pas attendre. Le livre le montre par une panne. Deux personnes ajoutent chacune un article à la même liste de courses, au même moment. Chacune charge sa copie, ajoute son article et sauvegarde. La seconde sauvegarde efface la première : « un seul des articles est ajouté avec succès » (p. 143). Ce défaut s'appelle l'écrasement : la dernière écriture gagne, sans que personne ne le voie.

Première parade du livre, un verrou : la seconde personne attend que la première ait fini, donc les ajouts passent un par un. Le livre l'appelle verrouillage pessimiste, par opposition à la méthode de l'idée 4 (p. 175). Plus rien ne se perd, mais l'exécution devient « presque équivalente à un traitement séquentiel » (p. 144). Le verrou protège en réalité un ensemble d'objets qui doit changer d'un bloc, et l'auteur donne un nom à cet ensemble.

Un agrégat (Aggregate) est un groupe d'objets qu'on modifie d'un seul bloc et qu'on sauvegarde en une seule transaction : tout passe ou rien ne passe, et les invariants restent vrais (p. 132). On n'y entre que par une seule entité, qui sert de porte d'entrée, et l'agrégat refuse toute modification qui casserait ses règles (p. 133). Entre deux agrégats, le seul lien permis est l'identifiant de l'autre : en placer un dans l'autre ne les rendrait pas transactionnels, puisque chacun est sauvegardé dans sa propre transaction (p. 145).

Que mettre dans un agrégat ? Quand un invariant touche plusieurs objets, ils partagent le même agrégat (p. 145). Le livre demande ensuite de questionner chaque règle, sur l'exemple d'un budget de 1000 qui reçoit un loyer de 800, puis des courses de 250. Dans la première version, le budget contient ses dépenses et refuse les courses, puisqu'il ne reste que 200 (p. 148). Or un budget dépassé n'est pas une panne du logiciel, c'est une information pour l'utilisateur. La seconde version sort les dépenses du budget et calcule le reste à part, car « un budget dépassé n'est pas un état invalide mais une situation attendue » (p. 149). Les courses passent alors sans erreur, et le dépassement devient une donnée à montrer (p. 149).

Une fois les vraies règles trouvées, le livre veut des agrégats « aussi petits que possible » (p. 150). Plus un agrégat contient d'objets, plus deux personnes risquent de le modifier en même temps (p. 150). Son exemple est un calendrier : deux ans à deux rendez-vous par jour font 1460 entrées, lourdes à charger d'un bloc (p. 151). Pour le livre, le risque de conflit compte pourtant moins que le défaut de conception (p. 151). Modifier un rendez-vous n'en touche jamais un autre, donc aucun conflit n'est possible et aucune règle ne les lie : rien ne justifie de les enfermer ensemble (p. 151).

Tout ce qui sort de l'agrégat se met d'accord plus tard, par événement. L'exemple est un serveur de jeu. Le serveur garde sa capacité et la liste de ses joueurs, et chaque joueur garde son statut « en ligne » et son serveur courant (p. 152). Ce sont deux agrégats. Quand le serveur accepte un joueur, il publie PlayerAddedToServer, et un abonné marque ensuite le joueur en ligne (p. 155) :

addPlayer(playerId) {
  if (this.getLeftSpots() <= 0)
    throw new Error('limit reached');
  this.#playerIds.push(playerId);
  this.#eventBus.publish(new PlayerAddedToServerEvent(
    {serverId: this.id, playerId}));
}

Ce décalage porte un nom, la cohérence à terme (eventual consistency) : une donnée lue hors de l'agrégat peut être périmée un court instant. Le livre prend l'exemple d'une carte bancaire : le paiement passe tout de suite, mais le compte en ligne ne l'affiche que plus tard (p. 151). Le délai est souvent acceptable, surtout quand il se compte en millisecondes, à une condition : « un mécanisme de mise à jour doit toujours être fiable » (p. 152), c'est-à-dire sans perdre d'événement (idée 5).

Pourquoi garder le statut en ligne des deux côtés, chez le joueur et dans la liste du serveur ? Pourquoi ne pas le ranger d'un seul côté et calculer l'autre à la demande ? S'il vit chez les joueurs, connaître les places libres d'un serveur oblige à interroger tous les joueurs. S'il vit chez le serveur, savoir si un joueur est en ligne oblige à fouiller tous les serveurs. « Aucune des deux approches ne passe bien à l'échelle » (p. 153).

À retenir : un agrégat est la plus petite zone qui doit rester juste à chaque instant, et tout ce qui peut attendre se synchronise par événement. Le verrou du début faisait attendre tout le monde, et l'idée suivante montre la parade qui ne bloque personne.

4Un numéro de version empêche deux sauvegardes de s'écraser

Le verrou de l'idée 3 fait attendre chaque transaction jusqu'à ce que les précédentes aient fini (p. 144). Le livre propose une autre parade, logée dans la classe qui sauvegarde les objets. Cette classe s'appelle un repository : elle charge un agrégat par son identifiant et le sauvegarde, en cachant fichiers et tables au reste du code (p. 167).

Le scénario de départ est un meetup de 30 places (p. 181). Deux personnes réservent 15 places chacune, au même instant (p. 182) : à elles deux, elles remplissent le meetup. Les deux chargent le même état, ajoutent leur réservation et sauvegardent. Sans garde, la seconde sauvegarde écrase la première, comme la liste de courses, et 15 places réservées disparaissent.

La concurrence optimiste règle le cas avec un numéro. Chaque sauvegarde réussie augmente la version de l'agrégat, et chaque écrivain retient la version qu'il a lue. Avant d'écrire, le repository relit la version actuelle, et « seulement si elles correspondent, la requête est traitée et la version est incrémentée » (p. 175). Sinon la requête est rejetée : c'est un conflit. La méthode ne touche que les écritures, et la lecture reste toujours permise (p. 175). J'en déduis le sens du mot optimiste : on parie que le conflit est rare. Dans le code, un agrégat jamais sauvegardé n'a pas de fichier : .catch(() => ({})) et || 0 lui donnent la version 0.

async #verifyVersion(entityToSave) {
  const latestEntity = await this.load(entityToSave.id)
    .catch(() => ({}));
  if ((entityToSave.baseVersion || 0)
      !== (latestEntity.baseVersion || 0))
    throw new ConcurrencyConflictError(
      {entityToSave, latestEntity});
}

Que faire d'un refus ? Le livre donne trois réponses, de la plus simple à la plus métier :

  • Signaler : l'erreur remonte, et l'utilisateur recommence.
  • Relancer : le code recharge et rejoue, ici avec dix essais au plus. Trois demandes de 10 places sur 30 passent alors toutes (p. 183).
  • Résoudre : le métier décide. Sur 20 places, Jim en prend 15, il en reste 5. Ben en demande 1 et John 5 au même instant. Ben sauvegarde le premier, il reste 4 places. John perd le conflit : plutôt que de le refuser, le code relit les places libres et lui réserve le plus petit nombre entre 5 et 4 (pp. 184-185).

Le livre met pourtant en garde : la relance « favorise un comportement implicite et traite les conflits comme des situations attendues » (p. 182). Les conflits doivent rester l'exception, et des conflits fréquents peuvent signaler un agrégat mal découpé (p. 182). Pour le meetup, il refuse de découper : toutes les réservations partagent l'invariant « la somme ne dépasse pas la capacité », donc un seul agrégat, au prix de conflits rejoués : c'est le compromis (p. 183).

Son code a aussi une limite, qu'il avoue : il « ne fonctionne correctement qu'avec une seule instance de repository par type d'entité » (p. 178), c'est-à-dire un seul objet repository dans le programme. La vérification relit le fichier puis écrit, en deux temps. Une file d'attente en mémoire, propre à cet objet, empêche deux sauvegardes de se croiser. Un second programme lancé sur les mêmes fichiers a sa propre file et ne la voit pas.

Ma lecture

Transposition hors livre. Avec une base SQL, la comparaison et l'écriture se font en une seule requête, et la limite tombe quel que soit le nombre de serveurs. Si la requête ne touche aucune ligne, quelqu'un a sauvegardé entre-temps : c'est le conflit.

UPDATE meetup
SET data = $1, version = version + 1
WHERE id = $2 AND version = $3;

À retenir : une version lue puis comparée à l'écriture remplace le verrou, et le conflit devient une décision métier au lieu d'une perte silencieuse. Une sauvegarde réussie doit encore prévenir les autres, et c'est là que se cache la panne suivante.

5Sauvegarder puis prévenir : l'événement voyage avec la donnée

Une sauvegarde protégée doit encore prévenir les autres de ce qu'elle a changé, et ces deux gestes ne se font jamais d'un seul coup. Le livre part d'une boutique : chaque commande publie un événement, et un abonné envoie l'e-mail de confirmation (p. 185). Si l'on publie avant de sauvegarder et que la sauvegarde échoue, le client reçoit la confirmation d'une commande qui n'existe pas. Pour le livre, « quelque chose ne compte comme arrivé que lorsque le changement associé est sauvegardé » (p. 185).

Publier après la sauvegarde ne suffit pas non plus. Les deux écritures ne forment pas une transaction, et « quel que soit l'ordre, la seconde opération peut échouer sans affecter la première » (p. 190). Si le bus tombe juste après, la commande est enregistrée mais personne n'est prévenu, et l'e-mail ne part jamais (p. 190).

Le livre construit la parade par étapes, sur une autre plateforme, des enchères où l'on prévient l'enchérisseur dépassé (p. 185). D'abord, l'entité change de rôle : contrairement au serveur de jeu de l'idée 3, elle ne publie plus rien. Elle range ses nouveaux événements dans une liste, ce qui la libère de toute dépendance au bus (p. 188). Ensuite, le repository écrit l'enchère et ses événements dans le même fichier, en une écriture qui réussit en entier ou pas du tout (p. 191) :

async save(entity) {
  const data = {...this.#convertToData(entity),
    newDomainEvents: undefined};
  const savedEvents =
    await this.loadDomainEvents(entity.id);
  const allEvents =
    savedEvents.concat(entity.newDomainEvents);
  const fullData = {...data, domainEvents: allEvents};
  await writeFileAtomically(
    this.getFilePath(entity.id), JSON.stringify(fullData));
}

Enfin, il faut publier depuis ce fichier. Dans la première version, le repository s'en charge lui-même : après l'écriture, il publie les nouveaux événements, puis note l'identifiant du dernier envoyé dans un petit fichier propre à chaque enchère (p. 192). Si le bus tombe, sa fonction publishOverdueDomainEvents, « publier les événements en retard », reprend tout ce qui suit cet identifiant (p. 192).

Ce schéma s'appelle l'outbox, la boîte d'envoi : à chaque changement, les événements rejoignent la donnée dans le même stockage, en une transaction (p. 190), et leur publication est traitée à part. Avec une base de données, on range souvent les événements à publier dans une collection à part. On les y marque une fois publiés (p. 193).

Le prix est connu d'avance, et il vaut pour tout composant qui écrit une donnée puis, à part, le numéro du dernier élément traité. Sans transaction commune, un plantage entre les deux écritures laisse le numéro en retard, et l'événement repart au passage suivant. Le livre le dit du repository qui publie : l'implémentation « garantit la publication » mais « peut mener à des doublons dans de rares cas » (p. 192). C'est la livraison au moins une fois de l'idée 2, d'où l'abonné idempotent.

Pourquoi ne pas laisser le repository publier ? Pour une raison de conception, cette fois, et non de transaction. Publier depuis le repository le rend dépendant d'un mécanisme de publication et alourdit son rôle (p. 188). Le repository se contente donc de fournir les événements sauvegardés (p. 194). Un composant à part, DomainEventPublisher, reprend la publication avec le même suivi du dernier identifiant. Sa fonction publishDueDomainEvents sert aussi à rattraper une panne (pp. 194-195).

À retenir : ce qui doit être publié se sauvegarde avec la donnée, puis part de là, quitte à partir deux fois. Reste à organiser les actions de l'utilisateur qui mettent ces objets en mouvement.

6Une action de l'utilisateur tient en trois gestes : charger, demander au métier, sauver

Les idées précédentes ont donné des objets, des événements et des repositories. Il manque celui qui met tout en mouvement quand une requête HTTP arrive. Le livre confie ce rôle à une couche fine de services applicatifs. Chacun sert un seul cas d'usage, c'est-à-dire une action que l'utilisateur peut demander (p. 208). Son exemple est un bloc-notes (p. 211) :

async updateNoteContent({noteId, content}) {
  const note = await this.#noteRepository.load(noteId);
  note.content = content;
  await this.#noteRepository.save(note);
}

Ce code affecte note.content sans contrôle, alors que l'idée 1 protégeait ses objets. Ce n'est pas un contre-exemple : le livre a pris ici une note sans règle métier, au titre et au contenu libres (p. 210). Son comportement se réduit donc à une affectation. Une entité à règles appellerait une méthode comme planActivity (idée 1).

Le livre résume ce geste : « la procédure standard consiste à charger une entité, exécuter un comportement et sauvegarder le changement qui en résulte » (p. 212). La règle qui va avec interdit toute logique métier dans cette couche : elle « ne doit contenir aucun comportement ni garder aucun état qui appartienne au modèle du domaine » (p. 208). Le livre partage les rôles ainsi : le domaine (les objets des idées 1 à 3) porte les règles. La couche applicative le protège en faisant façade, la porte d'entrée qui délègue (p. 208). J'en tire l'intérêt : les règles vivent à un seul endroit. Un détail pratique complète la règle : le service reçoit l'identifiant de l'objet à créer au lieu de le générer (p. 209). Le livre y voit un comportement plus prévisible, qui permet une procédure idempotente (p. 209). Rejouée, la même demande vise le même objet au lieu d'en créer un second.

Le schéma se complique quand une action touche deux agrégats. Sur la plateforme de petites annonces, chaque compte peut publier trois annonces au plus, et publier demande trois étapes : créer l'annonce, ajouter son identifiant au compte du vendeur, envoyer une confirmation (p. 215). Le livre appelle processus un cas d'usage fait de plusieurs étapes. Chacune a sa propre transaction, puisqu'un agrégat se sauvegarde en une seule (idée 3, p. 214). Il en essaie trois versions :

  • Un seul service fait tout. C'est simple, mais une panne entre deux étapes laisse une annonce que le compte ne connaît pas. Le livre le dit : « une interruption de service peut faire exister cet état intermédiaire de façon permanente » (p. 218).
  • Un service par étape, que l'appelant enchaîne. Chaque étape est sûre, mais l'enchaînement repose sur l'appelant, qui peut lui aussi s'interrompre au milieu (p. 219).
  • Des événements relient les étapes. Le service crée l'annonce et s'arrête là. L'événement ClassifiedAdCreated déclenche l'ajout au compte, qui publie à son tour l'événement qui déclenche la confirmation (pp. 219-222).

La troisième version gagne en solidité, et le livre dit ce qu'elle coûte. Si la livraison est garantie (idée 5), le compte finit toujours par être mis à jour, et l'état ne reste jamais faux. Le système reste pourtant visible un instant dans l'état intermédiaire (p. 222). Il reste un risque : la règle des trois annonces s'appuie sur le compte, mis à jour plus tard, et un retard peut laisser passer une quatrième annonce (p. 222). Le livre propose qu'un abonné de ClassifiedAdCreated exécute, si la limite est dépassée, une action corrective, sans dire laquelle (p. 222). Ici, le service ne fait que lancer la première étape, puis chaque étape réagit à la précédente sans composant central. Le livre ne range pas cet exemple dans une case, mais sa définition de la chorégraphie lui va : chaque partie réagit à un déclencheur et fait une seule étape (p. 215). L'orchestrateur, un composant central qui suit l'avancement, a surtout du sens selon lui pour les processus longs ou à plusieurs chemins (p. 215).

Dernier outil de la couche : ce qui concerne tous les services à la fois, comme les traces écrites (logs), la validation ou la sécurité. Écrit dans chaque service, ce code « se disperse » dans tout le logiciel (p. 223). Le livre l'enroule donc autour des services, sous forme de middleware : une enveloppe qui s'exécute avant et après chaque appel (p. 223). Après la trace et la validation, son dernier exemple reprend la relance de l'idée 4. Une seule enveloppe rejoue tout appel qui échoue sur un conflit de version, ici en cinq essais au plus, avec 15 millisecondes d'attente (p. 230) :

const createRetryMiddleware = ({retries}) =>
  async ({originalFunction, args, next}) => {
    while (retries-- > 0)
      try {
        return await next({originalFunction, args});
      } catch (error) {
        const isConflict =
          error.message === 'ConcurrencyConflict';
        if (!isConflict || retries <= 0) throw error;
        await timeout(15);
      }
  };
Ma lecture

En Go, la même enveloppe s'écrit comme un middleware net/http, une fonction qui reçoit le service suivant et renvoie un service enrichi. En PHP, Symfony Messenger range ces enveloppes dans la configuration de son bus de messages. Dans les deux cas, la relance ne doit viser que l'erreur de conflit, jamais une règle métier refusée.

À retenir : un service applicatif charge, demande au métier, sauvegarde, et ne décide de rien lui-même. Quand une action touche plusieurs agrégats, des événements enchaînent les étapes, au prix d'un état intermédiaire visible. Tout ce chemin sert à écrire. Reste la lecture, qui a d'autres besoins.

7Écrire et lire n'ont pas les mêmes besoins

Jusqu'ici, le même objet sert à tout : on le charge pour le modifier, et on le charge aussi pour l'afficher. Le livre montre où cela coince avec des avis clients sur un ordinateur portable, notés de 0 à 5 (p. 255). Pour afficher la note moyenne d'un produit, la version naïve charge tous les avis, un fichier par avis, et calcule la moyenne à chaque affichage. Le livre le constate : « bien que cette implémentation fonctionne, elle a une mauvaise performance, car elle exige de toujours charger tous les avis d'un produit » (p. 258).

La version séparée tient à côté une petite fiche par produit, avec ses notes et leur moyenne déjà calculée (p. 259). Chaque nouvel avis la met à jour. Pour afficher la moyenne, « un seul fichier doit être chargé » (p. 260).

async addOrUpdateRating({productId, reviewId, rating}) {
  const product = await this.#storage.load(productId);
  product.ratings[reviewId] = rating;
  product.averageRating = ratingCalculator
    .getAverageRating(Object.values(product.ratings));
  await this.#storage.save(productId, product);
}

Ce découpage est le CQRS de l'introduction. D'après le livre, les deux côtés peuvent différer par leurs données, leur disponibilité, leur cohérence, leur performance et la complexité de leur code (p. 251). L'exemple montre la performance, et la suite montre une donnée mise en forme puis un retard toléré. J'y ajoute une lecture à moi : sans règle métier, le code du côté lecture reste simple. En général, le livre applique le CQRS à une zone choisie du logiciel, pas à tout le système (p. 251).

Le modèle d'écriture est l'agrégat de l'idée 3, dont on ne garde que les opérations qui modifient : il protège les invariants, toujours sur l'état actuel (p. 254). Le modèle de lecture est une donnée déjà mise en forme pour un écran, sans règle métier (p. 255). La fiche de notes ci-dessus en est un.

L'objection : « deux modèles, c'est de la complication ». Le livre répond que beaucoup de projets le font déjà sans le savoir : « même le seul usage d'un mécanisme de cache peut être vu comme une forme de CQRS » (p. 253). Il ajoute qu'une grande plateforme qui affiche son nombre d'utilisateurs ne le calcule certainement pas en interrogeant sa base de production (p. 253).

Reste à garder le modèle de lecture à jour. Le livre compare trois façons, sur des chansons que les utilisateurs aiment ou n'aiment plus. Le modèle de lecture doit donner les plus aimées (p. 261) :

  • Le côté écriture appelle lui-même le côté lecture après chaque changement. C'est rapide, mais l'écriture dépend alors de la lecture, et rien ne garantit que l'appel aboutisse (pp. 263-265).
  • Le côté lecture recalcule à la demande. Chaque demande du classement parcourt toutes les chansons, et un cache garde la réponse une seconde. C'est simple aussi, mais la lecture reste lente, donc intenable avec beaucoup de chansons (p. 267).
  • Le côté écriture publie un événement, le côté lecture s'y abonne. C'est « la variante la plus puissante » (p. 269), au prix d'une complexité supplémentaire (p. 268).

Cette séparation a une conséquence visible : le modèle de lecture est en cohérence à terme (idée 3). « Même si la mise à jour peut prendre du temps, elle devrait en général se terminer en quelques secondes » (p. 261). L'écran doit assumer ce délai, car juste après un clic il peut relire une donnée périmée. Le livre cite deux parades : afficher un instant ce que l'utilisateur vient d'envoyer, ou prévenir l'écran quand la donnée change (p. 261). L'idée 11 y revient.

Le découpage apporte enfin un vocabulaire. Ce que l'utilisateur demande de faire s'appelle une commande (LikePost), ce qu'il demande à lire une requête de lecture (query) (pp. 270-273). Commande et événement se ressemblent, et le livre les oppose. Une commande est « une instruction de faire quelque chose, formulée au présent » (p. 273), adressée à un seul destinataire qui peut l'accepter ou la refuser. Un événement décrit un fait passé, diffusé à tous, qu'on ne peut que prendre en compte (p. 273). Le code qui reçoit une commande s'appelle un handler, un service applicatif de l'idée 6 (p. 274). Il ne devrait rien renvoyer, sauf un simple code de statut (p. 278), car lire est l'affaire d'un autre handler (p. 274).

À retenir : séparer l'écriture de la lecture permet à chaque côté d'avoir la forme qui lui convient, à condition d'accepter un délai que l'écran doit gérer. Le côté écriture garde encore l'état actuel de chaque agrégat. L'idée suivante propose de ne garder que son histoire.

8On garde l'histoire, et l'état n'est qu'un calcul

L'Event Sourcing ne garde que l'histoire. Un relevé bancaire fonctionne déjà ainsi : la banque garde chaque opération, et le solde n'est que leur somme. Le livre définit l'Event Sourcing comme « un patron d'architecture où l'état est représenté comme une suite d'événements de changement » (p. 299). Les événements sont immuables et s'ajoutent à la fin d'un journal, une liste où l'on n'écrit qu'à la suite, sans rien modifier ni supprimer. L'état courant se recalcule à la demande, et le livre le tient pour transitoire : on peut le jeter et le refaire (p. 299).

Son exemple est un système de tickets de support. On ouvre un ticket, on le commente, on le ferme, et commenter un ticket fermé le rouvre (p. 304). Le livre choisit pour ses exemples le style par fonctions (p. 310) : une fonction reçoit l'état et un événement, puis rend le nouvel état. On lui passe chaque événement dans l'ordre (p. 309) :

const applySupportTicketEvents =
  (state = {}, events = []) =>
    events.reduce(applyEvent, state);

const applyEvent = (state, {type, data}) => {
  if (type === SupportTicketOpenedEvent.type)
    return {...state, id: data.ticketId, isOpen: true};
  if (type === SupportTicketClosedEvent.type)
    return {...state, isOpen: false};
  if (type === SupportTicketReOpenedEvent.type)
    return {...state, isOpen: true};
  return {...state};
};

Un comportement, lui, ne modifie rien. Il lit l'état et renvoie les événements à ajouter. Commenter un ticket fermé en produit deux, une réouverture puis le commentaire, et c'est pourquoi chaque comportement renvoie une liste (p. 309) :

const commentSupportTicket =
  (state, {authorId, message = ''}) => {
    const ticketId = state.id;
    const events = [];
    if (!state.isOpen) events.push(
      new SupportTicketReOpenedEvent({ticketId}));
    events.push(new SupportTicketCommentedEvent(
      {ticketId, authorId, message}));
    return events;
  };

Le livre en tire trois exigences pour le modèle d'écriture. Chaque agrégat doit savoir reconstruire son état à partir d'une liste d'événements. Ses comportements ne modifient jamais l'état directement, ils produisent des événements qui décrivent le changement. Enfin, ces nouveaux événements doivent sortir de l'agrégat pour être sauvegardés (p. 303).

Pourquoi s'imposer cela ? Pour l'auteur, l'avantage le plus net se joue côté lecture : l'historique des événements « permet de dériver des données spécialisées pour la lecture, même rétroactivement » (p. 299). Une question que personne ne posait hier trouve sa réponse dans des événements déjà écrits. Il faut donc un côté lecture séparé, et le livre est net : l'Event Sourcing « ne peut être appliqué correctement qu'avec une forme de CQRS » (p. 299).

Un piège guette pourtant. Les événements rangés dans l'histoire sont fins, et certains n'ont de sens que dans leur contexte, la partie du logiciel qui les produit : « les exposer directement risque de coupler des fonctionnalités externes à des détails internes » (p. 301). Le livre précise alors qu'il ne nomme événements de domaine que les événements publics, destinés à l'extérieur (p. 302). Le chapitre des événements (idée 2) employait pourtant le mot plus largement. Un mécanisme dédié les traduit et écarte au passage les privés (p. 301). Sur une plateforme vidéo, l'ajout et le retrait d'un abonnement sont publics, car le propriétaire de la chaîne doit en être prévenu. Le réglage des notifications reste privé (p. 302).

Le livre explique deux styles pour écrire ce modèle, objet et fonctions, plus une variante. Il refuse de trancher : « il n'existe pas d'argument universel » pour l'un ou l'autre (p. 304). Le prix, lui, est dit sans détour : une conception et un code plus complexes (p. 303).

Ma lecture

Hors livre. Un état stocké avec un journal à côté fait deux copies, qui peuvent diverger si un code écrit l'état sans l'événement. Pour auditer seulement, gardez l'état comme référence et ajoutez un journal d'audit. Si l'historique doit faire foi ou nourrir de futures vues, l'Event Sourcing vaut son prix.

Ma lecture

Une pratique courante, hors livre. Rejouer tout l'historique à chaque chargement finit par coûter cher quand un agrégat accumule des milliers d'événements. La parade habituelle est l'instantané (snapshot) : on range de temps en temps l'état calculé avec le numéro du dernier événement inclus, puis on ne rejoue que la suite. Mesurez avant de l'ajouter, car un instantané est une donnée de plus à garder juste quand les événements changent de forme.

À retenir : en Event Sourcing, la vérité est la liste des faits, et l'état se recalcule en la rejouant. Cette liste doit maintenant vivre quelque part, et le livre montre qu'il ne faut pas grand-chose pour la ranger.

9Une base d'événements tient en quelques dizaines de lignes

Les produits spécialisés dans le rangement des événements impressionnent. Le livre les démystifie en écrivant le sien. Il appelle flux (stream) la suite des événements d'un même agrégat, et Event Store la base qui garde ces flux. Son minimum tient en une phrase : « au minimum, un Event Store doit fournir une opération pour lire un flux et une autre pour ajouter de nouveaux événements » (p. 311). Avec lui, les repositories de l'idée 4 deviennent inutiles (p. 310), puisque chaque flux contient l'histoire dont se déduit l'état d'un agrégat (idée 8).

Sa version tient sur des fichiers : un dossier par flux, un fichier numéroté par événement, et un fichier _version qui dit où s'arrête le flux (pp. 311-312). Pour une marchandise d'entrepôt, cela donne :

good/good-id/
  1.json
  2.json
  3.json
  _version   (contient 3)

L'ajout se lit sans peine. Le code lit la version, numérote chaque nouvel événement à la suite, écrit chaque fichier, puis seulement met à jour la version (pp. 311-312) :

async save(streamId, events) {
  const dir = `${this.storageDirectory}/${streamId}`;
  await mkdir(dir, {recursive: true});
  const currentVersion = await this.#getStreamVersion(dir);
  await Promise.all(events.map((event, index) => {
    const number = currentVersion + index + 1;
    return writeFileAtomically(`${dir}/${number}.json`,
      JSON.stringify({...event, number}));
  }));
  const newVersion = currentVersion + events.length;
  await writeFile(`${dir}/_version`, `${newVersion}`);
}

Le détail qui compte est l'ordre des écritures. La lecture ignore tout fichier au-delà de la version. Donc, si le serveur tombe au milieu d'un lot, les fichiers déjà écrits restent invisibles, et le flux reste propre. Le livre le dit : l'écriture « peut échouer au milieu de la persistance de plusieurs événements sans produire d'état corrompu » (p. 312).

La concurrence optimiste de l'idée 4 revient plus simplement : l'écrivain passe la version lue, expectedVersion, et le store refuse d'écrire si le flux a bougé (p. 314), avec la même limite d'une seule instance :

const currentVersion = await this.#getStreamVersion(dir);
if (expectedVersion != null
    && expectedVersion !== currentVersion)
  throw new StreamVersionMismatchError(
    {expectedVersion, currentVersion});

Reste à nourrir le côté lecture. Le store offre un abonnement : un abonné reçoit les événements d'un flux à partir d'une version donnée, puis ceux qui arrivent ensuite, sans interroger le store en boucle (pp. 315-316). Pour suivre tous les agrégats à la fois, le store recopie chaque événement dans un flux global, nommé $global (p. 318). Il refuse d'y promettre un ordre entre flux différents (p. 318). Le store note le dernier événement copié dans un fichier de version, et retrouve le prix de l'outbox (idée 5) : un événement peut apparaître deux fois (p. 319).

Le livre trace enfin une frontière nette. Ces abonnements servent à l'intérieur du contexte qui possède le store, jamais à diffuser des événements dans tout le système (p. 316). Pour parler à l'extérieur, un abonné dédié lit le flux global. Il ne publie que les types publics : c'est le mécanisme de l'idée 8 (p. 329).

Ma lecture

Version base de données, hors livre. Dans PostgreSQL, une contrainte d'unicité sur le couple flux et version fait le travail de expectedVersion. Deux écrivains qui ont lu la version 3 insèrent chacun une ligne de version 4. La base refuse la seconde, quel que soit le nombre de serveurs. Mon article CQRS en Go, partie 4 : PostgreSQL comme event store détaille la table et les abonnements.

CREATE TABLE events (
  stream_id text   NOT NULL,
  version   bigint NOT NULL,
  type      text   NOT NULL,
  data      jsonb  NOT NULL,
  UNIQUE (stream_id, version)
);

À retenir : un Event Store, c'est ajouter à la fin, vérifier la version attendue et prévenir les abonnés. Ces abonnements servent à fabriquer les écrans, et c'est le rôle des projections.

10Une vue de lecture se calcule en rejouant les événements

Une fois l'histoire rangée, il faut en tirer des écrans. Le livre appelle projection le composant qui s'en charge : « les projections de modèles de lecture sont des composants qui consomment des flux d'événements et calculent des données de lecture dérivées » (p. 320). Il en distingue deux sortes, les projections à la demande et les projections continues (p. 320).

La projection à la demande calcule au moment de la question. L'exemple est le temps de jeu d'un joueur. Son flux contient des événements « a commencé à jouer » et « a arrêté », chacun avec son heure (p. 321). Pour savoir combien de temps il a joué, la projection charge le flux et additionne les écarts entre chaque début et chaque fin (p. 322) :

let result = 0, lastStart = 0;
const {events} =
  await this.#eventStore.load(`player/${playerId}`);
events.forEach(({data, type}) => {
  if (type === PlayerStartedPlayingGameEvent.type)
    lastStart = data.time;
  if (type === PlayerStoppedPlayingEvent.type)
    result += data.time - lastStart;
});
return result;

Le résultat n'est gardé nulle part et se recalcule à chaque question. Le coût grandit donc avec le nombre d'événements (p. 323). Le livre l'accepte ici : « c'est négligeable, car le temps de jeu sera sans doute demandé rarement » (p. 323).

Une donnée lue sans arrêt se calcule d'avance. La projection continue s'abonne au flux global et met à jour son modèle de lecture à chaque événement (p. 323). L'exemple est le stock d'un entrepôt. Une fonction reçoit l'ancien état et un événement, et rend le nouvel état (p. 324) :

const updateStock = (state, {type, data}) => {
  if (type === GoodRegisteredEvent.type)
    return {...state, goodId: data.goodId,
      title: data.title, stock: 0};
  if (type === GoodStockedEvent.type)
    return {...state, stock: state.stock + data.units};
  if (type === GoodDestockedEvent.type)
    return {...state, stock: state.stock - data.units};
  return {...state};
};

Le livre note qu'elle ressemble à la fonction applyEvent du côté écriture (p. 324). C'est le même geste, au service de l'écran plutôt que des règles. Après 100 unités entrées et 95 sorties, la projection affiche cinq unités (p. 325). Ces données sont en cohérence à terme (idée 3), donc parfois en retard : le livre les réserve à l'information qui n'engage à rien, comme une disponibilité affichée sur une boutique (p. 323).

Où vit le résultat ? En mémoire, le modèle de lecture se perd au redémarrage et doit être reconstruit en rejouant les événements (p. 325). Pour un entrepôt de millions de produits, « recréer les modèles de lecture à chaque redémarrage n'est pas faisable » (p. 326). La version persistante range donc le stock et, à côté, le numéro du dernier événement traité, puis repart de là au redémarrage (p. 326). Ces deux écritures forment le cas de l'outbox (idée 5) : sans transaction commune, un événement peut revenir au redémarrage (p. 325).

Ma lecture

Sans transaction commune, le livre demande en général des transformations idempotentes (p. 325), mais updateStock additionne. Rejouée sur le même événement, elle compte deux fois les unités (idée 2 : mettre à 5, ou ajouter 5). Dans une base SQL, écrivez le stock et le numéro du dernier événement traité dans la même transaction. Ignorez ensuite tout événement dont le numéro est déjà passé.

Ma lecture

Une déduction, pas une phrase du livre. Une projection neuve, sans numéro enregistré, repart du tout premier événement (p. 326). Pour ajouter un écran imprévu, on écrit une projection de plus et on la laisse rattraper le passé.

À retenir : une vue de lecture n'est qu'un calcul sur les événements, fait à la demande quand on la lit rarement, d'avance quand on la lit souvent. Il reste deux questions plus larges : ce que cela change pour l'écran, et comment quitter les fichiers.

11Un écran pensé en tâches parle la langue des commandes (survol)

L'écran est le dernier maillon. Beaucoup d'interfaces d'administration sont des formulaires d'édition : on ouvre un enregistrement, on change des champs, on valide. Le livre appelle cela une interface CRUD (créer, lire, modifier, supprimer). Il lui oppose l'interface en tâches, où chaque interaction qui compte déclenche un cas d'usage précis (p. 364). Sur son blog d'exemple, publier et dépublier un article passent par un bouton qui envoie la commande PublishBlogPost ou UnpublishBlogPost (p. 368). Avec un simple formulaire, changer le statut « serait représenté comme une modification d'enregistrement générique, sans capturer correctement le sens métier » (p. 368).

Pour le délai de l'idée 7, le livre ajoute deux outils. L'interface optimiste affiche le succès avant la réponse du serveur, et propose de réessayer en cas d'échec (pp. 369-372). Son exemple est l'écriture d'un message dans un réseau social. Les modèles de lecture réactifs ne livrent pas seulement une donnée mais aussi ses mises à jour futures (p. 372). Ici, ils les poussent vers le navigateur par Server-Sent Events (SSE). C'est une connexion HTTP longue, à sens unique, qui se reconnecte seule (p. 374). Sur une liste de tâches partagée, le livre compare trois façons de choisir ce qu'on pousse au navigateur à chaque changement : les événements eux-mêmes, le modèle entier, ou la seule partie modifiée (pp. 381-387). Il conclut qu'« aucune ne peut être recommandée en général » (p. 388).

À retenir : un bouton par intention plutôt qu'un formulaire générique, et l'écran envoie directement les commandes du métier. Reste à brancher tout cela sur de vrais outils.

12Les briques maison se remplacent avec peu d'ajustements (survol)

Depuis l'introduction, tout repose sur des fichiers : peut-on mettre des fichiers JSON en production ? Le plus souvent non, répond le livre dans l'annexe B (p. 430). En production, « la plupart des fonctionnalités d'infrastructure devraient reposer sur des technologies existantes » (p. 430), branchées derrière des interfaces qui ressemblent à celles des versions sur fichiers (p. 430), comme le repository de l'idée 4.

Son application d'exemple est un tableau de tâches pour des équipes à distance. Elle passe à EventStoreDB pour les événements, Redis pour les modèles de lecture et RabbitMQ pour le bus. NGINX, en façade, envoie les requêtes d'écriture d'un côté et le reste de l'autre (pp. 433-443). Le tout tourne dans 14 conteneurs Docker, ces boîtes isolées qui emportent chacune un programme et ce dont il a besoin (p. 440). L'application compte trois contextes indépendants, au sens de l'idée 8. Chacun a quatre conteneurs (écriture, lecture, EventStoreDB, Redis), plus RabbitMQ et NGINX en commun. Le code existant ne demande que « des ajustements mineurs » (p. 440), surtout dans les handlers de commande et le publieur d'événements (p. 440). J'y lis le signe que les frontières des idées précédentes étaient au bon endroit. Le livre reste honnête : EventStoreDB n'a pas exactement la même interface que la version sur fichiers, ses versions commençant par exemple à zéro (p. 435). Il ajoute que PostgreSQL ou MongoDB conviennent aussi, car un Event Store a surtout besoin de transactions et d'une notification des changements (p. 433).

À retenir : les interfaces des idées 4 et 9 sont le point de remplacement, on change l'outil derrière et le métier reste.

Mon avis, honnêtement

Un livre d'atelier, pas un livre de vitrine, et il répond aux trois questions du début. Faut-il séparer l'écriture de la lecture, ou garder l'histoire ? Seulement là où cela paie, jamais par défaut. Comment empêcher qu'une sauvegarde et son message se désynchronisent ? Avec la boîte d'envoi de l'idée 5, et des abonnés qui supportent les doublons. Faut-il écrire ses briques soi-même ? Oui pour comprendre, puis un vrai produit prend le relais derrière les mêmes interfaces (idée 12).

Sa force : chaque brique est construite sous vos yeux, en peu de lignes, et l'auteur dit où elle casse. Il avoue le repository limité à une seule instance (idée 4) et l'outbox qui peut publier deux fois (idée 5). Le lecteur sait ainsi où renforcer le code avant la production. Quand on pratique déjà ces architectures, on voit vite ce qui passe en Go et ce qui reste propre au JavaScript. Les mécanismes passent tels quels. Les files d'attente en mémoire (idée 4) et Object.freeze (idée 1) sont du décor propre à Node.js.

Deux réserves. La prose est sèche, et chaque section change d'exemple : meetup, enchères, chansons, entrepôt, tickets. On réapprend un petit métier toutes les cinq pages, et la lecture d'une traite fatigue. Ensuite, la limite d'une seule instance par repository revient avec l'Event Store maison (pp. 178, 314). Avec plusieurs serveurs sur la même base, la garde contre les écrasements doit donc passer dans la base. On l'écrit soi-même, ou on la confie à un vrai Event Store comme dans l'annexe B. Les encadrés « Ma lecture » des idées 2, 4 et 9 en donnent les premiers pas.

Mon conseil : sautez les chapitres 1 à 5 du livre (leur numéro n'est pas celui des idées) si vous avez lu Evans ou Khononov, présentés plus bas. Lisez les chapitres 6 à 12 un clavier à portée de main, puis l'annexe B (la production, idée 12). Si vous visez plusieurs programmes, le chapitre 13 découpe l'application en programmes séparés qui communiquent entre eux. Sinon, survolez-le, et gardez le 14 (l'interface, idée 11) si vous touchez au front.

Odilon

Toujours valable en 2026 ?

Le fond n'a pas bougé : agrégats petits, version comparée à l'écriture, outbox, livraison au moins une fois et projections restent le quotidien de ces architectures. Les Server-Sent Events de l'idée 11 restent un standard du navigateur (objet EventSource). Ce qui a changé touche surtout l'annexe de production :

À qui s'adresse-t-il ?

Lisez-le si

  • vous rejoignez une équipe qui parle d'agrégats, de projections et d'outbox, et vous voulez voir ce que ces mots font vraiment
  • vous utilisez un framework d'Event Sourcing et voulez savoir ce qu'il fait à votre place
  • vous lisez du JavaScript sans peine, même si vous codez en PHP ou en Go
  • vous préférez taper du code pour comprendre plutôt que lire des diagrammes.

Passez si

  • vous cherchez la modélisation du métier avec les experts, plus que le code : Evans ou Khononov s'y consacrent
  • vous voulez une recette de production clé en main, avec base SQL et plusieurs serveurs
  • les longs listings sans types vous découragent.

Pour aller plus loin

Commentaires (0)

Dans le même rayon

DDD et Event Sourcing

Voir tout le rayon (8 fiches) →