Les primitives communes à tous les bounded contexts
Bootcode IWA-S04 — Semaine 18, Jour 1
1. Explorer le package
Connaître le rôle de chaque fichier de packages/tools/ddd/
2. AggregateRoot & applyChange
Comprendre le lien entre AggregateRoot, applyChange et @Handle
3. Primitives en action
ValueObject, DomainEvent, Usecase, Query, Mapper, DomainError
4. Diagramme de classes
Être capable de dessiner l'architecture du package
Vue d'ensemble du package
Les briques de base du codebase
AggregateRoot
La classe mère de tout agrégat event-sourcé
ValueObject, DomainEvent, Usecase
Les primitives du domaine et de l'orchestration
Query, Mapper, DomainError
Lecture, persistence et erreurs typées
Un vrai agrégat du codebase
Rendre tout ça concret
Module 1
packages/tools/ddd/ — le socle commun
On évite de réinventer la roue dans chaque bounded context
Le package ddd centralise les abstractions que tous les contextes métiers partagent.
🧱
AggregateRoot
Tout agrégat en hérite
📦
ValueObject
Valeurs immuables
⚡
DomainEvent
Événements métier
⚙️
Usecase
Orchestration
🗺️
Mapper
Persistence
🛡️
DomainError
Erreurs typées
Module 2
La classe mère de tout agrégat
props
L'état mutable de l'agrégat, typé et encapsulé
uncommittedChanges
Les événements nouvellement produits, pas encore persistés
applyChange(event)
Enregistre l'événement ET appelle le handler @Handle
@Handle(EventClass)
Décorateur qui relie un événement à sa mutation d'état
💡 Tout agrégat métier hérite de cette classe. C'est le coeur du package.
Le squelette du package packages/tools/ddd/AggregateRoot.ts
// packages/tools/ddd/AggregateRoot.ts
export abstract class AggregateRoot<Props> {
protected props: Props;
private uncommittedChanges: DomainEvent[] = [];
protected constructor(props: Props) {
this.props = props;
}
protected applyChange(event: DomainEvent): void {
this.uncommittedChanges.push(event);
this.handleEvent(event);
}
private handleEvent(event: DomainEvent): void {
// @Handle relie la classe d'événement à sa méthode
}
getUncommittedEvents(): DomainEvent[] {
return [...this.uncommittedChanges];
}
}
💡 applyChange fait 2 choses : stocker l'événement ET muter l'état.
Relie un événement à la méthode qui mute l'état.
// Décorateur
export function Handle(eventClass: any) {
return function (
target: any,
propertyKey: string
) {
const meta =
Reflect.getMetadata('handles', target) || {};
meta[eventClass.name] = propertyKey;
Reflect.defineMetadata(
'handles', meta, target
);
};
}
// Utilisation dans un agrégat
class BankAccount extends AggregateRoot<BankAccountProps> {
@Handle(MoneyDeposited)
onMoneyDeposited(event: MoneyDeposited) {
this.props.balance += event.payload.amount;
}
}
🚨 Attention : @Handle est pour la mutation SYNCHRONE dans l'agrégat, pas un handler asynchrone externe.
Module 3
Données immuables et messages métier
Identifié par ses attributs, pas par un ID. Immuable et comparable par valeur.
// packages/tools/ddd/ValueObject.ts
export abstract class ValueObject<Props> {
protected constructor(
readonly props: Props
) {}
equals(other: ValueObject<Props>): boolean {
return JSON.stringify(this.props) ===
JSON.stringify(other.props);
}
}
// Exemple : Email
class Email extends ValueObject<{ value: string }> {
static create(value: string): Email {
return new Email({ value });
}
}
💡 Deux ValueObjects avec les mêmes attributs sont égaux.
Représente quelque chose qui s'est passé dans le passé. Immutable. Porte un payload et des métadonnées.
// packages/tools/ddd/DomainEvent.ts
export abstract class DomainEvent {
public readonly id: string;
public readonly occurredAt: Date;
public readonly aggregateId: string;
protected constructor(
aggregateId: string,
public readonly payload: any
) {
this.id = crypto.randomUUID();
this.occurredAt = new Date();
this.aggregateId = aggregateId;
}
}
💡 Un événement = nom au passé + payload + métadonnées (id, date, aggregateId).
Module 4
Orchestration, lecture, persistence et erreurs
Orchestre une action métier : autorisation, validation, appel du domaine, persistence.
// packages/tools/ddd/Usecase.ts
export abstract class Usecase<Input, Output> {
abstract execute(input: Input): Promise<Output>;
}
// Exemple concret
class DepositMoneyUsecase extends Usecase<
{ accountId: string; amount: number },
void
> {
constructor(
private repository: BankAccountRepository
) {}
async execute(input): Promise<void> {
const account = await this.repository.findById(input.accountId);
account.deposit(input.amount);
await this.repository.save(account);
}
}
Représente une question qu'on pose au système. Le C du CQRS côté lecture.
// packages/tools/ddd/Query.ts
export abstract class Query<Result> {
abstract execute(): Promise<Result>;
}
// Exemple concret
class GetAccountBalanceQuery extends Query<number> {
constructor(
private readonly accountId: string,
private readonly readModel: AccountReadModel
) {}
async execute(): Promise<number> {
return this.readModel.getBalance(this.accountId);
}
}
💡 Usecase = écriture. Query = lecture. On sépare les responsabilités.
Fait le pont entre le domaine et la persistence. Convertit Entity/Aggregat ↔ DTO de persistence.
// packages/tools/ddd/Mapper.ts
export interface Mapper<Domain, Persistence> {
toDomain(raw: Persistence): Domain;
toPersistence(domain: Domain): Persistence;
}
// Exemple : BankAccountMapper
class BankAccountMapper implements Mapper<BankAccount, BankAccountRow> {
toDomain(raw: BankAccountRow): BankAccount {
return BankAccount.restore(raw.id, raw.events);
}
toPersistence(account: BankAccount): BankAccountRow {
return {
id: account.props.id,
events: account.getUncommittedEvents(),
};
}
}
Erreur métier typée avec un code machine-readable et un message humain.
// packages/tools/ddd/DomainError.ts
export abstract class DomainError extends Error {
protected constructor(
public readonly code: string,
message: string
) {
super(message);
}
}
// Exemple d'usage
class InsufficientFundsError extends DomainError {
constructor() {
super(
'INSUFFICIENT_FUNDS',
'Le solde est insuffisant pour effectuer ce retrait.'
);
}
}
🛡️ Chaque invariant métier mérite SA propre DomainError.
Module 5
Le package ddd en action
Un vrai agrégat qui hérite de AggregateRoot et utilise plusieurs primitives.
// bounded-contexts/iac/domain/User.ts
class User extends AggregateRoot<UserProps> {
static init(email: Email): User {
const user = new User({ id: crypto.randomUUID(), email });
user.applyChange(new UserCreated(user.props.id, { email: email.props.value }));
return user;
}
changeEmail(newEmail: Email): void {
this.applyChange(new UserEmailChanged(
this.props.id,
{ email: newEmail.props.value }
));
}
@Handle(UserCreated)
onUserCreated(event: UserCreated) {
this.props.email = Email.create(event.payload.email);
}
@Handle(UserEmailChanged)
onUserEmailChanged(event: UserEmailChanged) {
this.props.email = Email.create(event.payload.email);
}
}
💡 Ici on voit AggregateRoot + ValueObject (Email) + DomainEvent + @Handle travailler ensemble.
L'architecture en une image.
AggregateRoot
applyChange, @Handle
DomainEvent
id, occurredAt, aggregateId
ValueObject
immutable, equals()
Usecase
écriture / commande
Query
lecture
DomainError
code + message
Mapper ↔ Repository ↔ Usecase ↔ AggregateRoot
Le Mapper fait le pont entre le monde persistence et le domaine
❌ Trop de primitives d'un coup
Les étudiants paniquent face à AggregateRoot, ValueObject, DomainEvent, Usecase, Mapper...
✅ Une par une
On va utiliser chaque primitive au moment où on en a besoin. Pas besoin de tout retenir aujourd'hui.
❌ Confondre @Handle et handler asynchrone
@Handle est une mutation SYNCHRONE dans l'agrégat. Les handlers asynchrones arrivent en S20.
✅ @Handle = mutation d'état interne
Il relie un événement à la méthode qui met à jour props. Rien à voir avec les subscribers.
❌ applyChange est de la magie
Si on ne comprend pas le flux, on ne sait pas où est l'état.
✅ applyChange = enregistrer + muter
Deux étapes claires : push dans uncommittedChanges, puis appel du handler @Handle.
1. Package ddd = socle commun
Tous les bounded contexts partagent ces primitives.
2. AggregateRoot est la base
props, uncommittedChanges, applyChange, @Handle.
3. ValueObject & DomainEvent
Immuables : valeurs comparées par contenu, événements au passé.
4. Usecase, Query, Mapper, DomainError
Ces abstractions structurent l'architecture CQRS et la gestion d'erreurs.
Demain : on plonge dans le mécanisme d'event sourcing en profondeur.