Agrégat event-sourcé

Implémenter un Task de A à Z

Bootcode IWA-S04 — Semaine 18, Jour 3

Objectifs de la leçon

1. Créer les événements

TaskCreated, TaskCompleted, TaskReopened

2. Factory init()

Produire le premier événement à la création

3. Actions

complete() et reopen() avec applyChange

4. Handlers @Handle

Muter l'état pour chaque événement

Plan du cours

1

Rappel du mécanisme

action → applyChange → @Handle → mutation

2

Les événements

TaskCreated, TaskCompleted, TaskReopened

3

La factory init()

Créer une Task avec son premier événement

4

Les actions

complete() et reopen()

5

Les handlers & test

Vérifier l'état et les uncommitted events

Module 1

Rappel du mécanisme

Le flux qu'on a vu hier

Le flux en 4 étapes

1. L'action est appelée par le Usecase.

2. L'action crée un événement et appelle applyChange(event).

3. applyChange pousse l'événement dans uncommittedChanges ET appelle le handler.

4. Le handler @Handle mute l'état.

💡 Aujourd'hui on met les mains dans le code : on construit un vrai agrégat.

Module 2

Les événements

Commencer par les messages

Les événements de Task

Trois événements métier : création, complétion, réouverture.

// domain/events.ts

export class TaskCreated extends DomainEvent {

constructor(

aggregateId: string,

public readonly title: string

) {

super(aggregateId, { title });

}

}

export class TaskCompleted extends DomainEvent {

constructor(aggregateId: string) {

super(aggregateId, {});

}

}

export class TaskReopened extends DomainEvent {

constructor(aggregateId: string) {

super(aggregateId, {});

}

}

💡 Toujours commencer par les événements : c'est le langage du métier.

Module 3

Factory init()

Créer l'agrégat avec son premier événement

Le type des props

L'état interne de la Task : titre, statut, dates.

// domain/Task.ts

type TaskStatus = 'todo' | 'done';

type TaskProps = {

id: string;

title: string;

status: TaskStatus;

createdAt: Date;

completedAt?: Date;

}

💡 Pas d'ID dans le payload de l'événement : l'aggregateId est déjà dans DomainEvent.

La factory init()

Crée une nouvelle Task et produit le premier événement.

// domain/Task.ts

export class Task extends AggregateRoot<TaskProps> {

static init(id: string, title: string): Task {

const task = new Task({

id,

title,

status: 'todo',

createdAt: new Date(),

});

task.applyChange(new TaskCreated(id, title));

return task;

}

}

💡 init() = création. Elle produit un événement uncommitted.

Module 4

Les actions

complete() et reopen()

Action : complete()

L'action demande un changement d'état via un événement.

// domain/Task.ts

complete(): void {

this.applyChange(

new TaskCompleted(this.props.id)

);

}

reopen(): void {

this.applyChange(

new TaskReopened(this.props.id)

);

}

💡 L'action ne mute pas directement l'état. Elle délègue à applyChange.

Module 5

Les handlers @Handle

Muter l'état pour chaque événement

Handlers de la Task

Chaque handler est une pure mutation de l'état. Pas d'effet de bord.

// domain/Task.ts

@Handle(TaskCreated)

onTaskCreated(event: TaskCreated): void {

this.props.title = event.title;

this.props.status = 'todo';

}

@Handle(TaskCompleted)

onTaskCompleted(): void {

this.props.status = 'done';

this.props.completedAt = new Date();

}

@Handle(TaskReopened)

onTaskReopened(): void {

this.props.status = 'todo';

this.props.completedAt = undefined;

}

đź’ˇ Les handlers sont synchrones et sans effet de bord. Ils ne font que mettre Ă  jour props.

Task.ts complet

L'ordre d'implémentation : événements → factory → actions → handlers.

export class Task extends AggregateRoot<TaskProps> {

static init(id: string, title: string): Task {

const task = new Task({ id, title, status: 'todo', createdAt: new Date() });

task.applyChange(new TaskCreated(id, title));

return task;

}

complete(): void { this.applyChange(new TaskCompleted(this.props.id)); }

reopen(): void { this.applyChange(new TaskReopened(this.props.id)); }

@Handle(TaskCreated) onTaskCreated(e: TaskCreated) { this.props.title = e.title; this.props.status = 'todo'; }

@Handle(TaskCompleted) onTaskCompleted() { this.props.status = 'done'; this.props.completedAt = new Date(); }

@Handle(TaskReopened) onTaskReopened() { this.props.status = 'todo'; this.props.completedAt = undefined; }

}

Tester la Task

Vérifiez l'état final et les uncommitted events.

// main.ts

const task = Task.init("task-1", "Apprendre l'event sourcing");

task.complete();

task.reopen();

task.complete();

console.log("Status :", task.props.status); // done

console.log("CompletedAt :", task.props.completedAt); // Date

const events = task.getUncommittedEvents();

console.log(events.map(e => e.constructor.name));

// [TaskCreated, TaskCompleted, TaskReopened, TaskCompleted]

✅ L'état est done et il y a 4 événements non commités, dont un TaskReopened.

Pièges courants

❌ Muter l'état directement

complete() { this.props.status = 'done'; }

L'événement n'est pas créé, rien n'est persisté.

âś… Passer par applyChange

complete() { this.applyChange(new TaskCompleted(...)); }

L'événement est enregistré ET l'état est muté.

❌ Oublier un handler

Si TaskCompleted n'a pas de @Handle, la Task ne passe jamais Ă  done.

✅ Un handler par événement

Chaque événement a sa méthode @Handle dédiée.

❌ Confondre événement et action

L'action est une méthode publique. L'événement est un message immuable qui représente le passé.

✅ Séparation claire

Action = intention. Événement = fait. Handler = mutation.

Ă€ retenir !

1. Ordre d'implémentation

Événements → factory init() → actions → handlers.

2. applyChange dans les actions

Chaque action crée un événement et appelle applyChange.

3. @Handle = pure mutation

Les handlers mettent Ă  jour props, sans effet de bord.

4. Vérifier les uncommitted events

Le test montre que l'état ET les événements sont cohérents.

Demain : init() vs restore(), et les DomainErrors pour protéger les invariants.