CQRS + Event Sourcing — 01 — Commands, handlers et domain events

Comment modéliser des commands, écrire des handlers et produire des domain events. La différence entre command et event, validation, side effects. TypeScript et Python.

01 — Commands, handlers et domain events

Ce que tu vas apprendre

  • La différence exacte entre une command et un event
  • Comment structurer un command handler
  • Ce que sont les domain events et comment les produire
  • La validation : dans la command ou dans le handler ?
  • Exemples complets TypeScript et Python

Prérequis


Command vs Event : la distinction fondamentale

Ces deux concepts sont le cœur de CQRS + Event Sourcing. La confusion entre eux est la source de la plupart des erreurs de design.

Command Event
Définition Une intention — "fais ça" Un fait — "ça s'est passé"
Temps Présent impératif Passé
Peut échouer Oui — le handler peut rejeter Non — un event est déjà arrivé
Exemples CreateOrder, CancelOrder OrderCreated, OrderCancelled
Contient Les données pour réaliser l'action Les données de ce qui s'est passé
typescript// Command — intention, peut échouer
interface CreateOrderCommand {
  readonly customerId: string;
  readonly items: Array<{ productId: string; quantity: number; unitPriceCents: number }>;
  readonly shippingAddress: string;
}

// Event — fait accompli, immuable
interface OrderCreatedEvent {
  readonly eventType: "OrderCreated";
  readonly orderId: string;
  readonly customerId: string;
  readonly items: Array<{ productId: string; quantity: number; unitPriceCents: number }>;
  readonly totalCents: number;
  readonly occurredAt: string; // ISO 8601
}
pythonfrom dataclasses import dataclass
from typing import List

# Command
@dataclass(frozen=True)
class CreateOrderCommand:
    customer_id: str
    items: List[dict]
    shipping_address: str

# Event
@dataclass(frozen=True)
class OrderCreatedEvent:
    event_type: str  # "OrderCreated"
    order_id: str
    customer_id: str
    items: List[dict]
    total_cents: int
    occurred_at: str

La structure d'un command handler

Un command handler a une responsabilité unique : recevoir une command, vérifier les invariants, produire des events.

typescript// La structure invariable d'un handler
class CreateOrderHandler {
  constructor(
    private readonly eventStore: EventStore,
    private readonly customerRepo: CustomerRepository
  ) {}

  async handle(cmd: CreateOrderCommand): Promise<string> {
    // 1. Valider la command (invariants métier)
    this.validate(cmd);

    // 2. Charger l'état courant depuis les events (si l'agrégat existe déjà)
    // Pour une création, l'agrégat n'existe pas encore

    // 3. Vérifier les règles métier (peut nécessiter des données externes)
    const customer = await this.customerRepo.findById(cmd.customerId);
    if (!customer) throw new Error(`Client introuvable : ${cmd.customerId}`);
    if (!customer.isActive) throw new Error("Client inactif");

    // 4. Créer le domain event
    const orderId = crypto.randomUUID();
    const totalCents = cmd.items.reduce(
      (sum, item) => sum + item.unitPriceCents * item.quantity,
      0
    );

    const event: OrderCreatedEvent = {
      eventType: "OrderCreated",
      orderId,
      customerId: cmd.customerId,
      items: cmd.items,
      totalCents,
      occurredAt: new Date().toISOString(),
    };

    // 5. Persister l'event (pas l'état — l'event)
    await this.eventStore.append(`order-${orderId}`, [event], -1);

    // 6. Retourner l'ID — la command ne retourne pas l'état
    return orderId;
  }

  private validate(cmd: CreateOrderCommand): void {
    if (!cmd.customerId?.trim()) {
      throw new Error("customerId obligatoire");
    }
    if (!cmd.items || cmd.items.length === 0) {
      throw new Error("La commande doit contenir au moins un article");
    }
    for (const item of cmd.items) {
      if (item.quantity <= 0) throw new Error("Quantité invalide");
      if (item.unitPriceCents <= 0) throw new Error("Prix invalide");
    }
  }
}

