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

Pourquoi un fil rouge réaliste

La pédagogie classique du MDE travaille avec des exemples-jouets : un metamodel UML-vers-UML, un PetriNet-vers-StateMachine, un metamodel d'expression arithmétique. Ces exemples fonctionnent, mais ils ne sollicitent qu'une fraction des contraintes qu'un cadre réel doit absorber. Ils ne posent pas de question d'authentification multi-tenant. Ils ne distinguent pas un slice B2C single-click d'un slice B2B avec négociation de termes. Ils ne mélangent pas SEO/SSR et SPA hydratée. Et surtout, ils ne forcent jamais le cadre à composer plusieurs métamodèles simultanément.

Le mini-shop OpenStore, lui, force toutes ces choses. Il n'est pas exhaustif (un Shopify-killer demanderait dix fois plus de capabilities) mais il a la bonne forme — celle qui révèle les choix de conception du cadre M3 sans noyer la pédagogie. Cet article décrit OpenStore comme M1 : ce qu'il fait, qui l'utilise, comment il s'écrit en TypeScript déclaratif. Le M1 complet est dans assets/m1-openstore.ts.

La surface fonctionnelle

Diagram

Lecture critique du diagramme. L'asymétrie entre B2C (six use-cases courts) et B2B (cinq use-cases longs) est importante : un BuyNow B2C est atomique (clic → événement OrderPlaced), un IssuePurchaseOrder B2B est processuel (devis, négociation, validation hiérarchique, émission). Cette différence va se traduire dans le M2C : les slices B2C émettent un seul handler synchrone ; les slices B2B émettent un handler + une saga + des projections. La Tenancy n'est pas une capability au sens fonctionnel — c'est une préoccupation transversale qui pénètre les douze use-cases. Les Channels (front et back office) ne sont pas des use-cases non plus, ce sont les cibles d'émission : @openstore/front reçoit les routes SEO + SPA hydratée, @openstore/back reçoit l'admin SPA multi-tenant. Le mindmap montre la surface ; il ne montre pas les croisements (B2C × FrontOffice est un produit ; B2B × BackOffice en est un autre).

Les use-cases B2C : le métier du visiteur

Le B2C couvre l'expérience d'un visiteur ou d'un client final particulier. Six use-cases suffisent à couvrir le cycle de vie complet :

  • BrowseCatalog — navigation dans le catalogue, faceted search, filtres. Lecture seule, optimisée SEO (SSR), URL canonique stable (/c/:slug, /p/:sku).
  • BuyNow — achat single-click depuis une page produit. SLO de p95 < 800ms, déclaré explicitement dans le M1 — la déclaration sera projetée comme AcceptanceCriterion testable par le M2C.
  • AddToCart / Checkout — flux panier classique, état côté client + persistance côté serveur, paiement.
  • TrackOrder — consultation de l'état d'une commande, lecture pure depuis un ReadModel projeté par CQRS.
  • ReturnItem — saga partielle, qui orchestre la demande, l'inspection, le refund.

Tous ces use-cases ont une propriété en commun : leur logique métier peut être devinée à 80 %. Un BuyNow est toujours : valider stock + créer order + débiter + émettre OrderPlaced. Le générateur peut produire le squelette complet, et l'IA implémente les 20 % spécifiques (politique de retour, taxe régionale, fraude).

Les use-cases B2B : le métier du parti négociant

Le B2B est plus subtil. Un acheteur B2B demande un devis, le négocie, émet un PO (purchase order), reçoit une facture avec termes (NET30, NET60), et bénéficie d'une consolidation d'expédition pour grouper plusieurs commandes :

  • RequestQuote — demande de devis avec lignes, quantités, mode d'expédition souhaité. Produit un Quote en état draft.
  • NegotiateTerms — itère sur les conditions (prix unitaires, délais paiement, remises de gros). Le Quote peut traverser plusieurs cycles de propositions et contre-propositions.
  • IssuePurchaseOrder — convertit un Quote accepté en PurchaseOrder. C'est un autre aggregate, pas une mutation du Quote — choix DDD assumé.
  • InvoiceWithTerms — émet une facture avec termes (NET30, paiement par virement, IBAN du tenant).
  • ConsolidatedShipping — regroupe plusieurs PurchaseOrder en une seule expédition pour réduire les frais.

Ces use-cases ne peuvent pas être devinés par le générateur. Les règles de remise de gros sont spécifiques à chaque tenant. Les termes de paiement dépendent du secteur (l'industrie n'a pas les mêmes que la distribution). Les taxes dépendent de la juridiction de l'acheteur et du vendeur. C'est précisément ici que les ImplementationSlot sont les plus denses, et c'est précisément ici que la valeur du cadre M3 se voit : le squelette est généré, les contrats sont explicites, l'IA ou l'humain implémente sans se demander où mettre la logique.

La tenancy : trois niveaux d'isolation

OpenStore est multi-tenant de fait : un tenant = une boutique, qui héberge plusieurs partis (vendor principal, distributeurs, comptables, support), chacun ayant plusieurs utilisateurs :

