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

Command Query Responsibility Segregation tient en une phrase : la lecture et l'écriture ne sont pas la même opération, donc elles ne doivent pas passer par les mêmes objets. Greg Young l'a popularisé en 2010 ; Udi Dahan en avait posé les bases en 2008 ; le pattern est ancien — Bertrand Meyer en parlait déjà sous le nom Command-Query Separation dans Object-Oriented Software Construction en 1988. Ce que CQRS apporte de spécifiquement utile pour OpenStore, c'est la séparation architecturale : un côté écriture qui mute l'état via CommandCommandHandlerDomainEvent, et un côté lecture qui consulte via QueryQueryHandlerReadModel. Les deux côtés sont dénormalisés différemment parce qu'ils servent des préoccupations différentes.

Pour OpenStore, neuf Concepts CQRS suffisent à projeter chaque @UseCase du M1 en infrastructure exécutable. La liste est dans assets/m2-cqrs.ts.

Le diagramme — write side vs read side

Diagram

Lecture critique du diagramme. Quatre choses méritent l'œil. Premièrement, le diagramme est bipartite : Command/CommandHandler/EventStore à gauche (write side), Query/QueryHandler/ReadModel à droite (read side), Projection au milieu (le seul Concept qui traverse la frontière). Deuxièmement, DomainEvent est marqué <<shared with DDD>> — la résolution ConflictPolicy.share traitée Part 08 fait que ce stéréotype est informationnel, le Concept réel sera unique après composition. Troisièmement, Saga est isolée — elle consomme des DomainEvent mais ne touche pas directement au read side ni au write side ; elle orchestre via de nouveaux Command, ce qui boucle indirectement vers le write side (non représenté pour ne pas surcharger). Quatrièmement, l'absence de relation directe Command → AggregateRoot ou Query → ReadModel via le Repository n'est pas un oubli : c'est intentionnellement cross-M2, traité dans la Composition (Part 08) sous forme de CrossRelation.

Command et CommandHandler

Un Command est une intention de mutation d'état. RequestQuote { tenantId, customerId, lines, shippingMode }. Trois propriétés non-négociables : immutable (les champs sont posés à la construction), sérialisable (peut circuler via API ou message bus), intentionnel (le nom décrit ce que l'utilisateur veut faire, pas l'état désiré — RequestQuote, pas CreateQuoteEntity).

Le CommandHandler est le seul objet autorisé à muter l'aggregate. RequestQuoteHandler.handle(cmd): Promise<QuoteRequested>. La signature standard est (command) → DomainEvent | DomainEvent[]. Le handler charge l'aggregate via Repository, invoque la méthode métier, capture les events émis, persiste, retourne. Le M2C de Part 09 émet le squelette complet et laisse le corps métier en ImplementationSlot.

Une Constraint forte du M2 CQRS : CommandHandler est 1..1 avec Command — exactement un handler par command. C'est ce qui rend le système traçable (un événement → une cause).

Query et QueryHandler

Une Query est une question — GetQuoteById { tenantId, quoteId }. Pas de mutation, jamais. Le QueryHandler lit depuis un ou plusieurs ReadModel et retourne un DTO. La distinction avec Command Handler est architecturale : QueryHandler n'a pas accès aux Repositories du write side. Cela force la projection des données lues via le mécanisme DomainEvent → Projection → ReadModel.

DomainEvent (partagé)

DomainEvent apparaît ici comme dans le M2 DDD (Part 04). C'est intentionnel. Sans cette duplication, le M2 CQRS ne serait pas auto-suffisant — il ne pourrait pas dire CommandHandler emits DomainEvent ou Projection consumes DomainEvent sans référencer un Concept qui n'existe pas dans son schema. La ConflictPolicy.share (Part 08) fait que les deux déclarations réfèrent au même Concept après composition.

Pour OpenStore, exemples : QuoteRequested, QuoteAccepted, OrderPlaced, OrderShipped. Chaque event est émis par exactement un CommandHandler et consommé par zéro, une ou plusieurs Projection / Saga.

EventStore

L'EventStore est l'infrastructure de persistance des DomainEvent. Append-only, ordre stable par aggregate, sérialisation versionnée. Le M2 CQRS le déclare comme Concept pour pouvoir relier EventStore stores DomainEvent, mais l'implémentation concrète (PostgreSQL avec table events, EventStoreDB, Kafka topic compacté) est hors du périmètre de l'émission — c'est la couche infrastructure configurée à runtime.

Projection et ReadModel

La Projection est le pont entre les deux côtés. Elle réagit aux DomainEvent (@Reacts onQuoteRequested(event)) et met à jour un ou plusieurs ReadModel. C'est la primitive qui permet d'avoir des vues dénormalisées du domaine sans que les Aggregates aient à se soucier de la forme des lectures.

