Événements de domaine

VoletTactique Le mécanisme qui rend possible la quatrième règle des agrégats — et, plus largement, le découplage entre contextes.

Un événement de domaine est un fait métier significatif qui s’est produit. Trois caractéristiques, toutes conséquentes :

Il est au passé. ReservationConfirmee, PaiementRefuse, ColisExpedie. Un nom au présent ou à l’impératif — ConfirmerReservation — désigne une commande, c’est-à-dire une intention qui peut échouer. Un événement, lui, est déjà arrivé : on ne le refuse pas, on en tient compte.

Il est immuable. Le passé ne se modifie pas. Une erreur se corrige par un nouvel événement — ReservationAnnulee — jamais en réécrivant l’ancien.

Il porte les faits, pas des références à résoudre plus tard. L’identifiant de la réservation, le montant, l’instant. Un consommateur doit pouvoir décider sans rappeler l’émetteur.

Pourquoi ils changent tout

Trois bénéfices, du plus local au plus structurant.

Ils rendent explicite ce qui était implicite. Dans un modèle sans événements, le fait qu’une confirmation crédite des points de fidélité est enfoui dans un service. Avec un événement, le fait « une réservation a été confirmée » existe comme concept à part entière, et les réactions qu’il déclenche se lisent séparément.

Ils réalisent la cohérence différée. C’est le mécanisme concret de la règle « une transaction, un agrégat ». L’agrégat modifié enregistre un événement ; un autre morceau du système met à jour les autres agrégats ensuite.

Ils découplent les contextes. Un contexte publie ce qui s’est passé chez lui, sans savoir qui écoute. Le contexte Fidélité peut apparaître, disparaître ou changer de modèle sans que Réservation en sache quoi que ce soit — un renversement total par rapport à un appel direct.

Événement de domaine et événement d’intégration

Distinction essentielle, et rarement faite explicitement. Les confondre produit soit un couplage inter-contextes par les entrailles du modèle, soit une inflation de messages sur le bus.

Événement de domaineÉvénement d’intégration
PortéeInterne à un contextePublié vers l’extérieur
ContenuRiche, avec les objets du domainePlat, types primitifs, sérialisable
ContratLibre, refactorable à volontéStable et versionné
TransportEn mémoire, dans le processusBus de messages, file
NommageLe langage du contexteLe langage publié
ExempleReservationConfirmee(Reservation, Montant)reservation.confirmee.v1 {id, clientId, montantCentimes}

La règle qui en découle : un événement de domaine ne sort jamais du contexte tel quel. Il est traduit en événement d’intégration à la frontière, exactement comme une couche d’anticorruption traduit dans l’autre sens. Sans cette traduction, tout renommage interne casse les consommateurs, et le modèle se fossilise.

Émettre, collecter, publier

L’agrégat enregistre ses événements sans les publier — il n’a pas à connaître de bus. Le cas d’usage les récupère et les publie après validation de la transaction : publier avant reviendrait à annoncer un fait qui pourrait ne jamais avoir eu lieu.

​
// ---- L'événement : immuable, au passé ----
export class ReservationConfirmee {
  readonly survenuLe = Instant.maintenant();

  constructor(
    readonly reservationId: ReservationId,
    readonly clientId: ClientId,
    readonly montant: Montant,
  ) {}
}

// ---- L'agrégat enregistre, ne publie pas ----
export class Reservation {
  private readonly evenements: EvenementDomaine[] = [];

  confirmer(): void {
    if (this.etat !== EtatReservation.Provisoire) throw new TransitionInterdite();
    this.etat = EtatReservation.Confirmee;
    this.evenements.push(
      new ReservationConfirmee(this.id, this.clientId, this.total()),
    );
  }

  // Prélève et vide : un événement n'est publié qu'une fois
  prelverEvenements(): EvenementDomaine[] {
    return this.evenements.splice(0);
  }
}

// ---- Le cas d'usage publie APRÈS la transaction ----
export class ConfirmerReservation {
  constructor(
    private readonly reservations: ReservationRepository,
    private readonly transaction: UniteDeTravail,
    private readonly bus: BusEvenements,
  ) {}

  async executer(id: ReservationId): Promise<void> {
    const reservation = await this.reservations.parId(id);
    if (!reservation) throw new ReservationInconnue(id);

    reservation.confirmer();

    const evenements = await this.transaction.executer(async () => {
      await this.reservations.enregistrer(reservation);
      return reservation.prelverEvenements();
    });

    await this.bus.publier(evenements);        // Après le commit, jamais avant
  }
}
# ---- L'événement : immuable, au passé ----
from dataclasses import dataclass, field


@dataclass(frozen=True)
class ReservationConfirmee:
    reservation_id: ReservationId
    client_id: ClientId
    montant: Montant
    survenu_le: Instant = field(default_factory=Instant.maintenant)


# ---- L'agrégat enregistre, ne publie pas ----
class Reservation:
    def confirmer(self) -> None:
        if self._etat is not EtatReservation.PROVISOIRE:
            raise TransitionInterdite
        self._etat = EtatReservation.CONFIRMEE
        self._evenements.append(
            ReservationConfirmee(self.id, self.client_id, self.total())
        )

    def prelever_evenements(self) -> list[EvenementDomaine]:
        """Prélève et vide : un événement n'est publié qu'une fois."""
        evenements, self._evenements = self._evenements, []
        return evenements


