CQRS en Go — Partie 2 : command handlers sans effet de bord

Série CQRS en Go :

Si vous avez suivi la partie 1, vous savez ce qu'est un aggregate et comment Transition() reconstruit l'état à partir des events. Maintenant, une question plus concrète : qui décide d'émettre ces events ? Qui valide qu'une commande est légitime ? C'est le rôle du command handler.

Et c'est là que beaucoup de projets CQRS dérivent. Le handler commence propre, puis accumule des dépendances. Un repo par-ci, un service email par-là. Trois mois plus tard, vous avez un test qui démarre une base de données pour vérifier qu'un champ obligatoire est bien requis.

Il y a une meilleure façon.

Le problème avec les handlers classiques

Voici ce à quoi ressemble un handler "standard" — celui qu'on écrit quand on applique CQRS sans pousser le raisonnement jusqu'au bout :

// ❌ Handler classique — dépend de tout
func (h *OrderHandler) PlaceOrder(ctx context.Context, cmd PlaceOrderCmd) error {
    customer, err := h.customerRepo.Get(ctx, cmd.CustomerID)
    if err != nil {
        return err
    }

    order := Order{
        ID:         uuid.New(),
        CustomerID: customer.ID,
        Items:      cmd.Items,
    }

    if err := h.orderRepo.Save(ctx, order); err != nil {
        return err
    }
    if err := h.emailService.Send(customer.Email, "Order placed"); err != nil {
        return err
    }

    return nil
}

Pour tester cette fonction, il vous faut : un mock customerRepo, un mock orderRepo, un mock emailService. Trois mocks pour un seul test. Et chaque test devient un exercice de câblage — vous passez plus de temps à configurer l'environnement qu'à tester la logique métier.

Le pire : la logique métier réelle tient en cinq lignes. Le reste, c'est de l'I/O. Et c'est précisément ça, le problème : I/O et logique métier sont mélangés.

La signature magique

La solution tient dans une signature :

Handle(ctx context.Context, state Order, cmd PlaceOrderCmd) (Events, error)

Trois choses importantes dans cette signature :

  • Le handler reçoit le state actuel de l'aggregate — pas un repository, pas un accès DB. L'état est déjà reconstruit quand le handler est appelé.
  • Il retourne des events — pas un état modifié, pas un booléen. Des faits : "voici ce qui s'est passé".
  • Zéro I/O, zéro side effect. Le handler ne sait même pas que PostgreSQL existe.

Voici l'implémentation complète :

type PlaceOrderCmd struct {
    OrderID    uuid.UUID
    CustomerID uuid.UUID
    Items      []OrderItem
}

func (cmd PlaceOrderCmd) Validate() error {
    if cmd.OrderID == uuid.Nil {
        return fmt.Errorf("order ID required")
    }
    if len(cmd.Items) == 0 {
        return fmt.Errorf("at least one item required")
    }
    return nil
}

type PlaceOrderHandler struct{}

func (h PlaceOrderHandler) Handle(ctx context.Context, state Order, cmd PlaceOrderCmd) (Events, error) {
    if err := cmd.Validate(); err != nil {
        return nil, err
    }

    // Invariant métier : on ne peut pas placer une commande déjà placée
    if state.Status == OrderStatusPlaced {
        return nil, ErrOrderAlreadyPlaced
    }

    total := 0
    for _, item := range cmd.Items {
        total += item.UnitPrice * item.Quantity
    }

    var events Events
    events.Append(OrderPlaced{
        OrderID:    cmd.OrderID,
        CustomerID: cmd.CustomerID,
        Items:      cmd.Items,
        Total:      total,
        PlacedAt:   time.Now(),
    })

    return events, nil
}

Ce handler est une fonction pure déguisée en méthode. Mêmes entrées, même sortie, à chaque fois. Pas d'état interne. Pas de dépendances cachées. Testable en isolation totale.

La validation en deux couches

Regardez la structure : la validation est séparée en deux niveaux distincts, avec des responsabilités différentes.

Couche 1 : cmd.Validate() — validation structurelle. Champs requis, formats corrects, contraintes simples. Cette validation peut être appelée avant même de charger l'aggregate depuis le store. Pas besoin de contexte métier. Elle appartient à la commande elle-même.

Couche 2 : invariants métier dans le handler — ces validations nécessitent le state actuel. "Peut-on annuler cette commande ?" dépend du statut actuel de la commande. Sans state, impossible de répondre.

type CancelOrderCmd struct {
    OrderID uuid.UUID
    Reason  string
}

func (cmd CancelOrderCmd) Validate() error {
    if cmd.Reason == "" {
        return fmt.Errorf("cancellation reason required")
    }
    return nil
}

func (h CancelOrderHandler) Handle(ctx context.Context, state Order, cmd CancelOrderCmd) (Events, error) {
    if err := cmd.Validate(); err != nil {
        return nil, err
    }

    // Invariants métier — dépendent du state
    if state.Status == OrderStatusShipped {
        return nil, fmt.Errorf("cannot cancel shipped order")
    }
    if state.Status == OrderStatusCancelled {
        return nil, fmt.Errorf("order already cancelled")
    }

    var events Events
    events.Append(OrderCancelled{
        OrderID:     cmd.OrderID,
        Reason:      cmd.Reason,
        CancelledAt: time.Now(),
    })

    return events, nil
}

La séparation est nette. Les erreurs de validation structurelle remontent tôt, avant tout chargement depuis le store. Les invariants métier remontent après, quand le contexte est disponible.

Les tests — 10 lignes, sans mock

C'est là que la conception paie. Voici un test complet pour le cas nominal :

