CQRS en Go — Partie 1 : l'aggregate, Transition() et le Clone() qu'on oublie

Série CQRS en Go :

C'est quoi un aggregate

L'aggregate est le gardien de la cohérence métier. C'est lui qui dit "non" quand une opération est invalide : on ne peut pas annuler une commande déjà expédiée, on ne peut pas ajouter un article à un panier fermé. Cette logique vit dans l'aggregate, nulle part ailleurs.

En Event Sourcing, l'aggregate n'a pas d'état persisté directement. Son état est le résultat du replay de tous ses events depuis le début. Chaque event représente quelque chose qui s'est passé — immuable, factuel, dans le passé. L'aggregate les rejoue dans l'ordre et reconstruit son état courant.

Ce que l'aggregate ne fait pas : de l'I/O. Il ne lit pas en base, ne fait pas de requêtes HTTP, n'envoie pas d'emails. Il reçoit une command, valide si elle est applicable à son état actuel, et émet des events. C'est tout. Cette contrainte est ce qui le rend testable à coût zéro.

La structure en Go

Un aggregate Order en Go ressemble à ça :

type OrderStatus string

const (
    OrderStatusDraft     OrderStatus = "draft"
    OrderStatusPlaced    OrderStatus = "placed"
    OrderStatusShipped   OrderStatus = "shipped"
    OrderStatusCancelled OrderStatus = "cancelled"
)

type OrderItem struct {
    ProductID string
    Quantity  int
    UnitPrice int // en centimes
}

type Order struct {
    OrderID    uuid.UUID
    CustomerID uuid.UUID
    Status     OrderStatus
    Items      []OrderItem
    Total      int
    CreatedAt  time.Time
}

Deux choix délibérés ici. D'abord, struct en valeur, pas un pointeur. Quand on passe un Order, Go en fait une copie. C'est ce qu'on veut : deux versions d'un state ne se partagent pas de mémoire par défaut (sauf pour les slices et maps — on y revient). Un aggregate qui se passe par pointeur entre les mains de plusieurs goroutines, c'est une source de bugs muets.

Ensuite, les fields sont exportés ici pour simplifier l'exemple, mais en production on les garde privés au package et on expose uniquement ce qui est nécessaire via des méthodes. L'aggregate n'est pas un DTO.

Les events comme source de vérité

Les events sont les faits qui ont eu lieu. Ils ne décrivent pas une intention — ça c'est le rôle des commands — ils décrivent ce qui s'est passé.

type OrderPlaced struct {
    OrderID    uuid.UUID
    CustomerID uuid.UUID
    Items      []OrderItem
    Total      int
    PlacedAt   time.Time
}

type OrderItemAdded struct {
    OrderID  uuid.UUID
    Item     OrderItem
    NewTotal int
}

type OrderCancelled struct {
    OrderID     uuid.UUID
    Reason      string
    CancelledAt time.Time
}

Une fois émis, un event ne change jamais. On ne corrige pas un event passé — on émet un nouvel event qui compense. C'est la différence fondamentale avec un UPDATE en base.

L'aggregate déclare les types d'events qu'il gère via EventKinds(). C'est utile pour l'event store qui a besoin de savoir comment désérialiser les events stockés :

func (o Order) EventKinds() []DomainEvent {
    return []DomainEvent{
        OrderPlaced{},
        OrderItemAdded{},
        OrderCancelled{},
    }
}

Transition() — le coeur du pattern

Transition() est la méthode qui applique un event sur le state courant et retourne un nouveau state. C'est le seul endroit où l'état de l'aggregate évolue.

func (o Order) Transition(event DomainEvent) Order {
    switch e := event.(type) {
    case OrderPlaced:
        o.OrderID = e.OrderID
        o.CustomerID = e.CustomerID
        o.Status = OrderStatusPlaced
        o.Items = e.Items
        o.Total = e.Total
        o.CreatedAt = e.PlacedAt

    case OrderItemAdded:
        o.Items = append(o.Items, e.Item)
        o.Total = e.NewTotal

    case OrderCancelled:
        o.Status = OrderStatusCancelled
    }
    return o
}

Le receiver est une valeur, pas un pointeur. Quand Go appelle cette méthode, il copie l'Order courant dans o. On modifie cette copie, on la retourne. Si on ignore la valeur de retour, rien n'a changé — l'Order original est intact.

C'est un réducteur pur, exactement comme Array.prototype.reduce en JavaScript ou fold en Haskell. L'état courant est une fonction déterministe de tous les events passés :

state = events.reduce(initialState, transition)

La conséquence pratique : on peut reconstruire l'état de l'aggregate à n'importe quel moment historique en rejouant les events jusqu'à ce point. Pour débugger un bug survenu le 14 février à 14h37, on rejoue les events jusqu'à ce timestamp et on inspecte le state. Pas de logs à fouiller, pas de snapshots à restaurer.

Le piège du Clone() — le bug silencieux

C'est là que la plupart des implémentations CQRS en Go tombent. Et c'est silencieux : pas de panic, pas d'erreur, juste des données corrompues.

