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 VerticalSlice, et pourquoi excerpts

L'architecture Vertical Slice (Jimmy Bogard, 2018, sur les travaux de Greg Young et Steven van Deursen) répond à un problème pratique : les architectures en couches (controller / service / repository) imposent un couplage par niveau qui rend chaque feature changeante en cinq fichiers éparpillés. Vertical Slice replie ce problème en disant : une feature = un dossier qui contient tout ce qu'il faut, du HTTP à la persistance. Pour le M2C de cette série, c'est encore plus radical : une feature = un package npm.

Pour OpenStore, six Concepts suffisent. La liste est dans assets/m2-vertical-slice.ts. Tout ce qui pourrait s'ajouter (Cache, RateLimit, Idempotency) tient comme Behaviour supplémentaire dans une Pipeline — donc pas besoin de promouvoir ces préoccupations au rang de Concept.

Le diagramme des Concepts

Diagram

Lecture critique du diagramme. Trois choses méritent l'œil. Premièrement, Slice est marqué <<root>> — c'est lui qui matérialise la frontière de packaging. Tout ce qui dépend de Slice est dans le même package npm émis ; tout ce qui n'en dépend pas est dans un autre. Cette décision-là cascade jusqu'aux frontières du déploiement et aux frontières d'équipes. Deuxièmement, Pipeline ne contient pas de Behaviour par cardinalité forcée — la cardinalité 0..* autorise une slice sans pipeline (rare mais possible : un read pur sans auth ni validation pour une route publique non-sensible). Troisièmement, Authorization et Validation héritent de Behaviour — c'est l'application de Liskov : tout Behaviour peut être substitué dans la chaîne, indépendamment de sa spécialisation. Ajouter un RateLimit ou un IdempotencyCheck revient à ajouter une sous-classe ; aucune modification de la Pipeline.

Slice — le pivot

Une Slice est une feature autonome : nom métier (b2b.request-quote, b2c.buy-now), un endpoint HTTP, un command ou query, son pipeline de behaviours. Trois propriétés non-négociables sur une Slice :

  1. Single Responsibility au sens strict — une Slice = un RequirementSpec. Si la même Slice est censée satisfaire deux requirements, elle doit être scindée. Cette Constraint est enforçable à la composition : le TransformerGenerator rejette le M1 où une @UseCase est dépendante de deux @Requirement distincts.
  2. Stand-alone — la Slice n'importe que de @openstore/domain et @openstore/telemetry. Elle n'importe jamais d'une autre Slice (@openstore/slice-b2b-issue-po ne peut pas importer @openstore/slice-b2b-request-quote). C'est ce qui garantit que chaque Slice est testable, déployable, et remplaçable indépendamment.
  3. Lit le métier dans le nom — le nom de la Slice doit être lisible par le métier. b2b.request-quote se lit ; B2bService.requestQuote() ne se lit pas, ou pas pareil. Cette discipline de naming est enforcée par le RequirementSpec.id qui doit matcher le Slice.id (Constraint du M2).

Endpoint — la projection HTTP