En Python :

pythonimport uuid
from datetime import datetime, timezone

class CreateOrderHandler:
    def __init__(self, event_store: 'EventStore', customer_repo: 'CustomerRepository') -> None:
        self._event_store = event_store
        self._customer_repo = customer_repo

    async def handle(self, cmd: CreateOrderCommand) -> str:
        # 1. Validation
        self._validate(cmd)

        # 2. Vérification métier
        customer = await self._customer_repo.find_by_id(cmd.customer_id)
        if not customer:
            raise ValueError(f"Client introuvable : {cmd.customer_id}")
        if not customer.is_active:
            raise ValueError("Client inactif")

        # 3. Construire l'event
        order_id = str(uuid.uuid4())
        total_cents = sum(item["unit_price_cents"] * item["quantity"] for item in cmd.items)

        event = OrderCreatedEvent(
            event_type="OrderCreated",
            order_id=order_id,
            customer_id=cmd.customer_id,
            items=list(cmd.items),
            total_cents=total_cents,
            occurred_at=datetime.now(timezone.utc).isoformat(),
        )

        # 4. Persister l'event
        await self._event_store.append(f"order-{order_id}", [event], expected_version=-1)

        return order_id

    def _validate(self, cmd: CreateOrderCommand) -> None:
        if not cmd.customer_id or not cmd.customer_id.strip():
            raise ValueError("customer_id obligatoire")
        if not cmd.items:
            raise ValueError("La commande doit contenir au moins un article")
        for item in cmd.items:
            if item.get("quantity", 0) <= 0:
                raise ValueError("Quantité invalide")
            if item.get("unit_price_cents", 0) <= 0:
                raise ValueError("Prix invalide")

Modéliser plusieurs commands sur un agrégat

Un agrégat reçoit plusieurs commands au cours de sa vie. Chacune charge l'état courant, vérifie les invariants, produit un event.

typescript// Les commands d'une commande e-commerce
type OrderCommand =
  | CreateOrderCommand
  | ConfirmOrderCommand
  | ShipOrderCommand
  | CancelOrderCommand;

// ConfirmOrderCommand — nécessite que l'agrégat existe et soit en "pending"
class ConfirmOrderHandler {
  constructor(private readonly eventStore: EventStore) {}

  async handle(cmd: ConfirmOrderCommand): Promise<void> {
    const streamId = `order-${cmd.orderId}`;

    // 1. Charger les events existants
    const { events, version } = await this.eventStore.load(streamId);
    if (events.length === 0) {
      throw new Error(`Commande introuvable : ${cmd.orderId}`);
    }

    // 2. Reconstituer l'état courant
    const state = OrderAggregate.rehydrate(events);

    // 3. Vérifier les invariants
    if (state.status !== "pending") {
      throw new Error(
        `Impossible de confirmer une commande en statut "${state.status}"`
      );
    }

    // 4. Produire l'event
    const event: OrderConfirmedEvent = {
      eventType: "OrderConfirmed",
      orderId: cmd.orderId,
      confirmedAt: new Date().toISOString(),
    };

    // 5. Appender avec le numéro de version attendu (concurrence optimiste)
    await this.eventStore.append(streamId, [event], version);
  }
}

La validation : command ou handler ?

Deux niveaux de validation servent des objectifs différents.

typescript// Niveau 1 : validation de la command
// Ce qui peut être vérifié sans accès à la BDD
function validateCreateOrderCommand(
  cmd: CreateOrderCommand
): string | null {
  if (!cmd.customerId?.trim()) {
    return "customerId obligatoire";
  }
  if (!cmd.items?.length) {
    return "Au moins un article requis";
  }
  for (const item of cmd.items) {
    if (!Number.isInteger(item.quantity) || item.quantity <= 0) {
      return `Quantité invalide pour ${item.productId}`;
    }
    if (!Number.isInteger(item.unitPriceCents) || item.unitPriceCents <= 0) {
      return `Prix invalide pour ${item.productId}`;
    }
  }
  return null; // Valide
}