@Tenancy
export class TenantIsolation {
  readonly isolationLevel = 'row-level' as const;
  readonly partyKinds = ['vendor', 'distributor', 'accountant', 'support'] as const;
}

Trois niveaux d'isolation, donc :

  1. Niveau tenanttenantId est un préfixe obligatoire sur toute opération. Le M2C garantit que chaque endpoint, chaque query handler, chaque projection consomme un tenantId validé. C'est une Constraint du M2 VerticalSlice : aucun @Endpoint ne peut être généré sans tenant scope.
  2. Niveau parti — RBAC par parti (vendor ne voit pas la comptabilité du distributor). Géré au niveau du @Behaviour Authorization, projeté par le M2C dans chaque slice.
  3. Niveau utilisateur — RBAC fin par utilisateur au sein d'un parti. Hors périmètre du squelette généré ; chaque tenant configure ses rôles fins via la couche de configuration runtime.

Le choix isolationLevel: 'row-level' est important. Schema-per-tenant serait l'autre option mais imposerait un binaire par tenant (cf. hors-périmètre). Le M2C n'émet qu'un binaire générique ; la séparation tenant passe par les requêtes SQL (WHERE tenant_id = ?) et les contrôles applicatifs, pas par la duplication d'infrastructure.

Les channels : front office SEO + SPA hydratée, back office admin

Deux applications distinctes sortent du M2C :

@Channel
export class FrontOffice {
  readonly id = 'front-office' as const;
  readonly rendering = 'ssr+hydrated-spa' as const;
  readonly seoCriticalRoutes = ['/c/:slug', '/p/:sku', '/search'] as const;
}

@Channel
export class BackOffice {
  readonly id = 'back-office' as const;
  readonly rendering = 'spa' as const;
  readonly auth = 'tenant-scoped-rbac' as const;
}

Le front office est servi en SSR pour les routes critiques au référencement (page catégorie, page produit, recherche), et bascule en SPA hydratée après chargement initial (panier, checkout, compte client). Le M2C émet sous @openstore/front un mix de modules SSR (rendu côté serveur via le framework du tenant — Astro, Next, Remix, au choix de la configuration runtime) et de modules client-side (composants hydratés).

Le back office est purement SPA, derrière auth tenant-scoped RBAC. Le M2C émet sous @openstore/back une application qui consomme @openstore/slices et @openstore/api pour offrir aux partis du tenant les écrans d'administration : gestion catalogue, suivi commandes, gestion clients B2B, comptabilité, support. Pas de SEO, pas de SSR — performance et ergonomie privilégiées.

Pourquoi cette forme et pas une autre

Trois choix méritent d'être justifiés explicitement, parce qu'ils orientent le reste de la série.

B2C et B2B ensemble, pas B2C seul. Une série qui ne couvrirait que B2C laisserait croire que le cadre M3 est suffisant pour des Handlers atomiques courts. C'est faux : la vraie valeur apparaît quand les Handlers deviennent processuels (sagas, projections multiples, contrats négociés). Le B2B force le cadre à se justifier dans le cas difficile, dès l'article 9.

Multi-tenant dès le M1, pas en post-traitement. Le multi-tenant doit être une décoration à tous les Concepts, pas une couche infrastructure ajoutée après. La capabilité TenantIsolation est donc dans le M1 (pas dans un fichier de configuration séparé). Conséquence : le M2C, quand il émet un handler, sait dès la première ligne que ce handler doit valider tenantId. Pas de couche d'aspect-weaving après coup.

Front et back ensemble, pas API + SPA. Une architecture qui s'arrête à l'API (les routes Fastify) laisse la moitié du problème non résolue : où vivent les écrans ? Le M2C de cette série assume que les écrans sont des cibles d'émission au même titre que les routes — @openstore/front et @openstore/back sortent du même pipeline que @openstore/api. C'est un choix : une série voisine pourrait justifier de découpler complètement (équipes front/back séparées) ; ici, on assume le full-stack TypeScript et on l'assume jusqu'au bout.

Ce que le M1 OpenStore ne dit pas

Le M1 est intentionnellement incomplet. Il ne dit pas :

  • Quelle base de données — c'est la responsabilité de la configuration runtime, pas du M1.
  • Quels Behaviours du Pipeline VerticalSlice sont activés pour chaque slice — c'est dans le M2 VerticalSlice, traité Part 06.
  • Quels endpoints exposent quelles routes HTTP — c'est généré, pas écrit à la main, à partir des @UseCase et de la capability associée.
  • Comment les slices sont déployés (monorepo unique ou multi-repos) — c'est une décision d'infrastructure, hors du M1.

C'est la propriété attendue d'un bon M1 : exprimer le métier sans pré-supposer la solution. Si l'on devait changer de framework HTTP (Fastify → Hono) ou de stratégie de rendu front (SSR → SSG pur), le M1 ne bougerait pas. Seuls les Emitters changeraient.

Pour aller plus loin

⬇ Download