Exercice complet

Agrégat Order event-sourcé

Bootcode IWA-S04 — Semaine 18, Jour 5

Objectifs de la leçon

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

Plan du cours

1

Le projet

Un agrégat Order de restaurant

2

La checklist

ÉvĂ©nements + erreurs + factory + actions + handlers + restore

3

Structure des fichiers

events.ts, errors.ts, order.ts, main.ts

4

RÚgles métier

Transitions, limites, validations

5

Bonus confirm()

Pour les plus rapides

Module 1

Le projet Order

Une commande de restaurant event-sourcée

Le contexte

On modélise une commande de restaurant avec un cycle de vie clair.

Actions possibles

  • ‱ place() : crĂ©er la commande
  • ‱ addItem(name, price) : ajouter un plat
  • ‱ cancel() : annuler
  • ‱ confirm() : confirmer (bonus)

Statuts

draft

placed

confirmed

cancelled

Les rÚgles métier

✅ AutorisĂ©

  • ‱ Placer une commande avec 1+ items
  • ‱ Ajouter des items tant que placed
  • ‱ Annuler une commande placed
  • ‱ Confirmer une commande placed (bonus)

❌ Interdit

  • ‱ Placer une commande vide
  • ‱ Ajouter un item si cancelled ou confirmed
  • ‱ Annuler une commande dĂ©jĂ  cancelled ou confirmed
  • ‱ Avoir plus de 20 items dans une commande

Module 2

La checklist

Toutes les couches d'un agrégat event-sourcé

Checklist de construction

Suivre cet ordre empĂȘche de se perdre.

1

ÉvĂ©nements — nommer ce qui peut arriver

2

DomainErrors — lister les rĂšgles Ă  protĂ©ger

3

Factory init() — crĂ©er avec le premier Ă©vĂ©nement

4

Actions — les mĂ©thodes publiques (place, addItem, cancel)

5

Handlers @Handle — muter l'Ă©tat par Ă©vĂ©nement

6

restore() — reconstituer sans double enregistrement

Module 3

Structure des fichiers

events.ts, errors.ts, order.ts, main.ts

Organisation du dossier

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

ÉvĂ©nements & Erreurs

Le langage du métier

Les événements Order

// 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, {}); }

}

Les erreurs Order

// 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

L'agrégat Order

Implémentation complÚte

OrderProps

// 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;

}

init() et restore()

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 actions

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.

Les handlers @Handle

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';

}

Order.ts complet

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

Test & Bonus

Scénarios positifs, négatifs, et confirm()

Scénario de test

// 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"

}

Bonus : confirm()

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

SynthĂšse & Transition

Ce qu'on a appris cette semaine

Récapitulatif de la 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).

PiĂšges de l'exercice

❌ 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.

À retenir !

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.