// Niveau 2 : règles métier dans le handler
// Ce qui nécessite la BDD ou l'état de l'agrégat
async handle(cmd: CreateOrderCommand): Promise<string> {
  const error = validateCreateOrderCommand(cmd);
  if (error) throw new Error(error);

  // Règles qui nécessitent la BDD
  const customer = await this.customerRepo.findById(cmd.customerId);
  if (!customer) throw new Error("Client introuvable");
  if (customer.creditLimitCents < totalCents) {
    throw new Error("Limite de crédit dépassée");
  }
  ...
}

Deux niveaux, deux raisons

Validation de la command : vérifiable sans I/O. Forme des données, types, contraintes. Rapide, sans effet de bord.

Règles métier dans le handler : nécessite l'état de la BDD (le client existe-t-il ? A-t-il assez de crédit ? La commande est-elle déjà confirmée ?).

Séparer les deux rend chaque niveau testable indépendamment. La validation de la command n'a pas besoin de mock. Les règles métier peuvent tester des scénarios précis avec un event store en mémoire.


Les domain events : conventions

Quelques règles de naming et de structure qui simplifient la maintenance :

typescript// Convention : passé, agrégat + action
// ✓ OrderCreated, OrderConfirmed, OrderShipped, OrderCancelled
// ✓ ItemAddedToCart, ItemRemovedFromCart
// ✓ PaymentReceived, PaymentFailed, PaymentRefunded
// ✗ CreateOrder (présent — c'est une command)
// ✗ OrderUpdate (vague — qu'est-ce qui a été mis à jour ?)

// Structure standard d'un event
interface DomainEvent {
  readonly eventType: string;       // "OrderCreated" — discriminant
  readonly occurredAt: string;      // ISO 8601
  // + les données spécifiques à l'event
}

interface OrderCreatedEvent extends DomainEvent {
  readonly eventType: "OrderCreated";
  readonly orderId: string;
  readonly customerId: string;
  readonly items: OrderItem[];
  readonly totalCents: number;
}

interface OrderCancelledEvent extends DomainEvent {
  readonly eventType: "OrderCancelled";
  readonly orderId: string;
  readonly reason: string;
  readonly cancelledAt: string;
}

En Python :

pythonfrom dataclasses import dataclass

@dataclass(frozen=True)
class OrderCreatedEvent:
    event_type: str        # "OrderCreated"
    order_id: str
    customer_id: str
    items: tuple           # frozen — pas de list mutable
    total_cents: int
    occurred_at: str

@dataclass(frozen=True)
class OrderCancelledEvent:
    event_type: str        # "OrderCancelled"
    order_id: str
    reason: str
    occurred_at: str

# Union des events d'une commande
OrderEvent = OrderCreatedEvent | OrderConfirmedEvent | OrderShippedEvent | OrderCancelledEvent

Résumé

Concept Rôle Règle
Command Intention de modifier l'état Présent impératif, peut échouer
Command handler Valide + exécute + produit des events Une seule responsabilité
Domain event Fait accompli Passé, immuable, contient toutes les données nécessaires
Validation Deux niveaux Forme (sans BDD) + règles métier (avec BDD)

Étape suivante : 02 — Event store avec PostgreSQL — comment persister ces events de façon fiable avec une contrainte append-only.


Sources

  • Young, G. (2010). CQRS Documents. cqrs.files.wordpress.com.
  • Evans, E. (2003). Domain-Driven Design. Addison-Wesley.
  • Vernon, V. (2013). Implementing Domain-Driven Design. Addison-Wesley.
  • Fowler, M. (2005). Event Sourcing. martinfowler.com.

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.