CQRS en Go — Partie 3 : sagas et chorégraphie par events

Série CQRS en Go :

Le problème — deux aggregates doivent coopérer

Scénario concret : un paiement est validé. L'aggregate Payment émet un event PaymentConfirmed. En réponse à ça, il faut créer une expédition dans l'aggregate Shipping.

Le problème : les deux aggregates ne se connaissent pas, et c'est intentionnel. Un aggregate qui appelle directement un autre aggregate, c'est une violation fondamentale du pattern. L'aggregate est une unité de cohérence isolée. Si Payment connaît Shipping, on recrée du couplage fort — exactement ce qu'on essayait d'éviter en quittant le modèle transactionnel classique.

La question est donc : comment faire réagir Shipping à quelque chose qui s'est passé dans Payment, sans que l'un connaisse l'autre ? Il y a deux familles de réponse : l'orchestration et la chorégraphie.

Orchestration vs chorégraphie

L'orchestration met en place un chef d'orchestre central — un SagaManager — qui connaît toutes les étapes, toutes les transitions, et tous les rollbacks possibles. Quand PaymentConfirmed arrive, c'est le SagaManager qui appelle explicitement le service Shipping, puis le service Email, puis le service Stock. Le flux est lisible en un seul endroit. En cas d'erreur, le manager sait exactement quoi compenser.

L'inconvénient : le SagaManager connaît tout le monde. Ajouter une nouvelle étape dans le flux force à modifier ce composant central. Il devient un point de couplage fort entre des domaines qui devraient s'ignorer.

La chorégraphie fonctionne autrement. Chaque aggregate émet des events. Des handlers indépendants écoutent ces events et déclenchent des commands sur d'autres aggregates. Personne ne centralise la connaissance du flux — il émerge de la somme des réactions.

La chorégraphie gagne dans la plupart des cas parce qu'elle scale mieux, qu'ajouter un nouveau comportement sur un event existant ne touche pas au code existant, et que chaque handler peut évoluer, être redéployé, voire tomber sans bloquer les autres. Le prix à payer : le flux n'est pas lisible en un seul endroit — on y revient en section 6.

L'event handler — le lien entre aggregates

L'outil central de la chorégraphie est l'event handler. C'est lui qui écoute les events d'un aggregate et déclenche des commands sur un autre. Sa responsabilité est étroite : traduire un event en command. Rien de plus.

// PaymentEventHandler réagit aux events Payment pour déclencher Shipping.
type PaymentEventHandler struct {
    shippingCmd ShippingCommandService
}

func (h PaymentEventHandler) Handle(ctx context.Context, event DomainEvent) error {
    switch e := event.(type) {
    case PaymentConfirmed:
        return h.shippingCmd.CreateShipment(ctx, CreateShipmentCmd{
            OrderID:    e.OrderID,
            CustomerID: e.CustomerID,
            Items:      e.Items,
            Address:    e.ShippingAddress,
        })
    }
    return nil
}

L'event handler ne contient pas de logique métier. Il ne décide pas si l'expédition doit être créée — cette décision a déjà été prise par le fait que PaymentConfirmed a été émis. Il ne valide pas les données — c'est le command handler de Shipping qui s'en charge. Il traduit, c'est tout.

Si la logique dans un event handler commence à grossir — conditions, branches, appels multiples — c'est un signal qu'il manque un aggregate quelque part, ou qu'une responsabilité n'a pas été bien découpée.

L'idempotence des sagas

Un event peut être redelivré. Ça arrive à chaque fois qu'un handler traite un event avec succès mais crashe avant d'acquitter le message. Le broker (Kafka, RabbitMQ, ou même une simple table de queue en PostgreSQL) ne sait pas que le traitement a réussi — il redélivre. Si la saga n'est pas idempotente, on crée deux expéditions pour une seule commande.

La solution la plus simple : vérifier avant d'agir. Si l'expédition existe déjà pour cet OrderID, on considère que c'est un doublon et on ignore.

func (h PaymentEventHandler) Handle(ctx context.Context, event DomainEvent) error {
    switch e := event.(type) {
    case PaymentConfirmed:
        err := h.shippingCmd.CreateShipment(ctx, CreateShipmentCmd{
            OrderID:    e.OrderID,
            CustomerID: e.CustomerID,
            Items:      e.Items,
            Address:    e.ShippingAddress,
        })
        // Si l'expédition existe déjà, c'est un doublon — on ignore.
        if errors.Is(err, ErrShipmentAlreadyExists) {
            return nil
        }
        return err
    }
    return nil
}

