Agrégat Order event-sourcé
Bootcode IWA-S04 â Semaine 18, Jour 5
1. Toutes les couches
Implémenter un agrégat event-sourcé complet
2. Transitions de statut
Gérer les rÚgles métier d'une commande
3. init() et restore()
Création et reconstitution correctes
4. DomainErrors
Protéger les invariants métier
Le projet
Un agrégat Order de restaurant
La checklist
ĂvĂ©nements + erreurs + factory + actions + handlers + restore
Structure des fichiers
events.ts, errors.ts, order.ts, main.ts
RÚgles métier
Transitions, limites, validations
Bonus confirm()
Pour les plus rapides
Module 1
Une commande de restaurant event-sourcée
On modélise une commande de restaurant avec un cycle de vie clair.
Actions possibles
place() : créer la commandeaddItem(name, price) : ajouter un platcancel() : annulerconfirm() : confirmer (bonus)Statuts
draft
placed
confirmed
cancelled
â AutorisĂ©
â Interdit
Module 2
Toutes les couches d'un agrégat event-sourcé
Suivre cet ordre empĂȘche de se perdre.
ĂvĂ©nements â nommer ce qui peut arriver
DomainErrors â lister les rĂšgles Ă protĂ©ger
Factory init() â crĂ©er avec le premier Ă©vĂ©nement
Actions â les mĂ©thodes publiques (place, addItem, cancel)
Handlers @Handle â muter l'Ă©tat par Ă©vĂ©nement
restore() â reconstituer sans double enregistrement
Module 3
events.ts, errors.ts, order.ts, main.ts
src/
domain/
events.ts â OrderPlaced, ItemAdded, OrderCancelled, OrderConfirmed
errors.ts â EmptyOrderError, OrderAlreadyConfirmedError, ...
order.ts â la classe Order (agrĂ©gat)
main.ts â scĂ©narios de test
đĄ SĂ©parer les Ă©vĂ©nements et les erreurs rend l'agrĂ©gat plus lisible.
Module 4
Le langage du métier
// domain/events.ts
export class OrderPlaced extends DomainEvent {
constructor(orderId: string, public readonly tableNumber: number) {
super(orderId, { tableNumber });
}
}
export class ItemAdded extends DomainEvent {
constructor(
orderId: string,
public readonly itemName: string,
public readonly price: number
) { super(orderId, { itemName, price }); }
}
export class OrderCancelled extends DomainEvent {
constructor(orderId: string) { super(orderId, {}); }
}
export class OrderConfirmed extends DomainEvent {
constructor(orderId: string) { super(orderId, {}); }
}
// domain/errors.ts
export class EmptyOrderError extends DomainError {
constructor() { super('EMPTY_ORDER', 'La commande doit contenir au moins un article.'); }
}
export class OrderAlreadyConfirmedError extends DomainError {
constructor() { super('ORDER_ALREADY_CONFIRMED', 'La commande est déjà confirmée.'); }
}
export class OrderCancelledError extends DomainError {
constructor() { super('ORDER_CANCELLED', 'La commande est annulée.'); }
}
export class MaxItemsReachedError extends DomainError {
constructor() { super('MAX_ITEMS_REACHED', 'Maximum 20 articles par commande.'); }
}
Module 5
Implémentation complÚte
// domain/order.ts
type OrderStatus = 'draft' | 'placed' | 'confirmed' | 'cancelled';
type OrderItem = {
name: string;
price: number;
}
type OrderProps = {
id: string;
tableNumber: number;
items: OrderItem[];
status: OrderStatus;
}
export class Order extends AggregateRoot<OrderProps> {
static init(id: string, tableNumber: number): Order {
const order = new Order({ id, tableNumber, items: [], status: 'draft' });
order.applyChange(new OrderPlaced(id, tableNumber));
return order;
}
static restore(id: string, events: DomainEvent[]): Order {
const order = new Order({ id, tableNumber: 0, items: [], status: 'draft' });
events.forEach(event => order.handleEvent(event));
return order;
}
}
Les méthodes publiques qui protÚgent les invariants et produisent les événements.
place(): void {
if (this.props.items.length === 0) {
throw new EmptyOrderError();
}
this.applyChange(new OrderPlaced(this.props.id, this.props.tableNumber));
}
addItem(name: string, price: number): void {
if (this.props.status === 'cancelled' || this.props.status === 'confirmed') {
throw new OrderCancelledError(); // ou AlreadyConfirmed
}
if (this.props.items.length >= 20) {
throw new MaxItemsReachedError();
}
this.applyChange(new ItemAdded(this.props.id, name, price));
}
cancel(): void {
if (this.props.status === 'confirmed') {
throw new OrderAlreadyConfirmedError();
}
this.applyChange(new OrderCancelled(this.props.id));
}
đĄ Les invariants sont vĂ©rifiĂ©s AVANT applyChange. Chaque invariant a sa DomainError.
Mutations pures de l'état. Aucune logique métier ici.
@Handle(OrderPlaced)
onOrderPlaced(event: OrderPlaced): void {
this.props.tableNumber = event.tableNumber;
this.props.status = 'placed';
}
@Handle(ItemAdded)
onItemAdded(event: ItemAdded): void {
this.props.items.push({ name: event.itemName, price: event.price });
}
@Handle(OrderCancelled)
onOrderCancelled(): void {
this.props.status = 'cancelled';
}
@Handle(OrderConfirmed)
onOrderConfirmed(): void {
this.props.status = 'confirmed';
}
export class Order extends AggregateRoot<OrderProps> {
static init(id: string, table: number): Order {
const o = new Order({ id, tableNumber: table, items: [], status: 'draft' });
o.applyChange(new OrderPlaced(id, table));
return o;
}
static restore(id: string, events: DomainEvent[]): Order {
const o = new Order({ id, tableNumber: 0, items: [], status: 'draft' });
events.forEach(e => o.handleEvent(e));
return o;
}
place() { if (this.props.items.length === 0) throw new EmptyOrderError(); this.applyChange(new OrderPlaced(this.props.id, this.props.tableNumber)); }
addItem(name: string, price: number) { if (this.props.status === 'cancelled') throw new OrderCancelledError(); if (this.props.items.length >= 20) throw new MaxItemsReachedError(); this.applyChange(new ItemAdded(this.props.id, name, price)); }
cancel() { if (this.props.status === 'confirmed') throw new OrderAlreadyConfirmedError(); this.applyChange(new OrderCancelled(this.props.id)); }
@Handle(OrderPlaced) onOrderPlaced(e: OrderPlaced) { this.props.tableNumber = e.tableNumber; this.props.status = 'placed'; }
@Handle(ItemAdded) onItemAdded(e: ItemAdded) { this.props.items.push({ name: e.itemName, price: e.price }); }
@Handle(OrderCancelled) onOrderCancelled() { this.props.status = 'cancelled'; }
}
Module 6
Scénarios positifs, négatifs, et confirm()
// main.ts
const order = Order.init("order-1", 12);
order.addItem("Pizza", 12);
order.addItem("Salade", 8);
order.place();
try {
order.cancel(); // â OK car placed
} catch (e) {
console.error(e);
}
try {
const empty = Order.init("order-2", 5);
empty.place(); // â EmptyOrderError
} catch (e) {
console.log(e.code); // "EMPTY_ORDER"
}
Pour les plus rapides. Une action qui passe de placed Ă confirmed.
// errors.ts
export class OrderNotPlacedError extends DomainError {
constructor() {
super('ORDER_NOT_PLACED', 'La commande doit ĂȘtre placĂ©e avant confirmation.');
}
}
// order.ts
confirm(): void {
if (this.props.status !== 'placed') {
throw new OrderNotPlacedError();
}
this.applyChange(new OrderConfirmed(this.props.id));
}
đ Bonus pour les plus rapides : ajouter confirm() avec son Ă©vĂ©nement et son handler.
Module 7
Ce qu'on a appris cette semaine
J1 â Le package ddd
AggregateRoot, ValueObject, DomainEvent, Usecase, Query, Mapper, DomainError, @Handle
J2 â Event Sourcing
Relevé bancaire, snapshot vs event sourcing, applyChange, immutabilité
J3 â AgrĂ©gat event-sourcĂ©
Task : événements, init, actions, handlers, test
J4 â init() vs restore()
Création vs reconstitution, DomainErrors, cycle de vie
đ Semaine 19 : on automatise le cĂąblage avec Inversify (DI).
â Vouloir tout faire d'un coup
Suivre la checklist évite de se perdre.
â Approche incrĂ©mentale
ĂvĂ©nements â erreurs â factory â actions â handlers â restore.
â Oublier les cas d'erreur
Tester seulement les chemins positifs. Les scénarios négatifs sont aussi importants.
â Tests nĂ©gatifs
Vérifier que EmptyOrderError, MaxItemsReachedError etc. sont bien levées.
â Oublier restore()
Sans restore, le chargement depuis la base ne marche pas.
â restore avec handleEvent
Et handleEvent ne pousse pas dans uncommittedChanges.
1. Checklist complĂšte
ĂvĂ©nements + erreurs + factory + actions + handlers + restore.
2. Invariants protégés
Chaque rÚgle métier a sa DomainError, vérifiée avant applyChange.
3. init vs restore
init utilise applyChange, restore utilise handleEvent.
4. Tests positifs ET négatifs
Un agrégat robuste est testé dans tous les états.
Semaine 19 : on ajoute l'injection de dépendances avec Inversify.