func TestPlaceOrder_Success(t *testing.T) {
    handler := PlaceOrderHandler{}
    state := Order{} // state vide = nouvelle commande

    cmd := PlaceOrderCmd{
        OrderID:    uuid.New(),
        CustomerID: uuid.New(),
        Items:      []OrderItem{{ProductID: "SKU-1", Quantity: 2, UnitPrice: 1500}},
    }

    events, err := handler.Handle(context.Background(), state, cmd)

    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if len(events) != 1 {
        t.Fatalf("expected 1 event, got %d", len(events))
    }

    placed, ok := events[0].(OrderPlaced)
    if !ok {
        t.Fatalf("expected OrderPlaced, got %T", events[0])
    }
    if placed.Total != 3000 {
        t.Errorf("expected total 3000, got %d", placed.Total)
    }
}

Pas de mock. Pas de DB. Pas de Docker. Pas de setup/teardown. Ce test tourne en microsecondes sur n'importe quelle machine.

Les invariants métier se testent avec la même simplicité — il suffit de passer un state dans le bon statut :

func TestPlaceOrder_AlreadyPlaced(t *testing.T) {
    handler := PlaceOrderHandler{}
    state := Order{Status: OrderStatusPlaced} // commande déjà placée

    _, err := handler.Handle(context.Background(), state, PlaceOrderCmd{
        OrderID: uuid.New(),
        Items:   []OrderItem{{ProductID: "SKU-1", Quantity: 1, UnitPrice: 100}},
    })

    if err != ErrOrderAlreadyPlaced {
        t.Fatalf("expected ErrOrderAlreadyPlaced, got %v", err)
    }
}

Et les table-driven tests s'écrivent de façon particulièrement lisible, parce que la seule variable entre les cas est le state initial :

func TestCancelOrder(t *testing.T) {
    tests := []struct {
        name    string
        state   Order
        wantErr bool
    }{
        {"draft order", Order{Status: OrderStatusDraft}, false},
        {"placed order", Order{Status: OrderStatusPlaced}, false},
        {"shipped order", Order{Status: OrderStatusShipped}, true},
        {"already cancelled", Order{Status: OrderStatusCancelled}, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            _, err := CancelOrderHandler{}.Handle(context.Background(), tt.state, CancelOrderCmd{
                OrderID: uuid.New(),
                Reason:  "changed mind",
            })
            if (err != nil) != tt.wantErr {
                t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr)
            }
        })
    }
}

Quatre cas de statuts, quatre lignes dans le tableau. La logique de test est entièrement dans les données — c'est exactement ce que les table-driven tests cherchent à atteindre.

Le type Events et Append()

Le type Events est intentionnellement simple :

type Events []DomainEvent

func (e *Events) Append(event DomainEvent) {
    *e = append(*e, event)
}

C'est un slice. Pas de bus d'événements, pas de dispatcher, pas de middleware. Juste un slice avec une méthode helper pour que le code d'appel soit un peu plus lisible que *e = append(*e, event).

Pourquoi si simple ? Parce que le handler n'a pas besoin de savoir ce qui se passe après. Il émet des events. C'est tout. Ce qui arrive ensuite — persistance, projections, sagas — c'est le problème du command gateway.

Le flux complet — du HTTP au stockage

Pour que la picture soit complète, voici ce que fait le command gateway quand il reçoit une commande. Le handler, lui, ne voit rien de tout ça :

// Ce que fait le CommandGateway quand il reçoit une command :
// 1. Charger les events existants de l'aggregate depuis le store
// 2. Rejouer les events pour reconstruire le state (via Transition())
// 3. Appeler handler.Handle(ctx, state, cmd)
// 4. Stocker les nouveaux events dans le store
// 5. Notifier les event handlers (projections, sagas)

func (g *CommandGateway) Dispatch(ctx context.Context, aggregateID uuid.UUID, cmd Command) error {
    // Charger et reconstruire l'aggregate
    storedEvents, err := g.store.Load(ctx, aggregateID)
    if err != nil {
        return fmt.Errorf("loading aggregate %s: %w", aggregateID, err)
    }

    state := g.aggregate.Rebuild(storedEvents)

    // Le handler ne sait rien de ce qui précède
    newEvents, err := g.handler.Handle(ctx, state, cmd)
    if err != nil {
        return err
    }

    // Stocker les nouveaux events
    if err := g.store.Append(ctx, aggregateID, newEvents); err != nil {
        return fmt.Errorf("appending events: %w", err)
    }

    // Notifier les projections et sagas (async)
    g.bus.Publish(newEvents)

    return nil
}

Le handler est appelé à l'étape 3. Il reçoit un state déjà reconstruit, retourne des events, et ne sait pas ce qui vient avant ni après. C'est cette ignorance délibérée qui le rend testable.

Les détails du store PostgreSQL et de la reconstruction via events sont traités en partie 4. Ce qui compte ici : le handler est pur.

Résumé

  • Handler pur : aucune dépendance sur des repos, DB ou services externes. Reçoit un state, retourne des events.
  • Signature Handle(ctx, state, cmd) (Events, error) : c'est le contrat. Toute l'architecture en découle.
  • Validation en deux couches : structurelle sur la commande (cmd.Validate()), métier dans le handler. Chacune là où elle a le contexte nécessaire.
  • Tests sans mock : construire un state, appeler le handler, vérifier les events. Pas de setup, pas d'infrastructure, pas de Docker.
  • Events comme slice : type Events []DomainEvent. Pas de bus intégré au handler. La complexité de routage appartient ailleurs.
  • Le gateway orchestre : chargement du store, reconstruction du state, appel du handler, persistance des events. Le handler ne voit rien de tout ça — et c'est voulu.

La partie 3 aborde les sagas : comment réagir à un event pour déclencher d'autres commands, et comment orchestrer des processus métier qui traversent plusieurs aggregates.

📄 CLAUDE.md associé

Commentaires (0)