Et les DomainErrors
Bootcode IWA-S04 — Semaine 18, Jour 4
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
Le problème
Rejouer les événements avec applyChange les sauve en double
init() vs restore()
Deux factories pour deux usages
Cycle de vie complet
init → save → restore → action → save
DomainErrors
Erreurs typées avec code machine-readable
Live coding
Task avec init/restore + DomainErrors
Module 1
Pourquoi applyChange ne suffit pas au chargement
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.
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
Création vs reconstitution
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.
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()
🔄 restore()
🚨 Cette distinction est CRITIQUE. Sinon, les événements historiques sont sauvegardés en double.
Module 3
init → save → restore → action → save
init() crée l'agrégat et produit des événements uncommitted.
save() le repository persiste les uncommitted events et les vide.
restore() recharge les événements et mute l'état sans créer de nouveaux événements.
action appelle applyChange et produit de nouveaux événements uncommitted.
save() persiste les nouveaux uncommitted events.
Module 4
Protéger les invariants métier
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.
// 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.
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
init, restore, actions, handlers, erreurs
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; }
}
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.
❌ 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.
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.