Ce que fait concrètement le transformer
Le M1-OpenStore est une dizaine de classes décorées dans assets/m1-openstore.ts. Le M1-composé attendu est une centaine de classes décorées dans les quatre M2 simultanément (cf. assets/m1-openstore-composed.ts qui montre une instance — la projection complète du M1 entier en demanderait plusieurs centaines de lignes). Le transformer composite est le programme qui prend la première forme et produit la seconde. Puis, par enchaînement d'Emitters, transforme la seconde en arbre de fichiers TypeScript répartis sur six packages npm.
Cet article traite la phase opératoire. C'est le chapitre où le cadre M3 se confronte au réel — où chaque concept introduit dans les huit articles précédents se traduit en un comportement de programme.
Le pipeline dans son ensemble
Lecture critique du diagramme. Trois choses méritent l'œil. Premièrement, le DAG est en deux étages — d'abord la transformation M1 → M1-composé (un seul nœud T), puis l'émission M1-composé → fichiers TypeScript (six Emitters parallèles). Cette séparation est cruciale : la transformation est pure (pas d'I/O, pas de side-effects), les Emitters sont side-effectful (écrivent dans la VirtFS). Deuxièmement, les six Emitters s'exécutent en parallèle sur le même Composed ; ils ne se voient pas, ne se coordonnent pas. La cohérence des références cross-packages est assurée par le fait que tous lisent le même M1-composé. Troisièmement, le VirtFS + runFixpoint est un seul nœud final — c'est lui qui détecte les divergences (deux Emitters qui écriraient le même fichier avec des contenus différents = erreur SG0020 EmissionDivergence) et qui commit atomiquement.
Le transformer composite : rules + helpers
L'asset assets/transformer-composite.ts déclare quatre rules et deux helpers. C'est un squelette — le vrai transformer en aurait davantage — mais la structure est représentative.
Helpers
const pascalCase: Helper<[string], string> = {
id: 'pascalCase',
pure: true,
call(input) {
const segments = input.split(/[-_./]/).filter(Boolean);
return segments.map((seg) => seg.charAt(0).toUpperCase() + seg.slice(1)).join('');
},
};
const aggregateNameFromUseCase: Helper<[string], string> = {
id: 'aggregateNameFromUseCase',
pure: true,
call(useCaseId) {
if (useCaseId.startsWith('b2b.')) return 'Quote';
return 'Order';
},
};const pascalCase: Helper<[string], string> = {
id: 'pascalCase',
pure: true,
call(input) {
const segments = input.split(/[-_./]/).filter(Boolean);
return segments.map((seg) => seg.charAt(0).toUpperCase() + seg.slice(1)).join('');
},
};
const aggregateNameFromUseCase: Helper<[string], string> = {
id: 'aggregateNameFromUseCase',
pure: true,
call(useCaseId) {
if (useCaseId.startsWith('b2b.')) return 'Quote';
return 'Order';
},
};Deux helpers représentatifs : un purement utilitaire (pascalCase), un porteur d'une décision métier (aggregateNameFromUseCase mappe les use-cases B2B vers Quote, les autres vers Order). Cette décision est du code TypeScript, écrite par l'humain qui rédige le transformer. Elle vit dans le transformer, pas dans le M1 — c'est connaissance de la chaîne, pas connaissance du métier.
Rules
Quatre rules, illustrant les trois kinds (matched, lazy, called) :
const ruleUseCaseToCommand: Rule<UseCaseSource, CommandTarget> = {
id: 'rule.useCase-to-command',
kind: 'matched',
sourceConceptId: 'UseCase',
targetConceptId: 'Command',
guard: { id: 'guard.command-kind', evaluate(uc) { return uc.kind === 'command'; } },
apply(uc, ctx) {
const name = `${pascalCase.call(uc.id.replace(/^b2[bc]\./, ''))}Command`;
const trace: Trace = {
ruleId: 'rule.useCase-to-command',
sourceElementId: `M1-OpenStore/UseCase/${uc.id}`,
targetElementId: `CQRS.Command/${name}`,
};
ctx.emit(trace);
return { conceptId: 'Command', name, traceId: trace.ruleId };
},
};const ruleUseCaseToCommand: Rule<UseCaseSource, CommandTarget> = {
id: 'rule.useCase-to-command',
kind: 'matched',
sourceConceptId: 'UseCase',
targetConceptId: 'Command',
guard: { id: 'guard.command-kind', evaluate(uc) { return uc.kind === 'command'; } },
apply(uc, ctx) {
const name = `${pascalCase.call(uc.id.replace(/^b2[bc]\./, ''))}Command`;
const trace: Trace = {
ruleId: 'rule.useCase-to-command',
sourceElementId: `M1-OpenStore/UseCase/${uc.id}`,
targetElementId: `CQRS.Command/${name}`,
};
ctx.emit(trace);
return { conceptId: 'Command', name, traceId: trace.ruleId };
},
};Trois choses à observer. Le kind: 'matched' : la rule s'applique automatiquement à toute UseCase. Le guard : filtre les UseCases de kind query (qui ne deviennent pas des Command). L'emit(trace) : enregistre la trace dans le TransformContext, qui sera persistée dans le M1-composé puis dans le code émis comme commentaire // @trace.
La rule ruleHandlerToSpan est kind: 'lazy' — elle s'applique seulement quand un autre Rule produit un CommandHandler. Elle ne scanne pas les UseCases. C'est ce qui évite la duplication : on n'écrit pas trois fois la logique « si UseCase est Command alors aussi Span » ; on écrit « si Handler existe alors Span ».
Application
apply(source: ProductSource): TransformationResult<ComposedTarget> {
const collected: Trace[] = [];
const ctx: TransformContext = {
helpers: this.helpers,
traces: collected,
emit(trace) { collected.push(trace); },
};
/* ... boucle sur source.useCases, applique les rules selon guards, */
/* concatène les outputs, retourne (target, traces). */
}apply(source: ProductSource): TransformationResult<ComposedTarget> {
const collected: Trace[] = [];
const ctx: TransformContext = {
helpers: this.helpers,
traces: collected,
emit(trace) { collected.push(trace); },
};
/* ... boucle sur source.useCases, applique les rules selon guards, */
/* concatène les outputs, retourne (target, traces). */
}C'est idiomatique TypeScript. Pas de framework. Pas de DSL externe. Le transformer est un objet typé qui implémente Transformation<TFrom, TTo>, et son apply() est une fonction normale.
Le M2C : six Emitters, six packages
L'asset assets/m2c-emit-front-back.ts déclare les six Emitters. Chacun produit un sous-arbre du système de fichiers final :
1. @openstore/domain
Émis par DomainEmitter. Contient un fichier par AggregateRoot (packages/openstore-domain/src/quote.ts, order.ts, etc.). Chaque fichier a :
- Un banner
// @generated by emitter.domain sha256:… - Une trace
// @trace ruleId=… source=M1-OpenStore/Aggregate/… target=DDD.AggregateRoot/… - La classe abstraite (les bodies des méthodes métier sont des
ImplementationSlot)
2. @openstore/slices-* (un package par slice)
Émis par SlicesEmitter. Pour chaque Slice, il produit trois fichiers :
package.jsontypé{ name: '@openstore/slice-b2b-request-quote', private: true, main: './src/index.ts' }src/index.tsavec la classe Handler et sonImplementationSlotsrc/feature.tsavec la déclaration@Feature/@Requirement/@AcceptanceCriterionalignérequirements-lib
C'est le split boundary matérialisé : un slice par package npm, déployable, testable, versionnable indépendamment.
3. @openstore/telemetry
Émis par TelemetryEmitter. Contient les constantes de nommage des Spans et les définitions de Metrics. Le M2C dérive le nom du Span depuis le nom du Handler — pas besoin que le développeur le décide.
4. @openstore/front
Émis par FrontOfficeEmitter. Routes SSR + hydratation SPA. Pour OpenStore, cela inclut les routes catalogue, produit, recherche, panier, checkout, compte. Chaque route a un rendering: 'ssr+hydrated-spa' constant — le framework runtime (Next, Astro, Remix) lit cette annotation pour décider de sa stratégie.
5. @openstore/back
Émis par BackOfficeEmitter. SPA admin multi-tenant. Auth RBAC, écrans pour chaque parti, dashboards par tenant. La liste des Slices admin est dérivée des Slices du back-office du M1.
6. @openstore/api
Émis par ApiEmitter. Routes Fastify, une par Slice. La méthode et le path sont lus depuis l'Endpoint de chaque Slice ; aucune information n'est écrite à la main par le développeur.
L'ImplementationSlot dans le code émis
Voici une émission représentative pour le slice b2b.request-quote :
// @generated by emitter.slices sha256:b2brequest12
// @trace ruleId=rule.useCase-to-slice source=M1-OpenStore/UseCase/b2b.request-quote target=VerticalSlice.Slice/b2b.request-quote
export class B2bRequestQuoteHandler {
async handle(_input: unknown): Promise<unknown> {
// @impl slot=B2bRequestQuoteHandler.handle requirement=REQ-b2b.request-quote
throw new Error('@impl: B2bRequestQuoteHandler.handle — fill via humain or AI, gated by AC');
}
}// @generated by emitter.slices sha256:b2brequest12
// @trace ruleId=rule.useCase-to-slice source=M1-OpenStore/UseCase/b2b.request-quote target=VerticalSlice.Slice/b2b.request-quote
export class B2bRequestQuoteHandler {
async handle(_input: unknown): Promise<unknown> {
// @impl slot=B2bRequestQuoteHandler.handle requirement=REQ-b2b.request-quote
throw new Error('@impl: B2bRequestQuoteHandler.handle — fill via humain or AI, gated by AC');
}
}Trois choses :
- Le banner SHA256 identifie l'origine. Si un développeur édite le fichier à la main, le SHA ne correspond plus, et le M2C détecte le drift (cf.
ts-codegen-pipelinecodeSG0010 HandEditDetected). C'est ce qui empêche le code généré de devenir un cauchemar de maintenance. - La trace indique d'où vient ce fichier : quel UseCase du M1, quelle Rule, quel Slice de quel M2. Lecture inverse possible — « d'où vient ce handler ? » a une réponse en une commande grep.
- L'
ImplementationSlotest explicite. Pas un// TODO. Pas unthrow new Error('not implemented')vague. Un slot avec sonrequirementlié, qui sera consommé par l'IA (ou l'humain) pour produire l'implémentation conforme auxAcceptanceCriteriondu Requirement.
Le pattern Req → Feat → AC → Impl à l'œuvre
Le fichier src/feature.ts émis pour chaque Slice :
// @generated by emitter.slices sha256:b2brequestfeat
// @Feature b2b.request-quote
// @Requirement REQ-b2b.request-quote
// @AcceptanceCriterion AC-b2b.request-quote-happy-path
export const FEATURE_B2B_REQUEST_QUOTE = {
id: 'b2b.request-quote',
requirementId: 'REQ-b2b.request-quote',
acceptanceCriteria: ['AC-b2b.request-quote-happy-path'],
} as const;// @generated by emitter.slices sha256:b2brequestfeat
// @Feature b2b.request-quote
// @Requirement REQ-b2b.request-quote
// @AcceptanceCriterion AC-b2b.request-quote-happy-path
export const FEATURE_B2B_REQUEST_QUOTE = {
id: 'b2b.request-quote',
requirementId: 'REQ-b2b.request-quote',
acceptanceCriteria: ['AC-b2b.request-quote-happy-path'],
} as const;C'est la chaîne complète :
- Le RequirementSpec (
REQ-b2b.request-quote) est dérivé de la@UseCase RequestQuotedu M1. Son contenu (« Un client B2B authentifié doit pouvoir demander un devis pour 1..N lignes de produits, avec mode d'expédition souhaité ») est issu de ladescriptionde la UseCase dans le M1, ou ajouté manuellement dans un fichier de spec à côté. - La FeatureSpec (
b2b.request-quote) est la Slice elle-même — c'est elle qui implémente le Requirement. - Les AcceptanceCriterion (
AC-b2b.request-quote-happy-path,AC-b2b.request-quote-no-stock,AC-b2b.request-quote-rate-limited…) sont dérivés des cas de test attendus, déclarés explicitement dans la Slice ou dans un fichier.feature.ts. Chaque AC est consommable par unQualityGate(qui peut être un test vitest, une assertion sur un property-based, un check de SLO). - L'
ImplementationSlotest ce que l'IA remplit pour faire passer tous les AC.
C'est la promesse SOLID + DRY + Req/Feat/AC/Impl tenue en code :
- SRP — un Slice, un Requirement, une Feature.
- OCP — ajouter un Requirement = ajouter un Slice + ses AC. Aucune modification d'un Slice existant.
- LSP / ISP — chaque Handler étend
CommandHandlerBaseet n'expose quehandle(). - DIP — Handler dépend de
QuoteRepository(interface), pas dePostgresQuoteRepository(implémentation). - DRY — la chaîne Req → Feat → AC → Impl n'est jamais répétée à la main ; elle est dérivée par le M2C.
VirtFS et fixpoint — pourquoi c'est essentiel
Le pipeline n'est pas une exécution séquentielle des Emitters. C'est un fixpoint : on relance les Emitters jusqu'à ce que l'arbre de fichiers ne change plus. C'est ce que @frenchexdev/ts-codegen-pipeline fait par construction.
Pourquoi un fixpoint ? Parce que certains Emitters dépendent de la sortie d'autres Emitters. L'ApiEmitter qui émet les routes Fastify a besoin de savoir quels Slices existent — donc il doit s'exécuter après SlicesEmitter. Le BackOfficeEmitter a besoin du Megamodel complet (ce qui inclut les Slices) pour produire les écrans admin. Plutôt que d'ordonner manuellement, on laisse le moteur tourner en boucle ; à chaque tour, chaque Emitter lit ce que les autres ont émis au tour précédent ; quand rien ne change, on commit. C'est idempotent par construction.
Le commit est atomique : soit tous les fichiers sont écrits, soit aucun. Si une erreur survient (un Emitter lève, un EmissionDivergence est détecté, un Constraint est violé), aucune écriture sur disque n'est faite. Le développeur retombe sur son état précédent, intact. C'est ce qui rend le pipeline sûr à exécuter en CI.
Pour aller plus loin
- Part 10 — Le cadre méta-méta — où le transformer composite lui-même devient générable depuis la
Composition. - ts-source-generator — Part 03 : VirtFS, fixpoint, backward-edge — détails sur le moteur sous-jacent.
- L'asset transformer :
assets/transformer-composite.ts; l'asset M2C :assets/m2c-emit-front-back.ts.