
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
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.
Les couches et la règle de dépendance
- Minimal APIs
- SignalR hubs
- OpenAPI
- DI composition
- EF Core 10
- Dapper
- MassTransit
- Outbox
- Hangfire
- Commands
- Queries
- Handlers
- Validators
- Ports
- 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.
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à.
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.
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é | Exemples | Règles |
|---|---|---|
| Commandes | CreateOrder, ConfirmOrder, CancelOrder, ReserveInventory | Nommées d'après l'intention, un seul agrégat, idempotentes par clé |
| Requêtes | GetOrder, GetOrderHistory, GetOrdersDashboard | Aucune é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.
Le chemin d'écriture
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É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.
- 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.
- 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é.
- 01Publier vers RabbitMQ
- 02Valider la commande
Après redémarrage
- Commande
- rien
- Ligne outbox
- —
- Broker
- rien
- Consommateur
- jamais exécuté
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énement | Publié quand | Qui s'y intéresse |
|---|---|---|
| OrderConfirmed | La commande passe les règles de confirmation | Stock, expédition, notifications |
| InventoryReservationRequested | La confirmation exige que du stock soit réservé | Stock |
| InventoryReserved | Le stock est réservé avec succès | Paiements |
| PaymentRequested | Le stock réservé doit être payé | Paiements |
| PaymentCompleted | Les fonds sont encaissés | Expédition |
| OrderCancelled | Annulation, expiration ou échec de paiement | Stock (libération), notifications |
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.
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 10 | Dapper | |
|---|---|---|
| Utilisé pour | Persistance des agrégats, écritures, migrations | Modèles de lecture, tableaux de bord, rapports |
| Pourquoi | Le suivi des changements et l'unit of work sont ce dont un côté écriture transactionnel a besoin | SQL explicite et contrôle des projections, sans le surcoût du suivi |
| Jamais utilisé pour | Les lectures larges de tableaux de bord multi-jointures | Tout 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.
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.
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.
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.
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é.
| Niveau | Ce qu'il prouve | Contre quoi |
|---|---|---|
| Domaine | Les invariants tiennent et les transitions illégales sont refusées | Rien — des objets purs |
| Application | Les handlers orchestrent correctement, les validateurs rejettent les mauvaises entrées | Des doublures de test pour les ports |
| Intégration | La persistance, la concurrence et les consommateurs fonctionnent réellement | De vrais SQL Server et RabbitMQ via Testcontainers |
| Architecture | La règle de dépendance n'est pas violée | Les 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.
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écision | Coût |
|---|---|
| Cohérence à terme entre services | L'interface doit montrer des états intermédiaires ; « confirmée » n'est pas instantanément vrai partout |
| Outbox transactionnel | Un dispatcher à exécuter et à surveiller, plus des lignes d'outbox à nettoyer |
| Deux bibliothèques d'accès aux données | Deux modèles mentaux, et une frontière à faire respecter |
| Des agrégats plutôt que du CRUD | Plus lent à écrire pour la première fonctionnalité ; le gain arrive à la dixième |
| Testcontainers | Des 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.