Agrégats event-sourcés, value objects, erreurs métier et interfaces de repositories
Bootcode IWA-S04 â Semaine 21, Jour 2
1. Implémenter au moins 2 agrégats event-sourcés fonctionnels
Avec pattern init/restore et méthodes métier
2. Créer au moins 1 value object avec validation
Immutable, validé à la construction, private constructor
3. Définir les erreurs métier
DomainError pour chaque rÚgle violée
4. Ăcrire au moins 4 use cases avec Usecase<I, O>
Chaque use case orchestre un agrégat et un repository
Rappel : la structure core/
agrégats, value objects, repositories, usecases, errors
Tips : l'ordre d'implémentation
ĂvĂ©nements â agrĂ©gat â value objects â use cases
Circuler et débloquer
Questions d'architecture, dépendances, invariants
Point collectif à mi-journée
Questions communes, retours d'expérience
Le domaine pur â zĂ©ro dĂ©pendance externe
# src/core/
domain/
Book.ts # agrégat
Loan.ts # agrégat
BookId.ts, ISBN.ts # value objects
events/
BookAdded.ts, BookBorrowed.ts
errors/
BookAlreadyBorrowedError.ts
repositories/
BookRepository.ts # interface abstraite
usecases/
BorrowBook.ts, ReturnBook.ts
đĄ Aucun import de typeorm, inversify, express dans core/. C'est la rĂšgle absolue.
Suivez cet ordre â chaque Ă©tape dĂ©bloque la suivante
Les événements
Ce qui se passe : BookAdded, BookBorrowed, BookReturned
L'agrégat
init/restore + méthodes métier qui produisent les événements
Les value objects
BookId, ISBN â avec validation
Les erreurs métier
Une DomainError par rÚgle violée
Les use cases
Orchestrent agrégat + repository
Au passé, immutable, contient les données de la transition
import { BookId } from '../domain/BookId';
import { MemberId } from '../domain/MemberId';
export class BookBorrowed {
constructor(
public readonly bookId: BookId,
public readonly memberId: MemberId,
public readonly borrowedAt: Date
) {}
}
đĄ L'Ă©vĂ©nement capture l'Ă©tat aprĂšs la transition. Pas besoin de stocker l'Ă©tat avant â on rejoue depuis le dĂ©but.
Deux factory methods : une pour créer, une pour reconstruire depuis l'historique
export class Book extends AggregateRoot<BookEvent> {
private id: BookId;
private isBorrowed = false;
// 1ïžâŁ CrĂ©ation â produit l'Ă©vĂ©nement initial
static init(id: BookId, isbn: ISBN): Book {
const book = new Book();
book.recordEvent(new BookAdded(id, isbn));
return book;
}
// 2ïžâŁ Rebuild â rejoue l'historique
static restore(events: BookEvent[]): Book {
const book = new Book();
events.forEach(e => book.apply(e));
return book;
}
}
La mĂ©thode apply est privĂ©e â seul restore() l'appelle
private apply(event: BookEvent): void {
if (event instanceof BookAdded) {
this.id = event.bookId;
this.isbn = event.isbn;
this.isBorrowed = false;
} else if (event instanceof BookBorrowed) {
this.isBorrowed = true;
} else if (event instanceof BookReturned) {
this.isBorrowed = false;
}
}
đĄ apply() ne fait que muter l'Ă©tat. Elle ne valide rien, ne lance pas d'erreur â l'Ă©vĂ©nement est un fait acquis.
Valide l'invariant, puis produit l'événement
borrow(memberId: MemberId): void {
// 1. Valider l'invariant
if (this.isBorrowed) {
throw new BookAlreadyBorrowedError(this.id);
}
// 2. Produire l'événement
this.recordEvent(
new BookBorrowed(this.id, memberId, new Date())
);
// 3. Appliquer immédiatement
this.isBorrowed = true;
}
â ïž L'ordre compte : valider AVANT de produire l'Ă©vĂ©nement. Sinon on crĂ©e un Ă©vĂ©nement pour une transition illĂ©gale.
Private constructor + factory statique qui valide
export class ISBN {
private constructor(public readonly value: string) {}
static create(raw: string): ISBN {
const cleaned = raw.replace(/-/g, '');
if (!isValidIsbn13(cleaned)) {
throw new InvalidISBNError(raw);
}
return new ISBN(cleaned);
}
}
â
ISBN.create("978-3-16-148410-0") â instance valide
â ISBN.create("abc") â InvalidISBNError
Une classe par rĂšgle violĂ©e â dĂšs le dĂ©but, pas aprĂšs
export class BookAlreadyBorrowedError extends DomainError {
constructor(bookId: BookId) {
super(
`Book ${bookId.value} is already borrowed`,
'BOOK_ALREADY_BORROWED'
);
}
}
â Bonnes pratiques
BOOK_ALREADY_BORROWEDâ Ă Ă©viter
throw new Error("erreur") â trop gĂ©nĂ©riqueDomainError pour toutAbstraite â l'implĂ©mentation vient demain dans adapters/
export interface BookRepository {
findById(id: BookId): Promise<Book | null>;
save(book: Book): Promise<void>;
findByIsbn(isbn: ISBN): Promise<Book | null>;
}
đĄ Le repository ne parle qu'en termes du domaine (Book, BookId). Pas de BookEntity TypeORM ici.
Orchestre : charge l'agrégat, appelle la méthode métier, sauvegarde
export class BorrowBook implements Usecase<BorrowBookInput, BorrowBookOutput> {
constructor(private bookRepo: BookRepository) {}
async execute(input: BorrowBookInput): Promise<BorrowBookOutput> {
// 1. Charger l'agrégat
const book = await this.bookRepo.findById(input.bookId);
if (!book) throw new BookNotFoundError(input.bookId);
// 2. Appeler la méthode métier (l'agrégat valide)
book.borrow(input.memberId);
// 3. Sauvegarder
await this.bookRepo.save(book);
// 4. Retourner le résultat
return { loanId: ..., dueDate: ... };
}
}
Un InMemoryRepository suffit â on valide la logique mĂ©tier, pas la persistance
export class InMemoryBookRepository implements BookRepository {
private books = new Map<string, Book>();
async findById(id: BookId) {
return this.books.get(id.value) ?? null;
}
async save(book: Book) {
const events = book.pullEvents();
// Rebuild simple : on stocke l'agrégat reconstruit
const restored = Book.restore(events);
this.books.set(book.id.value, restored);
}
}
đĄ Si vos tests passent avec l'InMemoryRepository, ils passeront avec le repository PostgreSQL de demain. Le contrat est le mĂȘme.
â Importer des dĂ©pendances infrastructure dans core/
Pas de typeorm, inversify, express. Si vous en avez besoin, vous ĂȘtes dans le mauvais dossier.
â ïž Oublier les DomainErrors
Ajoutez-les dÚs le début. Une rÚgle métier sans erreur = un bug silencieux.
â AgrĂ©gats trop gros
Un agrégat = une responsabilité. Si Book gÚre aussi les loans, découpez.
â ïž MĂ©langer apply() et logique mĂ©tier
apply() mute l'état sans validation. La validation se fait dans la méthode métier (ex : borrow()).
â
core/ ne dĂ©pend de RIEN d'externe â pas de TypeORM, pas d'Inversify ici.
â Les repositories sont des interfaces abstraites â l'implĂ©mentation vient demain.
â Pattern init/restore : init crĂ©e, restore rejoue l'historique.
â On peut tester le core/ sans base de donnĂ©es avec un InMemoryRepository.
â Ordre : Ă©vĂ©nements â agrĂ©gat â value objects â erreurs â use cases.
Demain : on implĂ©mente adapters/ (TypeORM, mappers, Build) đ