Cette erreur ErrShipmentAlreadyExists doit être une sentinel error définie dans le package Shipping, et le command handler de CreateShipment doit vérifier l'unicité sur OrderID avant d'insérer — idéalement avec une contrainte unique en base pour rendre le contrôle atomique.

L'autre approche est l'idempotency key : on passe l'ID de l'event comme clé d'idempotence au command handler, qui le stocke et rejette silencieusement tout doublon avec la même clé. C'est plus générique mais nécessite une table de suivi dédiée. Les deux approches sont valides ; le choix dépend souvent de si le command handler est déjà instrumenté pour les idempotency keys (voir partie 2 de la série).

Les erreurs partielles

Un event peut déclencher plusieurs réactions indépendantes : créer l'expédition, envoyer l'email de confirmation, décrémenter le stock. Si toutes ces réactions sont dans un seul handler et que l'email échoue, l'expédition est créée mais le stock n'est jamais mis à jour. Le handler a échoué en cours de route et l'état final est incohérent.

// Mauvais : un gros handler qui fait tout séquentiellement.
func (h BigHandler) Handle(ctx context.Context, event DomainEvent) error {
    switch e := event.(type) {
    case PaymentConfirmed:
        if err := h.shipping.Create(ctx, toShipmentCmd(e)); err != nil {
            return err // l'email et le stock ne seront jamais traités
        }
        if err := h.email.Send(ctx, toConfirmationEmail(e)); err != nil {
            return err // le stock ne sera jamais traité
        }
        if err := h.stock.Update(ctx, toStockCmd(e)); err != nil {
            return err
        }
    }
    return nil
}

La solution est de ne jamais mettre plusieurs responsabilités dans le même handler. Un handler, une action.

// Un handler par responsabilité. Chacun peut échouer indépendamment.

type ShippingOnPayment struct {
    shippingCmd ShippingCommandService
}

func (h ShippingOnPayment) Handle(ctx context.Context, event DomainEvent) error {
    if e, ok := event.(PaymentConfirmed); ok {
        return h.shippingCmd.CreateShipment(ctx, CreateShipmentCmd{
            OrderID: e.OrderID,
            Items:   e.Items,
            Address: e.ShippingAddress,
        })
    }
    return nil
}

type EmailOnPayment struct {
    mailer EmailService
}

func (h EmailOnPayment) Handle(ctx context.Context, event DomainEvent) error {
    if e, ok := event.(PaymentConfirmed); ok {
        return h.mailer.SendConfirmation(ctx, e.CustomerEmail, e.OrderID)
    }
    return nil
}

type StockOnPayment struct {
    stockCmd StockCommandService
}

func (h StockOnPayment) Handle(ctx context.Context, event DomainEvent) error {
    if e, ok := event.(PaymentConfirmed); ok {
        return h.stockCmd.DecrementStock(ctx, DecrementStockCmd{
            Items: e.Items,
        })
    }
    return nil
}

Ces trois handlers sont enregistrés séparément sur l'event PaymentConfirmed. Si l'email échoue, il sera retried indépendamment — sans bloquer l'expédition ni la mise à jour du stock. L'échec d'une branche n'empoisonne pas les autres.

Le retry avec backoff exponentiel est géré par l'infrastructure (broker, worker), pas par le handler. Le handler a une seule responsabilité : essayer de faire son action et retourner une erreur si elle échoue. C'est le système qui décide combien de fois retenter.

L'eventual consistency est une conséquence acceptée. Après un crash, le stock sera mis à jour au prochain retry — dans quelques secondes ou quelques minutes selon la politique de retry. Pour la plupart des cas e-commerce, c'est acceptable. Si ce n'est pas acceptable, il faut une transaction distribuée, et on quitte le périmètre de la chorégraphie.

Tracer le flux — le tableau event/handler

En chorégraphie, le flux n'est lisible dans aucun fichier unique. Il est distribué entre des dizaines de handlers. Sans documentation, il est impossible de savoir ce qui se déclenche quand PaymentConfirmed est émis. C'est le coût de la chorégraphie.

La réponse pratique est un tableau de mapping, maintenu à la main ou généré depuis les registrations de handlers. Pour chaque event, on liste les handlers et l'action qu'ils effectuent. C'est la carte du système.

Event Handler Action
PaymentConfirmed ShippingOnPayment Crée l'expédition
PaymentConfirmed EmailOnPayment Email de confirmation commande
PaymentConfirmed StockOnPayment Décrémente le stock
ShipmentCreated EmailOnShipment Email "votre colis est parti"
ShipmentCreated TrackingOnShipment Crée le suivi de livraison

