L'erreur à éviter : croire que la composition est un union
L'intuition naïve dit : « composer quatre métamodèles, c'est faire l'union de leurs Concepts ». C'est faux. L'union laisserait quatre îlots disjoints, avec un CommandHandler qui ne sait pas qu'il doit toucher un AggregateRoot, un Span qui ne sait pas qu'il doit envelopper un Handler, un Slice qui ne sait pas qu'il runs un Command. L'utilité du cadre M3 disparaîtrait : on aurait quatre M2 indépendants, et le développeur referait à la main les liens entre eux dans chaque fichier émis.
Le bon mot est produit relationnel : la composition de quatre M2Schemas est l'ensemble de leurs Concepts plus un graphe de CrossRelations entre Concepts de schemas distincts. C'est ce graphe qui rend la composition non-triviale, et c'est ce graphe qui justifie à lui seul l'existence du M3.
L'asset assets/m2-composition.ts pose la composition complète d'OpenStore.
Le diagramme de la composition
Lecture critique du diagramme. Cinq choses méritent l'œil. Premièrement, les quatre subgraphs sont visuellement distincts mais leurs Concepts sont reliés par les arêtes externes — c'est la structure « produit relationnel ». Deuxièmement, le lien DDD.DomainEvent ═══ CQRS.DomainEvent (égalité) matérialise la ConflictPolicy.share : ce sont deux déclarations syntaxiques du même Concept après composition. Troisièmement, les CrossRelations portent des verbes différents (touches, enforces, reacts, runs, wrappedBy, contributes) — chaque verbe encode une sémantique distincte sur ce que la transformation doit faire. Quatrièmement, les CrossRelations sont orientées — Slice runs Command, pas l'inverse. Le sens importe : il y a une cible (le concept qu'on lit) et une source (le concept qu'on annote). Cinquièmement, l'absence d'arête entre Telemetry et DDD directement est intentionnelle — la télémétrie n'instrumente pas le domaine, elle instrumente les handlers qui touchent le domaine. Cette distinction garde le domaine pur (testable sans tracer).
Les CrossRelations, une par une
L'asset assets/m2-composition.ts en déclare huit. Chacune a une raison sémantique précise.
CQRS.CommandHandler ─touches→ DDD.AggregateRoot
Le CommandHandler charge, invoque, persiste un AggregateRoot. C'est la relation la plus fréquemment matérialisée à la main par les développeurs — et c'est la plus précieuse à dériver automatiquement. Le M2C, quand il émet RequestQuoteHandler, sait qu'il faut injecter un QuoteRepository, charger ou créer un Quote, appeler une méthode métier dessus, sauvegarder. Le squelette complet est généré ; seule la logique métier interne au Quote reste un ImplementationSlot.
CQRS.CommandHandler ─enforces→ DDD.AggregateRoot.Invariant
Après chaque mutation, le M2C insère un check des invariants. Pour Quote.totalMatchesLines(), le M2C génère :
async handle(cmd: RequestQuote): Promise<QuoteRequested> {
const quote = /* ... */;
/* @impl: mutation métier */
if (!quote.totalMatchesLines()) {
throw new InvariantViolation('Quote.totalMatchesLines');
}
return /* event */;
}async handle(cmd: RequestQuote): Promise<QuoteRequested> {
const quote = /* ... */;
/* @impl: mutation métier */
if (!quote.totalMatchesLines()) {
throw new InvariantViolation('Quote.totalMatchesLines');
}
return /* event */;
}C'est non-négociable : un handler qui sauvegarderait un Quote dans un état où l'invariant échoue corromprait le domaine. Cette garantie ne dépend pas du soin du développeur — elle est portée par la CrossRelation.
CQRS.Projection ─reacts→ DDD.DomainEvent (et CQRS.DomainEvent partagé)
C'est le pivot du conflit nominatif DomainEvent. La Projection ne sait pas si elle réagit à un event issu de DDD ou issu de CQRS — après composition, c'est le même Concept. La ConflictPolicy.share unifie les deux déclarations syntaxiques en un seul Concept logique.
Si le projet voulait garder les deux distincts (ce qui n'a pas de sens ici mais peut en avoir ailleurs), il utiliserait ConflictPolicy.rename avec { DDD: 'DddDomainEvent', CQRS: 'CqrsDomainEvent' }. La politique est déclarative, située dans la Composition, et explicite. Pas de magie.
VerticalSlice.Slice ─runs→ CQRS.Command | CQRS.Query
C'est la CrossRelation qui résout la commandRef: 'cqrs.RequestQuote' typographique du M1 (Part 06) en référence typée. Après composition, le Megamodel sait que la Slice b2b.request-quote runs le Command RequestQuote ; le M2C peut donc émettre, dans le package de la Slice, l'import correct depuis le package qui contient le Command.
CQRS.CommandHandler ─wrappedBy→ Telemetry.Span et CQRS.QueryHandler ─wrappedBy→ Telemetry.Span
Ce sont les CrossRelations qui forcent l'instrumentation systématique. Sans elles, on a peut-être un Span autour d'un Handler (selon le soin du dev). Avec elles, on a toujours un Span, avec un nom canonique (openstore.${HandlerName}.handle), un attribut tenantId propagé depuis le contexte, et une politique de sampling dérivée du SLO du Command.
VerticalSlice.Behaviour ─contributes→ Telemetry.Metric | Telemetry.Span
Le TelemetryBehaviour dans une Pipeline n'est pas une boîte noire — il contribute des Metrics et des Spans explicites au flux. Le M2C connaît la liste, peut générer la documentation Markdown des métriques produites par chaque Slice, et peut valider qu'un Behaviour annoncé contribue effectivement ce qu'il prétend.
La résolution du conflit DomainEvent
C'est l'illustration complète de la ConflictPolicy. Voici l'extrait pertinent de m2-composition.ts :
const conflicts: ReadonlyArray<ConflictPolicy> = [
{
kind: 'share',
conceptName: 'DomainEvent',
},
];const conflicts: ReadonlyArray<ConflictPolicy> = [
{
kind: 'share',
conceptName: 'DomainEvent',
},
];Trois lignes. Mais voici ce qu'elles font après composition :
- Les deux Concepts (
ddd.DomainEvent,cqrs.DomainEvent) sont unifiés en un Concept logiquecomposition.DomainEvent. - Les Decorators des deux schemas (
@DomainEventde DDD,@DomainEventde CQRS) réfèrent au même Concept — peu importe lequel le développeur importe. - Les Relations des deux schemas qui pointent vers
DomainEventsont toutes valides sur le Concept unifié.AggregateRoot ─emits→ DomainEvent(DDD),CommandHandler ─emits→ DomainEvent(CQRS),Projection ─consumes→ DomainEvent(CQRS) — toutes valides simultanément. - Les Constraints des deux schemas sont toutes appliquées sur le Concept unifié.
C'est ce que veut dire « share » : unifier la nomenclature, conserver l'union des contributions. Les autres politiques (prefer:Schema et rename) servent les cas où l'unification n'est pas sémantiquement souhaitable.
Pourquoi cette composition justifie l'existence du M3
Voici l'argument court : sans M3, la composition serait ad hoc.
Chaque équipe qui voudrait combiner DDD + CQRS + VerticalSlice + Telemetry écrirait son propre script de génération, avec ses propres conventions de noms, ses propres résolutions de conflits implicites, ses propres CrossRelations codées en dur dans le générateur. Quand quelqu'un voudrait changer la sémantique (par exemple, ajouter une CrossRelation Saga ─wrappedBy→ Span), il devrait modifier le script de génération, sans aucune garantie que la modification soit cohérente avec les autres CrossRelations existantes.
Avec M3, la composition est données — un objet Composition typé, exposé par m2-composition.ts, lisible et modifiable comme une liste. Ajouter une CrossRelation est ajouter une entrée dans le tableau. Le TransformerGenerator du M3 (Part 10) lit cette Composition et génère le code TS du transformer qui projette M1-OpenStore vers M1-composé en respectant exactement ces CrossRelations. Pas d'ad hoc. Pas de conventions tacites. Pas de re-codage en dur des relations.
Et les Aspects ?
La série a posé Aspect et Merge comme constructs M3 dans Part 01. Où s'appliquent-ils dans OpenStore ?
L'usage explicite d'Aspect arrive quand on compose plusieurs M1 partiels pour produire un M1 complet. Le cas canonique dans OpenStore : M1-WebApp est la composition de trois aspects : les Slices (collectées depuis le M1-composé), la FastifyConfig (générée par une autre Transformation), et l'AuthAspect (qui sait comment le RBAC tenant-scoped s'applique aux routes).
Aspect<{}, { slices: ReadonlyArray<Slice> }> // aspect-slices
Aspect<{}, { fastify: FastifyConfig }> // aspect-fastify
Aspect<{}, { auth: AuthAspect }> // aspect-auth
Merge<[Slices, Fastify, Auth], WebApp> // merge multi-aspectAspect<{}, { slices: ReadonlyArray<Slice> }> // aspect-slices
Aspect<{}, { fastify: FastifyConfig }> // aspect-fastify
Aspect<{}, { auth: AuthAspect }> // aspect-auth
Merge<[Slices, Fastify, Auth], WebApp> // merge multi-aspectLe Merge est typé, son ConflictPolicy traite les chevauchements (par exemple, si deux Aspects veulent contribuer la même route HTTP, qu'arrive-t-il ?). Pour OpenStore le cas reste simple — les trois Aspects sont disjoints — mais la primitive est nécessaire dans le cadre M3 pour les projets qui composeront des modèles plutôt que de simplement traduire des modèles.
La forme typée de la Composition
L'asset complet :
export const OpenStoreComposition: Composition = {
id: 'openstore.composition',
schemas: [DddSchema, CqrsSchema, VerticalSliceSchema, TelemetrySchema],
crossRelations: [/* huit CrossRelations */],
conflicts: [{ kind: 'share', conceptName: 'DomainEvent' }],
};export const OpenStoreComposition: Composition = {
id: 'openstore.composition',
schemas: [DddSchema, CqrsSchema, VerticalSliceSchema, TelemetrySchema],
crossRelations: [/* huit CrossRelations */],
conflicts: [{ kind: 'share', conceptName: 'DomainEvent' }],
};C'est un objet. Il est sérialisable. Il est inspectable. Il est testable. Un test unitaire peut vérifier que pour chaque CrossRelation, le Concept source et le Concept cible existent dans les schemas listés. Un autre test peut vérifier que chaque ConflictPolicy adresse effectivement un conflit présent. C'est ce que veut dire réifier le metamodel : il devient programmable, pas aspirational.
Pour aller plus loin
- Part 09 — Transformer composite + M2C — où la Composition est consommée par un transformer qui projette M1-OpenStore en code TypeScript.
- Part 10 — Le cadre méta-méta — où le
TransformerGeneratorlit la Composition pour générer le code du transformer automatiquement. - L'asset complet :
assets/m2-composition.tset le M1 composéassets/m1-openstore-composed.ts. - microdsls-and-kernel traite une composition différente — un kernel + N micro-DSLs — qui combine avec celle-ci sans la dupliquer.