📐 Construire une app en JS
35. Les événements de domaine

Les événements de domaine

Nous voulons écrire ceci :

await dependencies.bookings.save(booking);
return context.withEvent(BookingConfirmed(booking));

Et que les mails partent tout seuls, après.

Une commande, un fait

Commençons par le vocabulaire, parce qu'il porte toute la conception.

Une commande est une intention : réserve ce logement. Elle est à l'impératif, elle s'adresse à quelqu'un, et elle peut être refusée.

Un événement est un fait : ce logement a été réservé. Il est au passé, il ne s'adresse à personne, et il ne peut pas être refusé. C'est arrivé.

CommandeÉvénement
tempsimpératif — bookpassé — BookingConfirmed
destinataireun seulzéro, un ou dix
peut échouerouinon, c'est déjà fait
qui la nommel'expert métierl'expert métier

Cette dernière ligne est la plus importante. Un événement porte un nom métier, jamais technique.

BookingConfirmed : oui. BookingCancelled : oui. SendConfirmationEmail : non — c'est une commande déguisée, et elle décide à la place de ses lecteurs.

Le test qui ne trompe pas : lisez le nom à l'expert métier. S'il comprend, c'est un événement de domaine. S'il vous demande ce qu'est un email transactionnel, vous avez écrit du code technique dans le domaine.

Le contexte collecte

export class Context {
  events = [];
 
  withEvent(event) {
    this.events.push(event);
    return this;
  }
  // withError, withUser, withToken, withData, isOk...
}

Le contexte accumulait déjà l'utilisateur, l'erreur, le jeton, les données. Il accumule maintenant les faits.

Et l'événement lui-même est un objet plat, comme les notifications du chapitre 34 :

// domain/events.js
export function BookingConfirmed(booking) {
  return {
    type: "BookingConfirmed",
    payload: {
      bookingId: booking.id,
      tenantId: booking.tenantId,
      accommodationId: booking.accommodationId,
      from: booking.stay.from.toString(),
      to: booking.stay.to.toString(),
      guests: booking.guests.total,
    },
  };
}
 
export function BookingCancelled(booking) {
  return {
    type: "BookingCancelled",
    payload: {
      bookingId: booking.id,
      tenantId: booking.tenantId,
      accommodationId: booking.accommodationId,
      from: booking.stay.from.toString(),
      to: booking.stay.to.toString(),
    },
  };
}

Une question revient toujours : que met-on dans la charge utile ? Juste les identifiants, ou toutes les données ?

Les deux écoles existent. Voici le critère qui tranche : l'événement doit rester compréhensible sans la base de données.

Si l'abonné doit refaire trois requêtes pour savoir de quoi on parle, l'événement est trop maigre. S'il embarque l'entité complète avec ses vingt champs, il devient un couplage déguisé — le jour où un champ change, tous les abonnés en dépendent.

Mettez ce qui identifie, et ce qui décrit le fait. Ici : quelle réservation, quel logement, quelles dates. Pas l'email du vacancier — il peut changer entre l'émission et la lecture.

Et jamais d'objets du domaine. Un événement est fait pour voyager : en mémoire aujourd'hui, dans une file d'attente demain, dans un fichier de log toujours. Un CalendarDay ne survit pas à JSON.stringify — enfin si, grâce au toJSON du chapitre 21, mais il ne revient pas.

L'application publie

Où publier ? Une seule règle, et elle vaut tous les schémas :

On publie ce qui est acquis. Donc à la fin, quand plus rien ne peut annuler le fait.

export class App {
  constructor(dependencies, subscribers = {}) {
    this.dependencies = dependencies;
    this.subscribers = subscribers;
  }
 
  async run(usecases) {
    let context = new Context();
    for (const usecase of usecases) {
      if (context.isOk()) {
        context = await usecase(this.dependencies, context);
      }
    }
 
    // Rien n'est publié si le scénario a échoué.
    if (context.isOk()) {
      await this.publish(context.events);
    }
    return context;
  }
 
  async publish(events) {
    for (const event of events) {
      for (const subscriber of this.subscribers[event.type] ?? []) {
        try {
          await subscriber(event, this.dependencies);
        } catch (error) {
          // Un abonné qui échoue ne défait pas ce qui a été fait.
          this.dependencies.logger.error("subscriber failed", { event, error });
        }
      }
    }
  }
}

Trois décisions dans ces vingt lignes.

if (context.isOk()). Un scénario en échec ne publie rien. C'est le fusible du chapitre 13 qui rend un second service : il empêchait déjà d'exécuter les commandes suivantes, il empêche maintenant d'annoncer des faits qui ne se sont pas produits.

Le try/catch autour de chaque abonné. C'est le cœur du chapitre précédent. Le serveur de mail est tombé ? On le note, et la réservation reste confirmée. Sans ce catch, nous aurions simplement déplacé le problème de trois lignes.

Les abonnés sont injectés, pas importés. App ne connaît aucun abonné : elle reçoit une table. Le domaine ne sait donc toujours pas qu'un service de mail existe — il l'ignorait avant l'événement, il l'ignore après.

Les abonnés

Ils vivent avec l'infrastructure, là où on assemble l'application :

