Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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

Diagram

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';
  },
};

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 };
  },
};

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). */
}

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.json typé { name: '@openstore/slice-b2b-request-quote', private: true, main: './src/index.ts' }
  • src/index.ts avec la classe Handler et son ImplementationSlot
  • src/feature.ts avec la déclaration @Feature / @Requirement / @AcceptanceCriterion aligné 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');
  }
}

Trois choses :

  1. 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-pipeline code SG0010 HandEditDetected). C'est ce qui empêche le code généré de devenir un cauchemar de maintenance.
  2. 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.
  3. L'ImplementationSlot est explicite. Pas un // TODO. Pas un throw new Error('not implemented') vague. Un slot avec son requirement lié, qui sera consommé par l'IA (ou l'humain) pour produire l'implémentation conforme aux AcceptanceCriterion du 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;

C'est la chaîne complète :

  1. Le RequirementSpec (REQ-b2b.request-quote) est dérivé de la @UseCase RequestQuote du 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 la description de la UseCase dans le M1, ou ajouté manuellement dans un fichier de spec à côté.
  2. La FeatureSpec (b2b.request-quote) est la Slice elle-même — c'est elle qui implémente le Requirement.
  3. 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 un QualityGate (qui peut être un test vitest, une assertion sur un property-based, un check de SLO).
  4. L'ImplementationSlot est 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 CommandHandlerBase et n'expose que handle().
  • DIP — Handler dépend de QuoteRepository (interface), pas de PostgresQuoteRepository (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

⬇ Download