Aller au contenu
Retour aux projets
Plan d'architectureProjet personnelEn cours11 min de lecture14 sections

OrderFlow

Plateforme de commandes et d'exécution événementielle

Traitement des commandes bâti sur des agrégats DDD et CQRS. Réservation de stock, paiement et exécution s'exécutent en workflows asynchrones publiés via un outbox transactionnel, avec des consommateurs idempotents, une gestion des reprises et des files de rebut, et l'état des commandes diffusé aux clients à chaque changement.

Conception d'agrégats, cohérence transactionnelle et workflows distribués de longue durée.

  • .NET 10
  • EF Core 10
  • Dapper
  • RabbitMQ
  • MassTransit
  • DDD
  • CQRS
  • SignalR

Projet personnel — pas encore de dépôt public

01

Le problème qu'elle sert à travailler

Le travail d'intégration industrielle est chargé en protocoles, en débit et en fiabilité, et léger en processus métier transactionnel. Une commande, c'est l'inverse : peu de données, beaucoup de règles attachées, qui traversent un workflow réparti sur plusieurs services et capable d'échouer à mi-parcours.

Ce projet existe donc pour travailler l'autre moitié du terrain : conception d'agrégats, cohérence transactionnelle, cohérence à terme, et la mécanique nécessaire quand ces deux dernières doivent coexister.

02

Les couches et la règle de dépendance

APIla plus externe — connaît tout, n'est connue de personne
  • Minimal APIs
  • SignalR hubs
  • OpenAPI
  • DI composition
Infrastructureimplémente les ports que l'application déclare
  • EF Core 10
  • Dapper
  • MassTransit
  • Outbox
  • Hangfire
Applicationorchestre — aucune persistance, aucun transport
  • Commands
  • Queries
  • Handlers
  • Validators
  • Ports
Domainela plus interne — ne dépend de rien
  • Aggregates
  • Value objects
  • Domain events
  • Invariants

La règle est appliquée par des tests d'architecture plutôt que par la discipline : un test vérifie que l'assembly Domaine ne référence aucun type ASP.NET Core, EF Core, Dapper, MassTransit, Hangfire ou Serilog. Une convention que personne ne peut enfreindre par accident valait mieux qu'un schéma dans un README.

Les dépendances pointent vers l'intérieur. Rien dans les deux bandes internes ne peut nommer quoi que ce soit des deux bandes externes.

L'intérêt de la règle de dépendance n'est pas la propreté : c'est que la partie coûteuse à changer — les règles métier — ne puisse pas être entraînée par une décision sur une base de données ou un broker de messages. Remplacer RabbitMQ est une modification d'infrastructure. Changer le sens d'une commande confirmée n'en est pas une, et cela ne devrait pas être possible depuis là.

03

L'agrégat Order

Order est la frontière de cohérence. Les lignes, les totaux et les transitions d'état ne sont atteints qu'à travers lui, ce qui signifie que chaque invariant n'a qu'un seul endroit où il peut être violé, et donc un seul endroit où il doit être défendu.

  • L'argent est un value object, pas un decimal. Un montant sans devise est un bug qui attend un second marché.
  • OrderId, CustomerId et ProductId sont des types distincts. Passer un identifiant de client là où un identifiant de produit est attendu ne devrait pas compiler.
  • Les transitions d'état sont des méthodes qui imposent les mouvements légaux, pas une propriété modifiable.
  • Ajouter une ligne à une commande confirmée est refusé par l'agrégat, pas par un validateur de la couche application.
04

Commandes et requêtes

Les commandes expriment une intention métier et modifient exactement un agrégat. Les requêtes renvoient des modèles de lecture façonnés pour l'appelant et ne modifient rien. Cette séparation n'est pas un cérémonial : c'est ce qui permet d'optimiser le côté lecture sans négocier avec le côté écriture.

CôtéExemplesRègles
CommandesCreateOrder, ConfirmOrder, CancelOrder, ReserveInventoryNommées d'après l'intention, un seul agrégat, idempotentes par clé
RequêtesGetOrder, GetOrderHistory, GetOrdersDashboardAucune écriture, aucune entité de domaine dans les réponses, paginées

Les entités de domaine n'atteignent jamais une réponse d'API. Un modèle de lecture est libre de dénormaliser, d'aplatir et de renommer pour celui qui le lit, et cette liberté est précisément la raison pour laquelle il ne doit pas être l'objet sur lequel le domaine défend ses invariants.