// infra/subscribers.js
import { BookingConfirmedMail, BookingCancelledMail } from "../domain/notifications";
 
export const subscribers = {
  BookingConfirmed: [notifyTenantAndHost],
  BookingCancelled: [notifyTenantAndHost, freeUpCalendarReminder],
};
 
async function notifyTenantAndHost(event, dependencies) {
  const { tenantId, accommodationId } = event.payload;
 
  const tenant = await dependencies.users.findById(tenantId);
  const accommodation = await dependencies.accommodations.findById(accommodationId);
  const host = await dependencies.users.findById(accommodation.hostId);
 
  await dependencies.notifications.send({ type: event.type, to: tenant.email, payload: event.payload });
  await dependencies.notifications.send({ type: event.type, to: host.email, payload: event.payload });
}

Un abonné a le droit de relire l'état du système — c'est même normal : l'événement porte des identifiants, pas des emails.

Notez qu'un même événement peut avoir plusieurs abonnés, et que le premier ne sait rien du second. C'est tout le bénéfice : ajouter "prévenir le service comptable" est une ligne dans cette table. Pas une ligne dans la commande book.

Et remarquez ce que devient le test du chapitre 34.

Rien. Il ne change pas. Il vérifie toujours que deux notifications sont parties avec les bonnes adresses. Nous avons déplacé la moitié du code, et le test est resté vert.

C'est la définition même d'un refactoring, et c'est la quatrième étape du cycle du chapitre 21 — rouge, vert, refactor. Un test qui décrit un comportement observable survit aux changements d'architecture. Un test qui décrit une implémentation les empêche.

Le problème que nous n'avons pas résolu

Soyons honnêtes sur ce que vaut notre publication.

Nous publions après la boucle, mais avant que quoi que ce soit ne soit garanti côté base de données. Le jour où save s'exécutera dans une transaction (chapitre 39), le schéma sera :

   commande → save (dans la transaction) → COMMIT → publication

Si le processus meurt entre le COMMIT et la publication, la réservation existe et personne n'est prévenu. Si l'on publie avant le COMMIT et que celui-ci échoue, on a prévenu tout le monde d'une réservation qui n'existe pas.

C'est le problème de la double écriture : deux systèmes à modifier, aucune transaction commune. Il n'a pas de solution parfaite, seulement un compromis bien connu — la boîte d'envoi (outbox).

L'idée tient en trois temps :

  1. La commande écrit la réservation et l'événement dans la même transaction, dans une table outbox. Une seule transaction, donc tout ou rien.
  2. Un processus séparé lit la table, publie les événements non traités, les marque comme publiés.
  3. En cas de panne, il reprend là où il s'était arrêté.

Le prix à payer, et il faut le connaître : un événement peut être publié deux fois. Le processus a publié, il meurt avant de marquer, il republie au redémarrage.

C'est ce qu'on appelle une garantie au moins une fois. La garantie exactement une fois n'existe pas, malgré ce que promettent certaines pages marketing.

Conséquence directe, et c'est la seule chose à retenir si vous ne retenez qu'une phrase de ce chapitre :

Un abonné doit pouvoir être rejoué sans dommage.

Envoyer deux fois le même mail est désagréable mais acceptable. Débiter deux fois une carte ne l'est pas. Chaque abonné qui touche à de l'argent, à un stock ou à un tiers doit vérifier s'il a déjà traité l'événement — ce qui suppose que l'événement ait un identifiant propre, stable au rejeu.

Ajoutons-le tout de suite, il ne coûte rien maintenant et coûtera cher plus tard :

export function BookingConfirmed(booking, eventId) {
  return { id: eventId, type: "BookingConfirmed", payload: { /* ... */ } };
}

Vous savez déjà d'où vient eventId : de dependencies.idProvider, comme tout ce qui est imprévisible depuis le chapitre 33.

Quand ne PAS faire d'événement

Terminons comme le chapitre 21, en refroidissant l'enthousiasme. Les événements sont grisants, et une architecture entièrement événementielle est très difficile à suivre.

Un événement n'est pas un moyen d'appeler une fonction. Si vous émettez BookingConfirmed pour qu'une seule fonction, connue, obligatoire, s'exécute juste après — appelez-la. Un événement ajoute une indirection : à la lecture de la commande, on ne sait plus ce qui va se passer. Ça vaut le coup quand il y a plusieurs lecteurs, ou quand le lecteur peut échouer sans conséquence. Pas autrement.

Un événement ne remplace pas un invariant. "Le logement doit être libre" ne peut pas être vérifié par un abonné : il faut refuser avant, dans la commande, de façon synchrone. Tout ce qui garantit l'intégrité reste dans la commande. Les événements servent à ce qui vient après la décision.

Un événement traverse rarement une frontière métier sans coût. Deux commandes qui se déclenchent en cascade par événements interposés, c'est une transaction distribuée que personne n'a conçue. Si A doit absolument entraîner B, écrivez-les dans le même scénario : app.run([a(), b()]). Vous avez un fusible et un contexte partagé, gratuitement.

Notre domaine sait maintenant décider, enregistrer et raconter. Il ne lui manque plus qu'une chose : que ce qu'il enregistre survive à un redémarrage.


Le code de cette étape est disponible ici (opens in a new tab).