// Architecture Réactive — Gregor Hohpe · Greg Young · Uncle Bob

EDA · CQRS · SAGA
NestJS

Le triptyque des architectures réactives modernes — Event-Driven Architecture, Command Query Responsibility Segregation, Pattern SAGA, intégrés dans NestJS avec TypeScript.

NestJSTypeScriptEvent-DrivenCQRSSAGARxJSEvent Sourcing
01

Le Triptyque

EDA, CQRS et SAGA ne sont pas trois architectures alternatives — ce sont trois couches complémentaires qui s’emboîtent. L’EDA pose le canal de communication (les événements). Le CQRS sépare les responsabilités lecture/écriture. Le SAGA orchestre les transactions distribuées sur ce canal.

Couche 1 — Communication
EDA
Les services communiquent via des événements immuables. Couplage minimal. Producteurs et consommateurs s’ignorent.
Couche 2 — Organisation
CQRS
Les commandes mutent l’état. Les queries lisent des vues optimisées. Jamais les deux en même temps.
Couche 3 — Coordination
SAGA
Transactions distribuées via des séquences d’événements avec compensation en cas d’échec. Pas de 2PC global.
PRODUCTEURSSvc Commandeemit()Svc Paiementemit()SAGA Orchestratoremit()EVENT BUSorder.created{ orderId, customerId, amount, timestamp }payment.completed{ orderId, transactionId, amount }stock.reserved{ orderId, items[], warehouseId }order.cancelled{ orderId, reason, compensations[] }Kafka · RabbitMQ · NATS · AWS EventBridgeCONSOMMATEURSSvc Livraisonsubscribe()Svc Notificationsubscribe()Projections CQRSsubscribe()SAGA Handlersubscribe()Pas de couplage directEvenements immuables — faits passesReagissent independamment
EDA — Producteurs, Event Bus, Consommateurs
02

Métaphores

📻

EDA — La Station de Radio

L’émetteur diffuse dans l’éther sans savoir combien d’auditeurs l’écoutent. Les auditeurs s’abonnent librement aux fréquences qui les intéressent. Personne n’attend personne. Un événement est un fait passé immuable : “La commande #42 a été passée”, jamais “Passe la commande #42”.

👨‍🍳

CQRS — Le Restaurant Étoilé

La brigade de cuisine (Write Side) reçoit les bons de commande, transforme et modifie l’état. Les serveurs en salle (Read Side) lisent le tableau des plats et servent. La cuisine ne parle jamais directement aux clients. Les serveurs ne touchent jamais aux fourneaux. Le tableau des plats du jour est une projection dénormalisée mise à jour dès qu’un plat change.

✈️

SAGA — L’Agent de Voyage

Tu réserves vol + hôtel + voiture : trois entreprises différentes. Impossible de faire un BEGIN TRANSACTION global. L’agent de voyage coordonne séquentiellement : vol ✅ → hôtel ✅ → voiture ❌ → annuler l’hôtelannuler le vol. Chaque étape est une transaction locale. Chaque échec déclenche des actions de compensation explicites en sens inverse.

03

EDA — Concepts Fondamentaux

📤
Producteur
Publisher / Emitter
Publie un evenement sans savoir qui l'ecoutera. Ne retourne rien. Ne connait aucun consommateur. Couplage zero vers l'aval.
fire & forgetno return
🚌
Event Bus
Message Broker
Canal de transport. Garantit livraison, routage par topic, persistance. C'est un detail d'infrastructure — derriere une interface.
KafkaRabbitMQNATS
📥
Consommateur
Subscriber / Listener
S'abonne aux evenements qui l'interessent. Reagit de maniere autonome. Plusieurs consommateurs peuvent ecouter le meme evenement.
subscribeidempotent

Structure d’un Événement