05

Le chemin d'écriture

Commande HTTPminimal API
valider
FluentValidationforme
traiter
Command handlerapplication
muter
Agrégat Orderinvariants
une transaction
SQL ServerEF Core 10
Table outboxmême commit
publier
RabbitMQMassTransit
consommer
Stockconsommateur
Paiementsconsommateur
Expéditionconsommateur
Une seule transaction couvre l'agrégat et la ligne d'outbox. Tout ce qui suit est asynchrone.
06

Pourquoi un outbox transactionnel

Enregistrer une commande et publier OrderConfirmed concernent deux systèmes différents, et aucune transaction ne couvre les deux. Faites-le naïvement et vous obtenez l'une de ces deux défaillances : publier puis planter, et les consommateurs agissent sur une commande qui n'existe pas ; valider en base puis planter, et la commande existe mais rien en aval n'en entend jamais parler.

  1. 1Écrire l'événement avec les donnéesLe handler ajoute l'événement d'intégration à une table d'outbox dans la même transaction que la modification de l'agrégat. Soit les deux aboutissent, soit aucun.
  2. 2Expédier séparémentUn dispatcher en arrière-plan lit les lignes d'outbox non envoyées et les publie vers RabbitMQ, en ne les marquant envoyées qu'une fois acceptées par le broker.
  3. 3Accepter le at-least-onceUn plantage entre la publication et le marquage signifie que le message part deux fois. C'est un compromis délibéré : les doublons sont un problème soluble, les messages perdus ne le sont pas.

L'outbox ne rend pas la livraison exactly-once. Il la rend at-least-once, et déplace le problème restant là où il peut réellement être résolu : chez le consommateur.

Plantez-le vous-même

Choisissez un ordre, puis tuez le processus à n'importe quelle étape. Le panneau montre où le système atterrit une fois redémarré.

Stratégie de publication

Prévenir le monde d'abord, l'écrire ensuite.

  1. 01Publier vers RabbitMQ
  2. 02Valider la commande

Après redémarrage

Commande
rien
Ligne outbox
—
Broker
rien
Consommateur
jamais exécuté
Un modèle du raisonnement ci-dessus, pas une mesure — OrderFlow est une conception. Tuez le processus où vous voulez et lisez l'état final.
07

Les événements d'intégration

Chaque événement existe parce qu'un autre composant a besoin de réagir sans que l'émetteur sache qu'il existe. Si un événement n'a qu'un seul consommateur, qui aurait pu être un appel direct, c'est un message pour le plaisir d'être un message.

ÉvénementPublié quandQui s'y intéresse
OrderConfirmedLa commande passe les règles de confirmationStock, expédition, notifications
InventoryReservationRequestedLa confirmation exige que du stock soit réservéStock
InventoryReservedLe stock est réservé avec succèsPaiements
PaymentRequestedLe stock réservé doit être payéPaiements
PaymentCompletedLes fonds sont encaissésExpédition
OrderCancelledAnnulation, expiration ou échec de paiementStock (libération), notifications
08

Des consommateurs idempotents

Une livraison at-least-once signifie que chaque consommateur verra tôt ou tard un message deux fois : traiter un doublon doit donc être normal plutôt qu'exceptionnel. Réserver deux fois du stock pour une même commande est un bug de stock qui se manifeste en rupture des semaines plus tard — exactement le genre de bug que personne ne relie jamais à une redélivrance.

  • Chaque consommateur enregistre l'identifiant des messages qu'il a traités, et une seconde livraison du même identifiant est acquittée sans rejouer l'effet.
  • Les commandes acceptent une clé d'idempotence fournie par l'appelant : une requête HTTP retentée ne peut donc pas créer une seconde commande.
  • Les reprises utilisent une temporisation bornée, et les messages qui épuisent leurs tentatives partent vers une file d'erreurs plutôt que de boucler indéfiniment.
  • Un identifiant de corrélation voyage de la requête entrante jusqu'à chaque événement et chaque ligne de log : tout le parcours d'une commande tient donc dans une seule requête.
09

EF Core et Dapper, volontairement

Deux bibliothèques d'accès aux données dans un même système signifient d'ordinaire que personne n'a tranché. Ici, la séparation est la décision, et chacune fait ce que l'autre fait mal.