# ---- Le cas d'usage publie APRÈS la transaction ----
class ConfirmerReservation:
    def __init__(
        self,
        reservations: ReservationRepository,
        transaction: UniteDeTravail,
        bus: BusEvenements,
    ) -> None:
        self._reservations = reservations
        self._transaction = transaction
        self._bus = bus

    def executer(self, id: ReservationId) -> None:
        reservation = self._reservations.par_id(id)
        if reservation is None:
            raise ReservationInconnue(id)

        reservation.confirmer()

        with self._transaction:
            self._reservations.enregistrer(reservation)
            evenements = reservation.prelever_evenements()

        self._bus.publier(evenements)        # Après le commit, jamais avant
// ---- L'événement : immuable, au passé ----
#[derive(Clone)]
pub enum EvenementDomaine {
    ReservationConfirmee {
        reservation_id: ReservationId,
        client_id: ClientId,
        montant: Montant,
        survenu_le: Instant,
    },
    ReservationAnnulee { reservation_id: ReservationId, motif: Motif },
}

// ---- L'agrégat enregistre, ne publie pas ----
impl Reservation {
    pub fn confirmer(&mut self) -> Result<(), ErreurReservation> {
        if self.etat != EtatReservation::Provisoire {
            return Err(ErreurReservation::TransitionInterdite);
        }
        self.etat = EtatReservation::Confirmee;
        self.evenements.push(EvenementDomaine::ReservationConfirmee {
            reservation_id: self.id,
            client_id: self.client_id,
            montant: self.total(),
            survenu_le: Instant::maintenant(),
        });
        Ok(())
    }

    /// Prélève et vide : un événement n'est publié qu'une fois.
    pub fn prelever_evenements(&mut self) -> Vec<EvenementDomaine> {
        std::mem::take(&mut self.evenements)
    }
}

// ---- Le cas d'usage publie APRÈS la transaction ----
impl<R: ReservationRepository, B: BusEvenements> ConfirmerReservation<R, B> {
    pub fn executer(&self, id: ReservationId) -> Result<(), Erreur> {
        let mut reservation = self.reservations
            .par_id(id)?
            .ok_or(Erreur::ReservationInconnue(id))?;

        reservation.confirmer()?;

        let evenements = self.transaction.executer(|| {
            self.reservations.enregistrer(&reservation)?;
            Ok(reservation.prelever_evenements())
        })?;

        self.bus.publier(&evenements)      // Après le commit, jamais avant
    }
}
Tip

prelever — et non lire — n’est pas un détail de nommage. Une méthode qui retourne la liste sans la vider laisse la porte ouverte à une double publication, et la double publication est le bug le plus pénible de cette famille : le client est crédité deux fois, et personne ne comprend pourquoi.

Le problème de la double écriture

Voici le piège que presque tout le monde rencontre, et rarement avant la production.

Le cas d’usage ci-dessus fait deux écritures dans deux systèmes différents : la base de données, puis le bus de messages. Ces deux écritures ne sont pas atomiques. Trois issues possibles :

  • tout se passe bien ;
  • la base est validée, puis le processus meurt avant la publication → l’événement est perdu, le client ne sera jamais crédité, et aucune trace n’existe ;
  • l’inverse, si l’on publie d’abord → un événement annonce un fait qui n’a pas eu lieu.

Aucun ordre des deux opérations ne résout le problème. La solution standard est le patron outbox : on écrit l’événement dans une table de la même base, dans la même transaction que l’agrégat. L’atomicité est alors garantie par la base. Un processus séparé lit ensuite cette table et publie sur le bus.

   Transaction unique (base de données)
   ┌────────────────────────────────────────────┐
   │  UPDATE reservations SET etat = 'confirmee' │
   │  INSERT INTO outbox (evenement, publie=false)│
   └────────────────────────────────────────────┘
                     │
                     │  commit
                     ▼
   ┌──────────────────────────────────────────────┐
   │  Publieur : lit l'outbox, publie sur le bus, │
   │  marque publie=true. Rejoue en cas d'échec.  │
   └──────────────────────────────────────────────┘

La conséquence à accepter est que la livraison devient au moins une fois : en cas de panne entre la publication et le marquage, l’événement est republié. Il n’y a pas de « exactement une fois » dans un système distribué, et prétendre le contraire coûte plus cher que de l’admettre.

D’où l’obligation symétrique côté consommateur : être idempotent. Le moyen le plus simple est de faire porter un identifiant à chaque événement et de tenir une table des identifiants déjà traités — la règle devenant « si je l’ai déjà vu, je n’en tiens pas compte ».

Warning

Une conséquence souvent découverte trop tard : les événements peuvent arriver dans le désordre. Un consommateur qui suppose que ReservationConfirmee précède toujours ReservationAnnulee se trompera. Soit on ordonne par clé de partition — tous les événements d’une même réservation sur la même partition — soit on rend les gestionnaires insensibles à l’ordre, ce qui est plus robuste et se conçoit dès le départ.

Ce que les événements ne sont pas

Ce n’est pas de l’event sourcing. Confusion très fréquente. Publier des événements de domaine consiste à notifier des faits, tout en conservant l’état courant dans une table classique. L’event sourcing est une décision bien plus lourde : le flux d’événements devient la source de vérité, l’état n’est plus stocké mais reconstruit par rejeu. Les deux se combinent, mais l’un ne requiert absolument pas l’autre — et adopter les événements de domaine est infiniment moins engageant.

Ce n’est pas un remplaçant des appels synchrones. Une décision qui a besoin d’une réponse immédiate — autoriser un paiement, vérifier une disponibilité — n’est pas un événement. Tout passer en asynchrone par principe transforme un enchaînement lisible en une chorégraphie que personne ne peut suivre.

Ce n’est pas un journal technique. CacheInvalide ou LigneMiseAJour ne sont pas des événements de domaine : personne dans le métier ne les nommerait. Le test est le même que pour le reste du modèle — l’expert reconnaît-il le nom ?

Références