init() vs restore()

Et les DomainErrors

Bootcode IWA-S04 — Semaine 18, Jour 4

Objectifs de la leçon

1. Le problème du double enregistrement

Pourquoi on ne peut pas rejouer avec applyChange

2. init() vs restore()

Création vs reconstitution

3. DomainErrors typées

Protéger les invariants métier

4. Cycle de vie complet

init → save → restore → action → save

Plan du cours

1

Le problème

Rejouer les événements avec applyChange les sauve en double

2

init() vs restore()

Deux factories pour deux usages

3

Cycle de vie complet

init → save → restore → action → save

4

DomainErrors

Erreurs typées avec code machine-readable

5

Live coding

Task avec init/restore + DomainErrors

Module 1

Le problème

Pourquoi applyChange ne suffit pas au chargement

Le scénario du piège

On charge les événements de la base pour reconstituer la Task. Si on utilise applyChange, on a un problème.

// ❌ Mauvais : charger avec applyChange

const task = new Task({ ... });

events.forEach(event => task.applyChange(event));

// task.uncommittedChanges contient maintenant

// tous les événements déjà sauvegardés !

// → Ils seront enregistrés une deuxième fois au prochain save()

🚨 Double enregistrement ! applyChange pousse dans uncommittedChanges, mais ces événements sont déjà persistés.

La solution : handleEvent sans enregistrer

Il faut une méthode qui mute l'état SANS ajouter l'événement aux uncommitted changes.

// AggregateRoot.ts

protected applyChange(event: DomainEvent): void {

this.uncommittedChanges.push(event); // enregistrer

this.handleEvent(event); // muter

}

protected handleEvent(event: DomainEvent): void {

// appelle le handler @Handle sans toucher Ă  uncommittedChanges

}

💡 handleEvent = mute SANS enregistrer. C'est la clé de restore().

Module 2

init() vs restore()

Création vs reconstitution

init() — Création

Utilisée la première fois qu'on crée un agrégat. Produit un événement uncommitted.

// domain/Task.ts

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. Génère des événements uncommitted à sauvegarder.

restore() — Reconstitution

Utilisée au chargement depuis la base. Rejoue les événements SANS les enregistrer à nouveau.

// domain/Task.ts

static restore(id: string, events: DomainEvent[]): Task {

const task = new Task({

id,

title: '',

status: 'todo',

createdAt: new Date()

});

events.forEach(event => task.handleEvent(event));

return task;

}

💡 restore() = mute SANS enregistrer. Les événements sont déjà persistés.

init() vs restore()

🆕 init()

  • • CrĂ©ation d'un nouvel agrĂ©gat
  • • Utilise applyChange()
  • • Produits des Ă©vĂ©nements uncommitted
  • • AppelĂ© par le Usecase de crĂ©ation

🔄 restore()

  • • Chargement depuis la base
  • • Utilise handleEvent()
  • • Aucun nouvel Ă©vĂ©nement
  • • AppelĂ© par le Repository / Mapper

🚨 Cette distinction est CRITIQUE. Sinon, les événements historiques sont sauvegardés en double.

Module 3

Cycle de vie complet

init → save → restore → action → save

Le cycle de vie d'un agrégat

1

init() crée l'agrégat et produit des événements uncommitted.

↓
2

save() le repository persiste les uncommitted events et les vide.

↓
3

restore() recharge les événements et mute l'état sans créer de nouveaux événements.

↓
4

action appelle applyChange et produit de nouveaux événements uncommitted.

↓
5

save() persiste les nouveaux uncommitted events.

Module 4

DomainErrors

Protéger les invariants métier

Pourquoi des erreurs typées ?

Les exceptions génériques ne disent pas quel invariant a été violé. Les DomainErrors portent un code exploitable.

❌ Erreur générique

throw new Error("Task déjà terminée");

Impossible à catcher proprement côté API.

✅ DomainError typée

throw new TaskAlreadyCompletedError();

Code machine-readable + message humain.

DomainError en pratique

// domain/errors.ts

export class TaskAlreadyCompletedError extends DomainError {

constructor() {

super(

'TASK_ALREADY_COMPLETED',

'La tâche est déjà terminée.'

);

}

}

export class TaskNotCompletedError extends DomainError {

constructor() {

super(

'TASK_NOT_COMPLETED',

'La tâche n\'est pas encore terminée.'

);

}

}

💡 Chaque règle métier a SA propre erreur avec un code unique.

Protéger les invariants AVANT applyChange

L'erreur est levée dans l'action, avant de produire un événement invalide.

// domain/Task.ts

complete(): void {

if (this.props.status === 'done') {

throw new TaskAlreadyCompletedError();

}

this.applyChange(new TaskCompleted(this.props.id));

}

reopen(): void {

if (this.props.status !== 'done') {

throw new TaskNotCompletedError();

}

this.applyChange(new TaskReopened(this.props.id));

}

🛡️ Les DomainErrors protègent les invariants AVANT applyChange.

Module 5

Task complet

init, restore, actions, handlers, erreurs

Task.ts complet

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;

}

static restore(id: string, events: DomainEvent[]): Task {

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

events.forEach(event => task.handleEvent(event));

return task;

}

complete(): void {

if (this.props.status === 'done') throw new TaskAlreadyCompletedError();

this.applyChange(new TaskCompleted(this.props.id));

}

reopen(): void {

if (this.props.status !== 'done') throw new TaskNotCompletedError();

this.applyChange(new TaskReopened(this.props.id));

}

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

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

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

}

Scénario de test

Création, sauvegarde, rechargement, nouvelle action, sauvegarde.

// 1. Création

const task = Task.init("t-1", "Préparer S19");

task.complete();

// 2. Sauvegarde (repository persiste les uncommitted)

const eventsToSave = task.getUncommittedEvents();

// persist(eventsToSave); task.markCommitted();

// 3. Rechargement depuis la base

const savedEvents = loadEvents("t-1");

const reloaded = Task.restore("t-1", savedEvents);

// 4. Nouvelle action

reloaded.reopen();

console.log(reloaded.getUncommittedEvents().length); // 1 (TaskReopened)

✅ Après restore, seul le nouvel événement est uncommitted. Pas de double.

Pièges courants

❌ Confondre uncommitted et committed

Les événements de restore sont committed. Les nouveaux sont uncommitted.

âś… Le repository vide les uncommitted

Après save, le repository vide la liste pour ne pas resauver.

❌ restore() utilise applyChange

Double enregistrement assuré au prochain save.

âś… restore() utilise handleEvent

Mute sans ajouter aux uncommitted changes.

❌ DomainError trop générique

throw new Error("invalid") → pas exploitable.

âś… Un code par invariant

TASK_ALREADY_COMPLETED, TASK_NOT_COMPLETED, etc.

Ă€ retenir !

1. init() vs restore()

init crée avec applyChange. Restore reconstitue avec handleEvent.

2. Pas de double enregistrement

handleEvent mute sans ajouter aux uncommitted changes.

3. DomainErrors typées

Code machine-readable + message humain. Un par invariant.

4. Cycle de vie complet

init → save → restore → action → save.

Demain : exercice complet — un agrégat Order event-sourcé avec toutes les couches.