Pourquoi excerpts
Le Domain-Driven Design d'Eric Evans (2003) et de Vaughn Vernon (2013) propose une encyclopédie de patterns : Bounded Context, Ubiquitous Language, Domain Event, Anti-Corruption Layer, Specification, Repository, Factory, Module, Layered Architecture, Hexagonal, Onion, Sagas, Process Manager — la liste est longue. Aucun cadre M2 raisonnable ne peut tout encoder. Pour OpenStore, neuf Concepts suffisent à projeter le M1 vers un squelette de domaine exécutable. C'est le principe d'Interface Segregation appliqué au metamodel lui-même : on ne déclare que ce qui sera consommé par les transformer et les emitters.
Les neuf Concepts retenus sont posés dans assets/m2-ddd.ts. Tout autre Concept DDD (Bounded Context, Anti-Corruption Layer, Specification…) est hors périmètre — pas parce qu'il est moins utile, mais parce qu'il n'est pas consommé par les transformations vers CQRS / VerticalSlice / Telemetry qui suivent.
Le diagramme de Concepts
Lecture critique du diagramme. Trois choses sont rendues visibles, et trois choses sont volontairement absentes. Visible : l'héritage AggregateRoot ─is-a→ Entity (un AggregateRoot est d'abord une Entity au sens DDD, avec une identité stable) ; la composition forte AggregateRoot ◇──> Entity/ValueObject (cardinalité 1..*, cycle de vie partagé) ; l'émission unidirectionnelle d'événements (AggregateRoot ──> DomainEvent). Absent : pas de relation entre DomainEvent et qui que ce soit (les événements sont opaques du point de vue du M2 DDD — ils sont publiés, point ; ce sont d'autres M2 — CQRS — qui les consomment) ; pas de relation entre Entity et Entity (DDD interdit les références d'Entity hors du même aggregate, sauf via Association typée par id) ; pas de Service applicatif (couche application, pas couche domaine).
AggregateRoot
L'AggregateRoot est la racine d'une cohérence transactionnelle. Tout changement à l'intérieur de l'aggregate passe par sa racine — pas d'accès direct aux Entity internes depuis l'extérieur. Dans le M1 OpenStore, Quote, Order, PurchaseOrder sont des AggregateRoot. Sur le diagramme assets/m2-ddd.ts, son rôle est central : c'est lui qui est la cible de la Composition, qui émet les DomainEvent, qui enforce ses Invariant, qui est persisté par un Repository.
Le decorator TypeScript @AggregateRoot est un NoopClassDecorator — il ne fait rien à runtime, il marque la classe comme conforme au Concept ddd.AggregateRoot. C'est le transformer qui lit ce marqueur via ts-morph (au sens ts-codegen-pipeline) et qui produit le code de l'aggregate concret dans @openstore/domain.
Entity
Une Entity a une identité stable dans le temps, indépendamment de ses attributs. QuoteLine est une Entity : changer son quantity ne change pas son identité. La propriété @Id est mandatoire (Constraint du M2). Les Entity vivent à l'intérieur d'un aggregate ; elles ne s'échappent jamais. Si une Entity doit être référencée depuis un autre aggregate, c'est par Association (par id, jamais par instance).
ValueObject
Un ValueObject est une valeur sans identité — défini uniquement par ses attributs. QuoteTerms { paymentDays, shippingMode, currency } est un ValueObject : deux instances avec les mêmes valeurs sont égales. Pas d'@Id. Pas de mutation (par convention, le transformer émettra des champs readonly ou un constructeur qui clone). C'est la primitive DDD la plus sous-estimée et celle qui rend les codebases pénibles à maintenir quand elle manque.
DomainService
Un DomainService porte une logique métier qui ne tient pas dans un aggregate unique. Le service QuotePricingService.computeTotals(quote) calcule les totaux d'un devis : c'est de la logique pure (pas d'effet de bord), mais qui peut traverser plusieurs aggregates (le devis, le catalogue de remises, le tarif courant). Le critère de promotion en DomainService : si ça ne va naturellement dans aucun aggregate, c'est probablement un DomainService.
DomainEvent
Un DomainEvent est un fait métier qui a eu lieu. QuoteRequested, QuoteAccepted, OrderPlaced. Immutable, daté, avec un payload sérialisable. Le M2 DDD le déclare. Le M2 CQRS le redéclare aussi (intentionnellement) ; la ConflictPolicy share dans Part 08 résout le conflit en faisant que les deux M2 réfèrent au même Concept. C'est exactement le cas pédagogique le plus important du cadre M3 : un Concept partagé entre deux M2Schemas, fusionné par politique explicite.
Repository
Un Repository est un protocole d'accès à un AggregateRoot. QuoteRepository { findById(tenantId, quoteId), save(quote) }. Pas une classe concrète : une interface (donc, dans le M1 TypeScript, une abstract class). L'implémentation concrète vit dans la couche infrastructure (@openstore/infrastructure-postgres, @openstore/infrastructure-firestore, etc.) qui n'est pas émise par cette série mais que rien n'empêche d'émettre dans un projet réel. Le Repository est ce qui rend le domaine injectable — Dependency Inversion à l'œuvre.
Composition, Association, Invariant
Ces trois decorators sont des modificateurs appliqués à des propriétés ou méthodes :
@Compositionmarque une propriété comme possession forte (cycle de vie partagé).Quote.lines: ReadonlyArray<QuoteLine>— supprimer leQuotesupprime sesQuoteLine.@Associationmarque une référence faible (par id).Quote.tenantId: TenantId— leTenantIdexiste indépendamment.@Invariantmarque une méthode comme règle métier inviolable.Quote.totalMatchesLines()doit toujours retournertrueaprès chaque mutation. Le transformer va générer un invariant check automatique à la fin de chaque CommandHandler qui touche l'aggregate (cf. Part 08, la CrossRelationCommandHandler ─enforces→ AggregateRoot).
Le M2Schema déclaratif
Tout ce qui précède n'est pas seulement un ensemble de decorators. C'est un M2Schema typé au sens du M3, exposé par assets/m2-ddd.ts :
export const DddSchema: M2Schema = {
id: 'ddd',
concepts: [/* AggregateRoot, Entity, ValueObject, ... */],
decorators: [/* @AggregateRoot maps to Concept AggregateRoot, ... */],
relations: [
{ id: 'aggregate-composes-entity', kind: 'composes', fromConceptId: 'AggregateRoot', toConceptId: 'Entity', cardinality: '1..*' },
{ id: 'aggregate-emits-event', kind: 'emits', fromConceptId: 'AggregateRoot', toConceptId: 'DomainEvent', cardinality: '1..*' },
{ id: 'repo-persists-aggregate', kind: 'associates',fromConceptId: 'Repository', toConceptId: 'AggregateRoot',cardinality: '1..1' },
/* ... */
],
constraints: [/* aggregate-has-id, ... */],
};export const DddSchema: M2Schema = {
id: 'ddd',
concepts: [/* AggregateRoot, Entity, ValueObject, ... */],
decorators: [/* @AggregateRoot maps to Concept AggregateRoot, ... */],
relations: [
{ id: 'aggregate-composes-entity', kind: 'composes', fromConceptId: 'AggregateRoot', toConceptId: 'Entity', cardinality: '1..*' },
{ id: 'aggregate-emits-event', kind: 'emits', fromConceptId: 'AggregateRoot', toConceptId: 'DomainEvent', cardinality: '1..*' },
{ id: 'repo-persists-aggregate', kind: 'associates',fromConceptId: 'Repository', toConceptId: 'AggregateRoot',cardinality: '1..1' },
/* ... */
],
constraints: [/* aggregate-has-id, ... */],
};C'est ce DddSchema que le TransformerGenerator du M3 (Part 10) lit pour générer automatiquement le code TypeScript d'une Transformation DDD → CQRS. Sans cette réification déclarative, le metamodel serait inerte — un commentaire dans la doc. Avec elle, il devient programmable.
Le conflit DomainEvent annoncé
Le DomainEvent est déclaré ici dans le M2 DDD. Il sera re-déclaré dans le M2 CQRS de Part 05. Cette duplication est intentionnelle — c'est le cas pédagogique que la série utilise pour démontrer la ConflictPolicy.share dans Part 08. Tant qu'on regarde DDD seul, il n'y a pas de conflit ; le conflit n'existe qu'au moment de la composition.
C'est important parce que cela illustre une propriété structurelle du M3 : les M2Schemas sont écrits indépendamment, en suivant leur logique interne, sans présupposer avec quels autres M2 ils seront composés. La résolution des chevauchements est différée à la Composition, pas anticipée dans les M2Schema. Cela respecte l'Open/Closed Principle : on peut ajouter un cinquième M2Schema sans toucher aux quatre premiers.
Exemple d'usage M1 : Quote
L'asset montre l'aggregate Quote complet :
@AggregateRoot
export class Quote {
@Id id!: string;
@Association tenantId!: string;
@Association customerId!: string;
@Composition lines!: ReadonlyArray<QuoteLine>;
@Composition terms!: QuoteTerms;
@Invariant
totalMatchesLines(): boolean {
return true; // @impl
}
}@AggregateRoot
export class Quote {
@Id id!: string;
@Association tenantId!: string;
@Association customerId!: string;
@Composition lines!: ReadonlyArray<QuoteLine>;
@Composition terms!: QuoteTerms;
@Invariant
totalMatchesLines(): boolean {
return true; // @impl
}
}Notez : le body de totalMatchesLines() est return true — c'est un ImplementationSlot. La vraie logique (somme des quantity * unitPrice égale au total) sera fournie par l'humain ou l'IA, après que le transformer aura généré le squelette. Le M1 n'a pas besoin de connaître l'implémentation pour décrire que l'invariant existe — c'est précisément la séparation que le cadre M3 défend.
Ce qu'on assume de ne pas reprendre
- Bounded Context explicite — implicite ici (un projet = un Bounded Context). Une série voisine pourrait promouvoir le Bounded Context au rang de Concept, mais ce serait un autre M2, pas le DDD-de-base.
- Anti-Corruption Layer — pertinent en intégration multi-systèmes, hors périmètre pour le shop OpenStore qui n'a pas de système legacy à interfacer.
- Specification — pattern souvent confondu avec
Validatordu M2 VerticalSlice. Cette série traite la validation via@Validation(M2 VerticalSlice), pas via@Specification(DDD). - Factory — émise automatiquement par le transformer (chaque AggregateRoot reçoit un
create(...)statique), donc pas exposée comme Concept M2 explicite.
Pour aller plus loin
- Part 05 — Métamodèle CQRS (excerpts) — où
DomainEventest re-déclaré, préparant le conflitshare. - Part 08 — Composer DDD × CQRS × VerticalSlice × Telemetry — où le conflit est effectivement résolu.
- L'asset complet :
assets/m2-ddd.ts. - metacratie-compilateur — Part 08-5 : State.Economy.Dsl — un autre cas d'aggregate large (économie d'État), traité avec un decorator vocabulary similaire.