Règle fondamentale : Un événement est un fait passé, immuable, daté. Il décrit ce qui s’est passé — pas ce qui doit se passer. Nommez-le au passé composé : OrderCreated, PaymentCompleted, jamais CreateOrder.
domain-event.interface.ts
// Interface de base — tout evenement respecte ce contrat interface DomainEvent { readonly eventId: string; // UUID — identifiant unique de l'event readonly eventType: string; // 'order.created' — discriminant readonly aggregateId: string; // ID de l'entite concernee readonly occurredAt: Date; // Quand c'est arrive (passe) readonly version: number; // Pour l'ordering et la concurrence readonly payload: Record<string, unknown>; } // Evenement concret — type class OrderCreatedEvent { constructor( public readonly orderId: string, public readonly customerId: string, public readonly items: ReadonlyArray<{ productId: string; quantity: number; price: number }>, public readonly totalAmount: number, public readonly occurredAt: Date = new Date(), ) {} }
04

EDA — Les 3 Patterns

🔔
Pattern 1
Simple Event Notification
Fire & forget. Le producteur emet, les consommateurs reagissent. Decouplage maximal. Ideal pour les notifications et les side-effects non-critiques.
AsyncDecoupleSimple
📚
Pattern 2
Event Sourcing
L'etat de l'application = rejouer la sequence des evenements. L'Event Store est la source de verite. Audit trail parfait. Time travel possible.
Append-onlyReplayAudit
Pattern 3
CQRS + Event Sourcing
Write Side base sur Event Sourcing. Read Side base sur des projections denormalisees reconstruites depuis les events. Scalabilite R/W independante.
ScalableEventualOptimise

Event Bus — Implémentation TypeScript

in-memory-event-bus.ts
type Handler<T> = (event: T) => Promise<void>; interface IEventBus { publish<T extends DomainEvent>(event: T): Promise<void>; subscribe<T extends DomainEvent>(eventType: string, handler: Handler<T>): void; } // In-Memory — pour les tests et le monolithe class InMemoryEventBus implements IEventBus { private readonly handlers = new Map<string, Set<Handler<any>>>(); subscribe<T>(eventType: string, handler: Handler<T>): void { if (!this.handlers.has(eventType)) this.handlers.set(eventType, new Set()); this.handlers.get(eventType)!.add(handler); } async publish<T extends DomainEvent>(event: T): Promise<void> { const handlers = this.handlers.get(event.eventType) ?? new Set(); // Parallel — les consommateurs sont independants await Promise.allSettled([...handlers].map(h => h(event))); } }
05

CQRS — Séparation Read / Write

CQRS applique à l’architecture ce qu’Uncle Bob applique aux fonctions dans Clean Code : une fonction fait UNE chose — soit elle répond à une question, soit elle exécute une action, jamais les deux. CQRS étend ce principe à l’ensemble du système.

COMMAND — Intention de changer l’état. Retourne void ou un ID. Valide. Produit des Events.
QUERY — Demande de lire l’état. Retourne un DTO. Ne modifie JAMAIS l’état. Fonction pure.
PROJECTION — Consomme des Events. Maintient une vue READ optimisée. Idempotente. Rejouable.
ControllerAPICommandWRITE SIDECommandHandlerDomainAggregateEvent Storeappend-onlyEvent BusEventualConsistencyProjectionHandlerREAD SIDEorders_viewcustomer_viewanalytics_viewinventory_viewQuery HandlerQueryDTO (lecture seule)Write DB = Event Store (source de verite)Read DB = Vue denormalisee (0 JOIN)Pas de SELECT iciPas de UPDATE/DELETE iciSuper-pouvoir : Replay — reconstruire une projection depuis position 0
CQRS — Write Side, Projections, Read Side

Projections — Le cycle de vie