Ce tableau devrait vivre dans la documentation du projet, pas dans un commentaire au fond d'un fichier. Il répond à la question "qu'est-ce qui se passe quand cet event est émis ?" — question qu'on pose souvent en production quand quelque chose cloche.

En Go, on peut aussi centraliser les enregistrements de handlers dans un fichier wire.go ou handlers.go par domaine, ce qui rend le mapping lisible dans le code :

func registerPaymentHandlers(bus EventBus, deps Dependencies) {
    bus.Register(PaymentConfirmed{}, ShippingOnPayment{shippingCmd: deps.ShippingCmd})
    bus.Register(PaymentConfirmed{}, EmailOnPayment{mailer: deps.Mailer})
    bus.Register(PaymentConfirmed{}, StockOnPayment{stockCmd: deps.StockCmd})
}

Tous les handlers liés à PaymentConfirmed sont visibles dans une seule fonction. Pas besoin de grepper tout le codebase pour trouver ce qui réagit à cet event.

Quand la chorégraphie ne suffit plus

La chorégraphie a une limite : les flux avec compensation. Compensation = annuler une étape déjà réalisée parce qu'une étape ultérieure a échoué. L'exemple classique : réserver un hôtel, un vol, et une voiture de location pour un voyage. Si le vol est indisponible, il faut annuler la réservation d'hôtel qui a réussi avant.

En chorégraphie pure, ce scénario force à émettre un event FlightBookingFailed et à avoir un handler qui annule l'hôtel. Ça fonctionne, mais le handler doit savoir si l'hôtel a été réservé pour ce voyage — ce qui implique un état partagé. On reconstruit un orchestrateur, mais de façon implicite et éparpillée.

Dans ce cas, un process manager est justifié. Un process manager est un event handler avec un état propre. Il écoute les events des différentes étapes, maintient l'état d'avancement, et décide des compensations si nécessaire.

type BookingProcessManager struct {
    bookingID uuid.UUID
    hotelOK   bool
    flightOK  bool
    carOK     bool
    hotelCmd  HotelCommandService
}

func (pm *BookingProcessManager) Handle(ctx context.Context, event DomainEvent) error {
    switch e := event.(type) {
    case HotelReserved:
        pm.hotelOK = true
        // Déclencher la réservation de vol
        return pm.flightCmd.BookFlight(ctx, BookFlightCmd{
            BookingID: pm.bookingID,
            FlightRef: e.FlightRef,
        })

    case FlightBooked:
        pm.flightOK = true
        // Déclencher la réservation de voiture
        return pm.carCmd.ReserveCar(ctx, ReserveCarCmd{
            BookingID: pm.bookingID,
        })

    case FlightBookingFailed:
        // Compensation : annuler l'hôtel si déjà réservé
        if pm.hotelOK {
            return pm.hotelCmd.CancelReservation(ctx, CancelHotelCmd{
                BookingID: pm.bookingID,
            })
        }
    }
    return nil
}

La différence avec un SagaManager global : ce process manager ne connaît que le flux de réservation de voyage. Il n'est pas un point de centralisation pour tous les flux du système. Un process manager par saga, pas un manager pour tout.

Son état doit être persisté — en base ou dans l'event store — pour survivre aux redémarrages. C'est le seul endroit dans une architecture chorégraphiée où on accepte explicitement d'avoir un composant avec état propre et logique de flux centralisée. Le reste du système continue de fonctionner par chorégraphie.

Résumé

  • Un aggregate ne peut pas appeler un autre aggregate directement — la chorégraphie par events est la solution standard.
  • L'event handler est un traducteur : il transforme un event en command sur un autre aggregate. Pas de logique métier dedans.
  • Les sagas doivent être idempotentes : un event peut être redelivré, le handler doit détecter et ignorer les doublons.
  • Un handler par responsabilité : ne jamais mettre plusieurs actions indépendantes dans un seul handler. Un échec partiel ne doit pas bloquer les autres branches.
  • L'eventual consistency est une conséquence assumée : une branche qui échoue sera retried indépendamment, sans bloquer les autres.
  • Le tableau event/handler est la documentation indispensable d'un système chorégraphié. Sans lui, le flux est illisible.
  • Pour les flux avec compensation, un process manager avec état propre est justifié — mais un par saga, pas un gestionnaire global.

La partie 4 couvre l'event store PostgreSQL : comment persister les events, gérer le versioning des aggregates, et implémenter le replay depuis la base.

📄 CLAUDE.md associé

Commentaires (0)