EF Core 10Dapper
Utilisé pourPersistance des agrégats, écritures, migrationsModèles de lecture, tableaux de bord, rapports
PourquoiLe suivi des changements et l'unit of work sont ce dont un côté écriture transactionnel a besoinSQL explicite et contrôle des projections, sans le surcoût du suivi
Jamais utilisé pourLes lectures larges de tableaux de bord multi-jointuresTout ce qui modifie un agrégat

La règle qui garde le tout honnête : un IQueryable ne s'échappe jamais de la couche infrastructure. Dès qu'un objet de requête franchit cette frontière, l'appelant hérite d'une préoccupation de base de données et la séparation s'est discrètement effondrée.

10

Le travail de fond, et ce à quoi il ne sert pas

Hangfire prend en charge le travail planifié et récurrent : expirer les commandes jamais confirmées, réconcilier les workflows bloqués, nettoyer les lignes d'outbox traitées. Tout cela est piloté par le temps, rien ne l'est par les événements.

11

L'état des commandes en temps réel

Une commande qui traverse réservation, paiement et expédition de façon asynchrone est exactement le cas où le polling est le pire : la plupart du temps rien ne change, et quand ça change l'utilisateur veut le savoir immédiatement. SignalR pousse les transitions d'état vers quiconque suit cette commande.

12

Observabilité

Une requête synchrone qui échoue vous le dit dans sa réponse. Un workflow distribué qui échoue à mi-parcours ne vous dit rien, à moins de l'avoir prévu — d'où le fait que cela soit conçu dès le départ plutôt qu'ajouté après le premier incident.

  • Journalisation structurée avec Serilog : CorrelationId, TraceId, OrderId, MessageId, Operation et DurationMs comme propriétés, pas comme interpolation de chaînes.
  • Traces OpenTelemetry couvrant les opérations HTTP, base de données et messagerie : le parcours d'une commande est une seule trace.
  • Des endpoints /health/live et /health/ready distincts — la liveness répond à « le processus tourne-t-il », la readiness à « peut-il servir du trafic ». Les confondre pousse un orchestrateur à redémarrer un processus qui attendait simplement une dépendance.
  • Aucun secret, token ni détail de paiement dans les logs, imposé par la configuration de journalisation plutôt que par la mémoire du relecteur.
13

Tester ce qui casserait vraiment

La couverture est un mauvais objectif ici. Les défaillances qui comptent dans un système événementiel sont la concurrence, les livraisons en double et la dérive des mappings, et aucune n'apparaît dans un test unitaire avec un dépôt simulé.

NiveauCe qu'il prouveContre quoi
DomaineLes invariants tiennent et les transitions illégales sont refuséesRien — des objets purs
ApplicationLes handlers orchestrent correctement, les validateurs rejettent les mauvaises entréesDes doublures de test pour les ports
IntégrationLa persistance, la concurrence et les consommateurs fonctionnent réellementDe vrais SQL Server et RabbitMQ via Testcontainers
ArchitectureLa règle de dépendance n'est pas violéeLes références entre assemblies

Simuler la base de données pour tester la persistance EF Core, c'est tester la simulation. Simuler le broker pour tester un consommateur, c'est tester la simulation. Quand un conteneur fournit une vraie dépendance en quelques secondes, la vraie dépendance est le choix le moins coûteux.

14

Les compromis que j'accepte

Chaque décision ci-dessus a un coût, et un document de conception qui ne liste que des bénéfices relève du marketing.

DécisionCoût
Cohérence à terme entre servicesL'interface doit montrer des états intermédiaires ; « confirmée » n'est pas instantanément vrai partout
Outbox transactionnelUn dispatcher à exécuter et à surveiller, plus des lignes d'outbox à nettoyer
Deux bibliothèques d'accès aux donnéesDeux modèles mentaux, et une frontière à faire respecter
Des agrégats plutôt que du CRUDPlus lent à écrire pour la première fonctionnalité ; le gain arrive à la dixième
TestcontainersDes exécutions de tests plus lentes et Docker requis en CI

Envie d'en discuter la conception ?

C'est une conception : elle est donc encore discutable. Si vous pensez que l'un des compromis ci-dessus est mauvais, je préfère l'entendre maintenant plutôt qu'après la construction.