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
- 00 — Introduction CQRS + Event Sourcing
- Notions de Value Objects recommandées
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.