Un Endpoint est la façade HTTP de la Slice : method ('POST' | 'GET' | ...), path (/b2b/quotes). C'est un Concept séparé de Slice parce qu'une même Slice peut exposer plusieurs Endpoints (POST /b2b/quotes pour créer, GET /b2b/quotes/:id pour consulter — bien que la convention recommande d'avoir une Slice par couple Command/Query pour rester SRP).

Le M2C de Part 09 émet automatiquement les routes Fastify dans @openstore/api, en s'appuyant sur Endpoint.method et Endpoint.path. Le binding Endpoint → Slice est unidirectionnel : le M2C ne demande pas à l'humain de remplir un fichier de routes — il est dérivé.

Pipeline et Behaviour

La Pipeline est la chaîne ordonnée de Behaviours qu'une Slice exécute avant d'invoquer son CommandHandler (ou QueryHandler). C'est le pendant du middleware Express, mais typé. Un Behaviour a deux hooks : before() (exécuté avant le handler) et after() (exécuté après). L'ordre des Behaviours dans la chaîne est la sémantique : [Authorization, Validation, Telemetry][Telemetry, Authorization, Validation] — dans le premier cas, on n'instrumente pas les requêtes rejetées par auth ; dans le second, si.

Le M2C émet la Pipeline comme une classe statique typée. Pas de bus de behaviours dynamique. Pas d'enregistrement à runtime. La chaîne est décidée à la génération et inscrite dans le code émis. Ce choix échange flexibilité contre traçabilité — on peut lire le code généré et voir exactement quels Behaviours s'exécutent dans quel ordre, sans avoir à analyser un container DI.

Authorization et Validation

Deux Behaviours spécialisés, suffisamment universels pour être Concepts séparés.

Authorization porte un requiredRole. Le M2C dérive de ce nom de rôle la logique de check (lecture du JWT, comparaison avec le rôle du parti, échec 403). La déclaration @Authorization class B2bCustomerAuthz { readonly requiredRole = 'b2b.customer' as const } est suffisante — pas besoin de logique métier dans cette classe ; c'est le M2C qui produit le code.

Validation est plus complexe parce que la logique de validation est métier (les règles d'un RequestQuoteValidator ne sont pas devinables). Le M2C émet la classe RequestQuoteValidator avec sa méthode validate(input): { ok, errors } — corps en ImplementationSlot. L'humain ou l'IA remplit ; la quality gate (un AcceptanceCriterion) garantit que les cas de test passent.

Le M2Schema déclaratif

L'asset assets/m2-vertical-slice.ts :

export const VerticalSliceSchema: M2Schema = {
  id: 'vertical-slice',
  concepts: [/* Slice, Endpoint, Pipeline, Behaviour, Authorization, Validation */],
  decorators: [/* @Slice, @Endpoint, @Pipeline, @Behaviour, @Authorization, @Validation */],
  relations: [
    { id: 'slice-exposes-endpoint',    kind: 'composes',  fromConceptId: 'Slice',         toConceptId: 'Endpoint',  cardinality: '1..*' },
    { id: 'slice-runs-pipeline',       kind: 'runs',      fromConceptId: 'Slice',         toConceptId: 'Pipeline',  cardinality: '1..1' },
    { id: 'pipeline-chains-behaviour', kind: 'composes',  fromConceptId: 'Pipeline',      toConceptId: 'Behaviour', cardinality: '0..*' },
    /* auth/validation héritent de behaviour */
  ],
  constraints: [
    { id: 'slice-is-srp', conceptId: 'Slice', check: () => [] },
  ],
};

La Constraint slice-is-srp est le filet de sécurité Open/Closed : un projet qui voudrait ajouter une feature transversale (cache, rate limit) doit le faire via un Behaviour additionnel, pas en élargissant la Slice — sinon check() émet un ConstraintViolation.

Exemple d'usage M1 : RequestQuoteSlice

@Slice
export class RequestQuoteSlice {
  readonly id = 'b2b.request-quote' as const;
  readonly endpoint = RequestQuoteEndpoint;
  readonly pipeline = RequestQuotePipeline;
  readonly commandRef = 'cqrs.RequestQuote' as const;
}

@Pipeline
export class RequestQuotePipeline {
  readonly chain = [B2bCustomerAuthz, RequestQuoteValidator, TelemetryBehaviour] as const;
}

@Authorization
export class B2bCustomerAuthz {
  readonly requiredRole = 'b2b.customer' as const;
}

Notez la propriété commandRef: elle est une référence textuelle ('cqrs.RequestQuote') plutôt qu'une référence directe à la classe RequestQuote. Pourquoi ? Parce que la Slice est dans son propre package npm, séparé du package qui contient le Command. La référence par chaîne est résolue par le Megamodel lors de la transformation, après que tous les Concepts ont été collectés. C'est exactement la mécanique des références par id en MDE classique, transposée à TypeScript via une simple string typée.

Pourquoi une Slice = un package npm

Ce choix mérite défense. Trois arguments :

  1. Déploiement — un package versionné peut être déployé indépendamment. Si la Slice b2b.consolidated-shipping doit évoluer, son package monte en version sans toucher au reste.
  2. Équipes — un package = un CODEOWNERS. Les Slices sont des candidats naturels à être assignées à des équipes distinctes (l'équipe b2b gère b2b.*, l'équipe b2c gère b2c.*).
  3. Test — un package est testable en isolation. La Slice peut avoir ses propres tests d'acceptance (les AcceptanceCriterion projetés par le M2C) sans avoir à monter tout le shop.

Le coût : un monorepo avec ~12 packages au lieu d'un seul. C'est acceptable avec un workspace pnpm correctement configuré. Le M2C émet le package.json de chaque Slice avec la cohérence nécessaire.

Ce qu'on assume de ne pas reprendre

  • Hexagonal Architecture explicite (ports/adapters) — la Slice est l'hexagone implicite. L'Endpoint est l'adapter inbound HTTP, le Repository est l'adapter outbound persistence. Pas besoin de promouvoir ces patterns au rang de Concepts séparés.
  • MediatR-like dispatch dynamique — le M2C émet l'invocation directe handler.handle(command) plutôt que mediator.send(command). Lisibilité > extensibilité runtime.
  • OpenAPI / Swagger autogénération — pertinent en vrai projet, mais c'est un autre Emitter dans le Megamodel, pas un Concept du M2.

Pour aller plus loin

⬇ Download