Le problème vient du modèle mémoire des slices en Go. Un slice est une structure à trois champs : un pointeur vers le backing array, une longueur, et une capacité. Quand Go copie un struct contenant un slice, il copie ces trois champs — mais pas le backing array. Les deux copies pointent vers le même tableau en mémoire.

append est particulièrement traître. Si la capacité du slice est suffisante pour accueillir le nouvel élément, append écrit directement dans le backing array existant sans en allouer un nouveau. Résultat : l'ancien state est modifié à l'insu du code.

package main

import "fmt"

type OrderItem struct {
    ProductID string
    Quantity  int
}

type Order struct {
    Items []OrderItem
}

func main() {
    // Slice avec capacité 4, longueur 1 — assez de place pour append sans réallocation
    original := Order{
        Items: make([]OrderItem, 1, 4),
    }
    original.Items[0] = OrderItem{ProductID: "A", Quantity: 1}

    // Simuler un Transition naif : copie du struct, append sur la copie
    modified := original
    modified.Items = append(modified.Items, OrderItem{ProductID: "B", Quantity: 2})

    // Le piège : append a écrit dans le backing array partagé.
    // original.Items pointe toujours sur len=1, mais l'index [1] en mémoire
    // contient maintenant le produit "B". Reslice et il réapparait.
    fmt.Println("original length:", len(original.Items))  // 1 — rassurant
    fmt.Println("modified length:", len(modified.Items))  // 2

    // Mais :
    ghost := original.Items[:2] // Reslice dans la capacité existante
    fmt.Println("ghost item:", ghost[1].ProductID) // "B" — il est là
}

En pratique dans un système CQRS, ce scénario se produit dès qu'on stocke un snapshot ou qu'on passe un state entre goroutines. L'ancien state qu'on croyait immuable a été silencieusement modifié.

La solution est Clone(). Avant toute Transition(), on clone explicitement le state pour couper le lien avec le backing array :

func (o Order) Clone() Order {
    c := o
    if o.Items != nil {
        c.Items = make([]OrderItem, len(o.Items))
        copy(c.Items, o.Items)
    }
    return c
}

copy alloue un nouveau backing array et y copie les éléments. Après ça, c.Items et o.Items sont complètement indépendants. N'importe quel append ultérieur sur l'un ne touche pas l'autre.

Même contrainte pour les maps. Go copie la référence, pas le contenu. Un Cart avec une map nécessite un deep clone explicite :

type Cart struct {
    Items map[uuid.UUID]CartItem
}

func (c Cart) Clone() Cart {
    clone := c
    clone.Items = make(map[uuid.UUID]CartItem, len(c.Items))
    for k, v := range c.Items {
        clone.Items[k] = v
    }
    return clone
}

Si CartItem lui-même contient des slices ou maps, il faut descendre encore d'un niveau. C'est le seul cas où la profondeur du clone dépend de la structure réelle des données — il n'y a pas de raccourci générique sûr en Go.

La règle en production : toujours appeler Clone() avant Transition(). On peut aussi l'intégrer directement dans Transition() comme première instruction, ce qui rend le pattern infaillible au prix d'une copie systématique même quand elle n'est pas nécessaire. Pour la plupart des aggregates, ce coût est négligeable.

Replay — reconstruire le state depuis les events

Avec Clone() et Transition() en place, le replay s'écrit en cinq lignes :

func Replay(events []DomainEvent) Order {
    var state Order
    for _, event := range events {
        state = state.Clone().Transition(event)
    }
    return state
}

On part d'un Order vide, on lui applique chaque event dans l'ordre, et on récupère l'état final. La preuve que l'aggregate est pur : cette fonction est déterministe. Même liste d'events, même résultat, toujours. Pas d'appel réseau, pas de cache, pas d'horloge système. On peut l'appeler depuis un test sans aucun mock.

Le replay est aussi ce qui permet de corriger des bugs a posteriori. Si la logique dans Transition() était incorrecte et qu'on la corrige, on peut rejouer tous les events historiques et obtenir des états corrigés — à condition que les events eux-mêmes soient justes, ce qu'ils sont par définition puisqu'ils représentent ce qui s'est réellement passé.

Résumé

  • L'aggregate est le gardien de la cohérence métier. Il ne fait pas d'I/O.
  • Son état est une fonction pure des events qu'il a reçus depuis sa création.
  • Transition(event) applique un event et retourne un nouveau state — méthode sur valeur, pas sur pointeur.
  • Go copie les structs par valeur, mais les slices et maps partagent leur backing store. Sans Clone(), les mutations via append peuvent corrompre silencieusement d'anciens states.
  • Clone() doit faire un deep copy de toutes les collections dans le struct.
  • Le replay reconstruit n'importe quel état historique en rejouant les events — sans I/O, donc testable de façon triviale.

La partie 2 couvre les command handlers : comment valider une command contre l'état courant de l'aggregate, émettre les events appropriés, et garder tout ça sans effet de bord ni dépendance implicite.

📄 CLAUDE.md associé

Commentaires (0)