Super-pouvoir du Replay : Si une projection est corrompue ou si une nouvelle vue est nécessaire, on relit l’Event Store depuis le début. Les events étant immuables, le replay produit toujours exactement le même résultat. C’est le Time Travel gratuit de l’Event Sourcing.
order-summary.projection.ts
interface IProjection { readonly eventTypes: ReadonlyArray<string>; handle(event: DomainEvent): Promise<void>; reset(): Promise<void>; // Pour le replay complet } class OrderSummaryProjection implements IProjection { readonly eventTypes = [ 'order.created', 'order.cancelled', 'payment.completed', 'order.shipped' ]; constructor(private readonly db: IOrderSummaryRepo) {} async handle(event: DomainEvent): Promise<void> { switch (event.eventType) { case 'order.created': await this.db.upsert({ orderId: event.payload.orderId, status: 'PENDING', totalAmount: event.payload.totalAmount, itemCount: event.payload.items.length, createdAt: event.occurredAt.toISOString(), }, ['orderId']); // Cle de deduplication — idempotence break; case 'payment.completed': await this.db.updateStatus(event.payload.orderId, 'PAID'); break; case 'order.shipped': await this.db.updateStatus(event.payload.orderId, 'SHIPPED'); break; case 'order.cancelled': await this.db.updateStatus(event.payload.orderId, 'CANCELLED'); break; } } async reset(): Promise<void> { await this.db.truncate(); // Prete pour le replay complet } }
06

SAGA — Transactions Distribuées

Le pattern SAGA résout le problème des transactions qui s’étendent sur plusieurs services. Il n’existe pas de BEGIN TRANSACTION global en distribué. La SAGA remplace l’atomicité ACID par une séquence d’étapes locales, chacune réversible via une action de compensation.

Idempotence — Non Négociable : Les brokers garantissent at-least-once delivery. Ton service de paiement peut recevoir le même message deux fois. Sans idempotence, tu débites deux fois le client. Solution : stocker le sagaId comme clé unique de déduplication.

Choreography vs Orchestration

💃
Style 1
Choreography
Chaque service connait ses voisins via des evenements. Pas de coordinateur central. Simple a mettre en place.
✓ Pas de SPOF✓ Simple✗ Logique dispersee✗ Debug difficile
🎼
Style 2
Orchestration
Un Orchestrateur central coordonne tous les services. Logique centralisee, lisible, monitorable. Services participants restent simples.
✓ Lisible✓ Monitorable✗ SPOF potentiel✗ Couplage central
SAGA STATE MACHINESTARTORDER_PENDINGPAYMENT_PENDINGSTOCK_PENDINGSHIP_PENDINGCOMPLETEDetat terminalCOMPENSATION (ROLLBACK DISTRIBUE)cancelOrdercancelOrder + refundPaymentcancelOrder + refundPayment + releaseStockCANCELLEDorder.createdpayment.donestock.reservedshipped→ Chemin heureux↓ Chemin de compensation (rollback distribue)Idempotence — CritiqueStocker sagaId comme cle unique. Verifier avant chaque etape. at-least-once delivery = messages dupliques possibles.
SAGA State Machine — Commande e-commerce

Code — SAGA Orchestrateur

create-order.saga.orchestrator.ts
type SagaStatus = | 'ORDER_PENDING' | 'PAYMENT_PENDING' | 'STOCK_PENDING' | 'COMPLETED' | 'COMPENSATING_ORDER' | 'COMPENSATING_PAYMENT' | 'CANCELLED'; interface SagaState { readonly sagaId: string; readonly orderId: string; readonly totalAmount: number; readonly status: SagaStatus; readonly transactionId?: string; readonly compensationReason?: string; } // Reducer pur — pattern Redux applique au SAGA const sagaReducer = (state: SagaState, event: SagaEvent): SagaState => { const transitions: Record<string, (s: SagaState) => SagaState | null> = { 'order.created': s => s.status === 'ORDER_PENDING' ? { ...s, status: 'PAYMENT_PENDING' } : null, 'payment.done': s => s.status === 'PAYMENT_PENDING' ? { ...s, status: 'STOCK_PENDING' } : null, 'stock.reserved': s => s.status === 'STOCK_PENDING' ? { ...s, status: 'COMPLETED' } : null, 'payment.failed': s => s.status === 'PAYMENT_PENDING' ? { ...s, status: 'COMPENSATING_ORDER' } : null, 'stock.failed': s => s.status === 'STOCK_PENDING' ? { ...s, status: 'COMPENSATING_PAYMENT' } : null, }; const next = transitions[event.step]?.(state); return next ?? state; // Idempotence : pas de transition = etat inchange };
07

NestJS — Module & Structure

@nestjs/cqrs fournit trois bus (CommandBus, QueryBus, EventBus) et quatre décorateurs (@CommandHandler, @QueryHandler, @EventsHandler, @Saga) qui s’injectent via le DI de NestJS. CqrsModule.forRoot() câble tout automatiquement.

src/ ├── order/ # Feature module │ ├── commands/ │ │ ├── create-order.command.ts │ │ ├── cancel-order.command.ts │ │ └── handlers/ │ ├── events/ │ │ ├── order-created.event.ts │ │ ├── order-cancelled.event.ts │ │ └── handlers/ # @EventsHandler -> projection │ ├── queries/ │ │ ├── get-order-by-id.query.ts │ │ └── handlers/ │ ├── domain/ │ │ ├── order.aggregate.ts # extends AggregateRoot │ │ └── value-objects/ │ ├── sagas/ │ │ └── order.saga.ts # @Saga() Observable<ICommand> │ ├── infrastructure/ │ │ ├── orm-entities/ │ │ ├── repositories/ │ │ └── mappers/ │ ├── order.controller.ts │ └── order.module.ts └── app.module.ts
order.module.ts
import { Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CommandHandlers } from './commands'; import { QueryHandlers } from './queries'; import { EventHandlers } from './events'; import { OrderSaga } from './sagas/order.saga'; import { OrderRepositoryImpl } from './infrastructure/repositories/order.repository.impl'; // Token DI pour l'inversion de dependance export const ORDER_REPOSITORY = Symbol('ORDER_REPOSITORY'); @Module({ imports: [ CqrsModule, // Injecte CommandBus, QueryBus, EventBus TypeOrmModule.forFeature([OrderEventOrmEntity, OrderSummaryView]), ], controllers: [OrderController], providers: [ ...CommandHandlers, // CreateOrderHandler, CancelOrderHandler ...QueryHandlers, // GetOrderByIdHandler ...EventHandlers, // OrderCreatedHandler -> projection OrderSaga, // @Saga() RxJS pipeline { provide: ORDER_REPOSITORY, useClass: OrderRepositoryImpl }, ], }) export class OrderModule {}
08

NestJS — AggregateRoot & Handlers

order.aggregate.ts
import { AggregateRoot } from '@nestjs/cqrs'; export class OrderAggregate extends AggregateRoot { private _id: string = ''; private _status: 'PENDING' | 'PAID' | 'SHIPPED' | 'CANCELLED' = 'PENDING'; private _totalAmount: number = 0; // Factory — logique metier, pas de constructeur public static create(customerId: string, items: OrderItem[]): OrderAggregate { if (items.length === 0) throw new Error('Commande vide'); const order = new OrderAggregate(); const total = items.reduce((s, i) => s + i.price * i.quantity, 0); order.apply(new OrderCreatedEvent(crypto.randomUUID(), customerId, items, total)); return order; } cancel(reason: string): void { if (this._status === 'CANCELLED') return; // Idempotence if (this._status === 'SHIPPED') throw new Error('Impossible d\'annuler une commande expediee'); this.apply(new OrderCancelledEvent(this._id, reason)); } // Handlers appeles par apply() et loadFromHistory() onOrderCreatedEvent(e: OrderCreatedEvent): void { this._id = e.orderId; this._status = 'PENDING'; this._totalAmount = e.totalAmount; } onOrderCancelledEvent(_e: OrderCancelledEvent): void { this._status = 'CANCELLED'; } }
09

NestJS — @Saga() & RxJS

Le décorateur @Saga() de NestJS transforme un flux d’événements (Observable) en flux de commandes (Observable<ICommand>). C’est l’intégration native du pattern Choreography avec RxJS.

Ne jamais laisser l’Observable mourir : Si le pipeline RxJS lance une exception non catchée, le SAGA cesse de fonctionner silencieusement. Toujours encadrer avec catchError((err, src) => { logger.error(err); return src; }) pour relancer la source.
order.saga.ts
import { Injectable, Logger } from '@nestjs/common'; import { ICommand, ofType, Saga } from '@nestjs/cqrs'; import { Observable } from 'rxjs'; import { map, filter, catchError } from 'rxjs/operators'; @Injectable() export class OrderSaga { private readonly logger = new Logger(OrderSaga.name); // SAGA 1 : Commande creee -> declencher le paiement @Saga() orderCreated = (events$: Observable<any>): Observable<ICommand> => events$.pipe( ofType(OrderCreatedEvent), map(event => { this.logger.log(`Saga: order ${event.orderId} -> processPayment`); return new ProcessPaymentCommand(event.orderId, event.customerId, event.totalAmount); }), catchError((err, src) => { this.logger.error(err); return src; }), ); // SAGA 2 : Paiement OK -> reserver le stock @Saga() paymentDone = (events$: Observable<any>): Observable<ICommand> => events$.pipe( ofType(PaymentCompletedEvent), map(e => new ReserveStockCommand(e.orderId, e.transactionId)), catchError((err, src) => { this.logger.error(err); return src; }), ); }
10

Pièges & Règles Uncle Bob

!
Publier avant de persister. mergeObjectContext(aggregate) publie les events immediatement dans apply(). Si le save() echoue ensuite, les projections sont corrompues et l'etat est incoherent. Regle : persister d'abord, publishAll() ensuite.
!
Observable SAGA sans catchError. Une exception non catchee termine l'Observable silencieusement. Le SAGA cesse de fonctionner sans aucun log d'erreur. Toujours : catchError((err, src) => { logger.error(err); return src; }).
!
EventBus synchrone dans le meme process. Par defaut, EventBus.publish() en NestJS est synchrone dans le meme processus. Pour une vraie EDA distribuee (Kafka, RabbitMQ), brancher un publisher custom via @nestjs/microservices ou un adaptateur externe.
!
Logique metier dans l'Orchestrateur. L'orchestrateur SAGA dirige quoi faire, pas comment. Des qu'il contient une regle metier (calcul, condition autre que le statut), c'est une violation. La regle va dans l'Agregat, pas dans la SAGA.
!
@Entity() TypeORM sur l'Agregat. La violation la plus repandue en NestJS. L'Agregat appartient au domaine — zero dependance vers @nestjs/typeorm. Utiliser le double modele : Agregat de domaine + ORM Entity + Mapper.
Récapitulatif des décorateurs @nestjs/cqrs :

@CommandHandler(MyCmd) → implements ICommandHandler → execute(cmd) → Promise<R>
@QueryHandler(MyQuery) → implements IQueryHandler → execute(qry) → Promise<R>
@EventsHandler(MyEvt) → implements IEventHandler → handle(evt) → Promise<void>
@Saga() → méthode retournant → Observable<ICommand> (RxJS pipeline)
11

Quand adopter ce triptyque ?

SignalEDA seulEDA + CQRSEDA + CQRS + SAGA
Ratio lectures/ecritures✓ Equilibre✓ Asymetrique (100:1)✓ Asymetrique
Transactions distribuees⚠ Difficile⚠ Difficile✓ Natif
Audit trail requis⚠ Partiel✓ Event Sourcing✓ Complet
Vues de lecture complexes⚠ JOINs lourds✓ Projections optimisees✓ Projections optimisees
Taille equipe✓ 2+ devs⚠ 5+ devs✗ 8+ devs recommande
Coherence forte requise✗ Eventual✗ Eventual✗ Eventual — compromis
Complexite domaine⚠ Moyenne✓ Elevee (DDD)✓ Tres elevee (DDD)
Exemple concretNotifications, webhooksE-commerce, reportingPaiement, reservation, logistique
🏛️

La Règle Uncle Bob appliquée au triptyque

Dans Architecture Logicielle Propre, Uncle Bob insiste sur la Dependency Rule : les dépendances pointent vers l’intérieur. Dans ce triptyque : le domaine (Agrégat) ne dépend de rien. Le Use Case (CommandHandler) dépend du domaine. L’infrastructure (TypeORM, Kafka) dépend du Use Case. Et le bus est l’abstraction frontière qui traverse tout — le producteur ne connaît jamais le consommateur.

EDA / Events
Read Side / Consommateurs
Write Side / Commands
CQRS / SAGA
Infrastructure / NestJS
Domaine / Agregat