Un ReadModel est une forme dénormalisée taillée pour une Query. QuoteSummary { quoteId, tenantId, total, status } est un ReadModel : tous les champs nécessaires à l'écran de liste des devis, dans un seul objet plat. Le ReadModel n'a pas d'invariants — c'est un DTO de projection, pas un aggregate. Il peut être recalculé entièrement en rejouant l'EventStore depuis le début. C'est cette propriété qui rend CQRS robuste aux changements de schéma de lecture : on jette le ReadModel, on rejoue les events, on en a un nouveau.

Saga

Une Saga est un process manager : elle écoute des DomainEvent qui surviennent dans l'écosystème et, en réaction, émet de nouveaux Command qui font progresser un workflow long. Pour OpenStore : ConsolidatedShippingSaga écoute les PurchaseOrderIssued et, quand assez d'orders sont prêts pour le même client, émet un ConsolidateShipmentCommand. Conceptuellement, c'est un coordinateur — pas un aggregate (pas d'état métier propre), pas un service (l'orchestration est durable, pas synchrone).

Le M2C de Part 09 émet un squelette de Saga avec ses @Reacts onXxx(event) méthodes vides — c'est dense en ImplementationSlot, parce que la logique d'orchestration est par nature spécifique au métier.

Le M2Schema déclaratif

Comme pour DDD, l'asset assets/m2-cqrs.ts expose le M2Schema typé :

export const CqrsSchema: M2Schema = {
  id: 'cqrs',
  concepts: [/* Command, Query, CommandHandler, ..., DomainEvent (intentionnellement redéclaré), ... */],
  decorators: [/* @Command, @Query, @CommandHandler, ..., @Handles (method), @Reacts (method) */],
  relations: [
    { id: 'handler-handles-command', kind: 'runs',     fromConceptId: 'CommandHandler', toConceptId: 'Command',     cardinality: '1..1' },
    { id: 'handler-emits-event',     kind: 'emits',    fromConceptId: 'CommandHandler', toConceptId: 'DomainEvent', cardinality: '1..*' },
    { id: 'projection-consumes-event',kind: 'reacts',  fromConceptId: 'Projection',     toConceptId: 'DomainEvent', cardinality: '1..*' },
    /* ... */
  ],
  constraints: [
    { id: 'command-mutates', conceptId: 'Command', check: () => [] },
    { id: 'query-is-pure',   conceptId: 'Query',   check: () => [] },
  ],
};

La Constraint query-is-pure est programmatique : son check() peut inspecter un M1 et émettre un ConstraintViolation si un QueryHandler modifie l'état observable (par exemple en mutant un objet partagé). C'est ce qui transforme le M2 en contrat exécutable, pas un simple commentaire d'architecture.

Exemple d'usage M1 : RequestQuote

L'asset montre la chaîne complète pour une seule capability B2B :

@Command
export class RequestQuote {
  tenantId!: string;
  customerId!: string;
  lines!: ReadonlyArray<{ readonly productSku: string; readonly quantity: number }>;
  desiredShippingMode!: 'standard' | 'express' | 'consolidated';
}

@DomainEvent
export class QuoteRequested {
  quoteId!: string;
  tenantId!: string;
  customerId!: string;
  occurredAt!: string;
}

@CommandHandler
export class RequestQuoteHandler {
  @Handles
  async handle(_command: RequestQuote): Promise<QuoteRequested> {
    throw new Error('@impl');
  }
}

@Projection
export class QuoteSummaryProjection {
  @Reacts
  onQuoteRequested(_event: QuoteRequested): void {
    throw new Error('@impl');
  }
}

Notez la cohérence : RequestQuoteHandler.handle retourne QuoteRequested, QuoteSummaryProjection.onQuoteRequested le consomme — la chaîne est typée bout-en-bout. Le M2C en émet un squelette complet, le métier (validation de la demande, calcul du devis, persistance du ReadModel) va dans les ImplementationSlot.

Ce qu'on assume de ne pas reprendre

  • Event Sourcing au sens strict (état reconstruit uniquement depuis l'EventStore) — possible avec ce M2 mais pas imposé. Le projet peut tenir un snapshot de l'aggregate en base relationnelle aussi. La décision est runtime, pas M2.
  • CommandBus / QueryBus abstrait — émis par le M2C comme code concret typé, pas comme abstraction publique. Ajouter une couche de bus pluggable serait un autre M2 (Messaging) qu'on pourrait composer avec celui-ci.
  • Acks asynchronesCommandHandler retourne directement le résultat dans cette série. Pour des Commands qui doivent attendre l'acquittement de plusieurs bounded contexts, un M2 AsyncCommand séparé serait pertinent. Pas dans OpenStore.

Pour aller plus loin

⬇ Download