Ce que signifie réellement l’architecture orientée événements
L’architecture orientée événements (Event-driven architecture) est un style de communication dans lequel les services enregistrent des événements — des déclarations au passé indiquant que quelque chose s’est produit — et y réagissent, au lieu de s’appeler directement. Un service de commande ne sollicite pas un service de facturation pour lui dire « facture ceci ». Il enregistre order.placed et passe à la suite. Un service de facturation intéressé par ce fait s’y abonne et effectue la facturation à son propre rythme.
Le mot important ici est fait. Un événement est immuable et déjà avéré. order.placed décrit quelque chose qui s’est produit ; aucun consommateur ne peut y mettre son veto, et aucun producteur n’attend de réponse. Une requête est une question qui attend une réponse, et l’appelant est bloqué jusqu’à ce qu’elle arrive. Un événement est une déclaration destinée à un public, et le producteur a terminé son travail dès que la déclaration est persistée.
Cette différence semble minime, mais elle change tout. Comme le producteur n’appelle personne, il n’a pas besoin de savoir qui est intéressé. Comme les consommateurs ne répondent pas, ils peuvent être lents, redémarrés ou ajoutés plus tard sans que le producteur ne s’en aperçoive. Le prix à payer est que le système ne possède plus de fil de contrôle unique que l’on peut suivre, et la fiabilité dépend désormais de la capacité de chaque participant à gérer gracieusement les doublons, les délais et le réordonnancement.
Ce guide porte sur la machinerie qui rend ces garanties concrètes, ainsi que sur les cas où ce compromis n’en vaut pas la peine.
Les commandes ordonnent, les événements décrivent
La source de confusion la plus courante dans les systèmes pilotés par les événements consiste à appeler ces deux types de messages des « événements ». Gardez-les bien distincts.
Une commande est une instruction : reserve-inventory, charge-card, send-welcome-email. Elle est impérative, elle s’adresse à un handler censé agir, et elle peut échouer ou être refusée. Une commande n’a qu’un seul propriétaire, et l’expéditeur se soucie généralement du résultat.
Un événement est une description : inventory-reserved, card-charged, user-registered. Il est au passé, c’est un fait détenu par le producteur, et il peut avoir zéro, un ou mille consommateurs. Aucun consommateur ne peut le rejeter, car il est déjà arrivé. Le producteur ne sait pas, et ne se soucie pas de savoir, qui le lit.
// A command asks for something and expects a handler to decide.
await commands.send("reserve-inventory", { orderId, quantity: 2 });
// An event reports that a decision was made. Nobody may reject it.
await events.publish("inventory.reserved", { orderId, quantity: 2 });
Cette nomenclature n’est pas une question de pédanterie. Un canal rempli de commandes est un appel de procédure distribué, et il possède tout le couplage qui va avec. Un canal rempli d’événements est un broadcast, et il peut être étendu sans demander de permission. Si le nom d’un message n’a pas de temps — inventory-reservation — personne ne pourra déterminer plus tard, en lisant le code, s’il s’agit d’une requête ou d’un fait.
Un test utile : l’expéditeur peut-il continuer sans connaître le résultat ? Si oui, c’est probablement un événement. Si l’expéditeur doit bifurquer en fonction du résultat, c’est une commande, et vous devriez l’acheminer vers un seul handler plutôt que de la diffuser.
Il est courant d’avoir besoin des deux dans un même flux. Un service de paiement envoie une commande charge-card au service de paiement et attend la réponse, car il ne peut confirmer la commande sans elle. Une fois le paiement réussi, le service de paiement publie payment.captured, et toutes les autres parties intéressées réagissent de manière asynchrone. La commande est la colonne vertébrale synchrone ; les événements sont le fan-out. Les mélanger délibérément ne pose aucun problème — l’erreur consiste à prétendre qu’une commande est un événement et à s’étonner que personne ne réponde.
Les événements, l’event sourcing et le CQRS sont trois concepts distincts
Ces trois notions sont liées, fréquemment utilisées ensemble, mais tout à fait séparables. Les traiter comme une seule et même chose est le moyen le plus rapide de construire un système plus complexe que le problème qu’il est censé résoudre.
L’approche event-driven est un style de communication. Les services s’échangent des faits via un broker. La base de données reste la source de vérité, et vous pourriez supprimer le broker demain sans perdre autre chose que le découplage.
L’event sourcing est un style de persistance. Au lieu de stocker la ligne actuelle, vous stockez la séquence ordonnée d’événements qui l’ont produite, et l’état actuel est le résultat d’un reduce (fold) sur cette séquence. Le log est la source de vérité. Reconstruire le solde d’un compte signifie rejouer ses dépôts et ses retraits. Cela vous offre une piste d’audit parfaite et la possibilité de remonter dans le temps, au prix de lectures plus complexes et de migrations plus ardues.
Le CQRS (Command Query Responsibility Segregation) consiste à séparer le modèle d’écriture du modèle de lecture. Les commandes passent par un modèle optimisé pour la validation et les invariants ; les requêtes lisent depuis une ou plusieurs projections optimisées pour la recherche. Les deux modèles peuvent partager une base de données ou utiliser des stockages totalement différents.
Vous pouvez adopter l’un d’entre eux sans les autres :
- Event-driven sans event sourcing : les services publient des faits, mais chacun conserve une table classique.
- Event sourcing sans broker : un seul service stocke ses événements dans sa propre base de données.
- CQRS sans événements : deux modèles sur les mêmes données, synchronisés de manière synchrone.
La plupart des équipes devraient commencer par une communication event-driven simple et une base de données classique. L’event sourcing est un engagement sérieux, et s’y lancer simplement parce que l’architecture semble impressionnante est le meilleur moyen de le regretter.
Pub/sub, topics et fan-out
Le transport qui permet le fonctionnement des événements est le publish/subscribe (publication/abonnement). Les producteurs publient sur un canal nommé, généralement appelé topic ou exchange, et les consommateurs s’abonnent aux topics qui les intéressent. Le broker gère le routage.
La propriété fondamentale est que le producteur ne s’adresse pas directement aux consommateurs. Il publie une seule fois vers orders, et le broker distribue l’information à chaque abonnement : facturation, exécution des commandes, indexation de recherche, analytique, détection de fraude. L’ajout d’un sixième consommateur est une modification de configuration pour ce consommateur, et non un changement de code pour le producteur.
// One publish, many independent subscribers.
await broker.publish("orders", {
type: "order.placed",
data: { orderId, customerId, totalCents },
});
Il existe deux grandes familles de brokers, et ce choix influence ce que vous pouvez construire :
- Les brokers basés sur les logs (log-based), comme Kafka, conservent chaque événement pendant une fenêtre de rétention et permettent à chaque groupe de consommateurs de suivre sa propre position. Les consommateurs peuvent rejouer l’historique, et plusieurs groupes peuvent lire le même topic indépendamment.
- Les brokers basés sur les files d’attente ou les exchanges (queue or exchange-based), comme RabbitMQ, routent chaque message vers une ou plusieurs queues, et un message est généralement supprimé une fois acquitté. Le routage est riche, mais le rejeu ne fait pas partie du modèle.
Un topic doit être nommé d’après le fait qu’il transporte, et non d’après le consommateur qui le lit. orders et users vieillissent bien ; billing-inbox non, car le jour où un second consommateur apparaît, le nom devient mensonger. Gardez vos topics stables et laissez les abonnements être l’élément qui évolue.
Le modèle d’acquittement (acknowledgement) est ce qui détermine les garanties de livraison. Un consommateur qui acquitte avant d’effectuer le travail risque de perdre un événement en cas de crash ; celui qui acquitte après risque de le traiter deux fois. Presque tous les brokers utilisent le second modèle par défaut, et c’est pourquoi l’idempotence n’est pas optionnelle. Certains systèmes permettent également à un seul abonnement logique de recevoir un événement une seule fois, même avec plusieurs consommateurs concurrents — une work queue — tandis que d’autres donnent à chaque abonné sa propre copie — un broadcast. Sachez ce que propose un topic avant de vous appuyer sur l’un ou l’autre.
Cohérence éventuelle et raisons du retard des consommateurs
Dès l’instant où un producteur cesse d’attendre, le système devient éventuellement cohérent (eventually consistent). Une fois que order.placed est validé, la commande existe immédiatement dans la base de données des commandes, mais pas encore dans la facture, l’index de recherche ou l’entrepôt d’analyse. Il existe une fenêtre — de quelques millisecondes en charge normale, à quelques minutes lors d’un incident — durant laquelle ces vues divergent.
Ce n’est pas un défaut à masquer, mais la propriété fondamentale de cette architecture. Chaque chemin de lecture basé sur un événement doit répondre à deux questions : quel niveau d’obsolescence est acceptable, et que voit l’utilisateur entre-temps ?
Quelques habitudes permettent de rendre cela gérable :
- Lisez vos propres écritures depuis la source. Après l’action d’un utilisateur, redirigez-le vers une vue servie par le modèle d’écriture, et non par une projection qui ne serait pas encore à jour.
- Affichez un statut honnête. Un
202 Acceptedavecstatus: "processing"vaut mieux qu’une page qui clignote en passant de vide à remplie. - Mesurez le retard (lag). L’écart entre l’événement publié le plus récent et la position d’un consommateur est le signal de santé le plus utile de tout le système.
Un exemple concret permet de mieux visualiser cette fenêtre. Un client passe une commande et la page de confirmation est servie par le service des commandes, elle est donc immédiatement correcte. Sa page de compte est servie par une projection construite à partir de order.placed ; pendant les deux cents millisecondes suivantes, elle n’affiche donc aucune commande. Si la projection a quelques secondes de retard lors d’un déploiement, le client voit “aucune commande” et ouvre un ticket de support. Rien de tout cela n’est un bug dans le flux d’événements ; c’est le flux qui fonctionne comme prévu, et l’UI doit être conçue en conséquence.
Le retard est normal et augmente pour des raisons courantes : un pic de trafic, une API en aval lente, le redémarrage d’un consommateur ou un rééquilibrage. Un retard qui croît sans limite est un problème de capacité, et il reste invisible à moins de l’afficher sur un tableau de bord. Une bonne projection suit sa propre position et l’expose, afin que l’écart soit un chiffre sur lequel vous pouvez configurer des alertes, plutôt qu’un sentiment découvert via des plaintes.
Il existe une garantie de cohérence qu’il vaut la peine de maintenir, même dans un système éventuellement cohérent : les lectures monotones. Un consommateur ne doit jamais revenir en arrière. Si un événement arrive avec un horodatage plus ancien qu’un événement déjà appliqué, l’appliquer hors ordre peut ressusciter des données supprimées ou faire régresser un compteur. Versionnez vos projections par numéro de séquence ou par offset, et non par l’heure réelle, et ignorez tout élément plus ancien que ce que vous avez déjà traité.
Le problème du double write et l’outbox transactionnelle
Voici la panne qui piège presque tout le monde. Un service doit modifier sa base de données et publier un événement. Il écrit la ligne, puis appelle le broker. Que se passe-t-il si la publication échoue ? Que se passe-t-il si le processus est interrompu entre les deux ?
- Commit d’abord, puis publication : la ligne existe, l’événement n’a jamais eu lieu, et les services en aval manquent silencieusement le changement.
- Publication d’abord, puis commit : l’événement annonce un état qui n’a jamais été sauvegardé, et les consommateurs agissent sur un fait qui est faux.
Il est impossible de rendre atomiques une écriture en base de données et une publication réseau. C’est le problème du double write, et il ne peut pas être résolu en ordonnant les deux appels plus prudemment. Un broker qui supporte les transactions n’aide pas, car la base de données est un système distinct.
La réponse standard est l’outbox transactionnelle. Au lieu de publier directement, écrivez l’événement dans une table outbox au sein de la même transaction que le changement d’état. Soit les deux lignes sont commitées, soit aucune ne l’est. Un relais séparé — un worker de polling, ou un connecteur de change-data-capture lisant le log de la base de données — lit les lignes non publiées et les transmet au broker.
BEGIN;
INSERT INTO orders (id, customer_id, status, total_cents)
VALUES ($1, $2, 'placed', $3);
INSERT INTO outbox (id, topic, payload)
VALUES ($1, 'orders', $2);
COMMIT;
Le relais supprime ou marque ensuite les lignes une fois publiées. S’il plante après la publication mais avant le marquage, l’événement est publié deux fois — c’est précisément pour cela que les consommateurs doivent être idempotents. S’il plante avant la publication, la ligne est toujours là et sera récupérée lors du passage suivant. Dans tous les cas, aucun événement n’est perdu.
Deux détails sont importants. Le relais doit revendiquer les lignes avec FOR UPDATE SKIP LOCKED (ou un équivalent) pour éviter que plusieurs instances du relais ne publient la même ligne simultanément. De plus, l’outbox doit être purgée, car une table qui ne fait que croître finira par devenir l’élément le plus volumineux de votre base de données.
Livraison “at-least-once” et consommateurs idempotents
Tout broker qui se respecte propose une livraison at-least-once (au moins une fois). Il ne propose pas le “exactly-once”, car une livraison exactement une fois à travers un réseau et malgré des crashs est concrètement impossible. Un consommateur peut traiter un événement, planter avant de l’accuser réception, et le recevoir à nouveau au redémarrage. Un relais outbox peut publier une ligne deux fois. Un producteur peut relancer une requête après un timeout alors que celle-ci avait en réalité réussi.
C’est le contrat, pas un bug. Vos consommateurs doivent être idempotents : traiter le même événement deux fois doit laisser le même état final que s’il avait été traité une seule fois.
Il existe trois patterns pratiques :
L’idempotence naturelle. Certaines opérations sont déjà sûres à répéter. Définir un statut à shipped deux fois revient au même qu’une seule fois. Une insertion avec ON CONFLICT DO UPDATE converge. Privilégiez ces approches dès que possible.
Une table de déduplication. Enregistrez chaque ID d’événement traité avec une contrainte d’unicité, dans la même transaction que le travail effectué. Si l’insertion échoue à cause d’un conflit, c’est que l’événement a déjà été géré et le consommateur s’arrête prématurément. C’est la solution polyvalente et celle à privilégier en premier.
Les clés d’idempotence du fournisseur. Les passerelles de paiement et nombreuses API acceptent une clé stable et renvoient le résultat original au lieu de répéter l’effet de bord. Combinez cela avec votre propre déduplication, car la clé protège l’appel, pas la logique environnante.
const seen = await client.query(
`INSERT INTO processed_events (event_id, consumer)
VALUES ($1, 'billing')
ON CONFLICT DO NOTHING
RETURNING event_id`,
[event.id],
);
if (seen.rowCount === 0) return { skipped: true };
Remarquez que la clé de déduplication est l’ID de l’événement, et non l’ID de la commande. Cela rend le consommateur sûr même lorsque le producteur publie légitimement deux événements différents concernant la même commande — order.placed et order.cancelled sont des faits distincts et tous deux doivent être traités.
L’idempotence est une propriété de l’effet, pas du transport. Un broker peut filtrer les ID d’événements en double à l’entrée, ce qui aide, mais il ne peut pas savoir si votre handler a déjà envoyé un e-mail ou débité une carte. Seul le consommateur, à l’intérieur de la même transaction que son effet de bord, peut le décider. C’est pourquoi la déduplication doit se situer juste à côté de l’écriture et non dans une couche middleware qui s’exécute avant.
Ordonnancement et partitionnement
Un broker qui distribue les données sur plusieurs partitions ne peut pas garantir un ordre global. Kafka ordonne les enregistrements au sein d’une partition ; RabbitMQ ordonne au sein d’une queue servie par un seul consommateur. À l’échelle du système, les événements arrivent dans l’ordre permis par le réseau et l’ordonnancement.
Cela ne pose aucun problème tant que vous choisissez une clé de partition (partition key) correspondant à l’ordre requis par vos consommateurs. Publiez chaque événement pour un client avec key = customerId, et tous les événements de ce client arriveront dans la même partition et seront traités dans l’ordre. Les différents clients seront répartis sur les partitions et traités en parallèle.
await producer.publish("orders", {
key: event.data.customerId, // ordering is per key
value: event,
});
Le piège consiste à choisir une clé qui ne respecte pas l’invariant. Utilisez orderId comme clé alors que vos consommateurs ont besoin d’un ordonnancement par client, et vous obtiendrez un parallélisme indésirable et un ordre sur lequel vous ne pouvez pas compter. À l’inverse, si vous utilisez une seule constante comme clé pour tout, vous obtiendrez un ordre parfait, mais sans aucun parallélisme.
Deux autres mises en garde. Modifier le nombre de partitions ultérieurement change le mappage entre la clé et la partition ; ainsi, les événements d’une même entité peuvent se retrouver répartis sur deux partitions et perdre leur ordre relatif. Déterminez le nombre de partitions en prévoyant une marge de croissance. De plus, si un consommateur traite une partition de manière séquentielle, un seul événement lent bloque tout ce qui suit : veillez donc à limiter le travail effectué par événement.
Évolution et versioning des schémas
Un événement est un contrat entre un producteur et des consommateurs qui sont déployés indépendamment. Le producteur sera mis à jour alors que d’anciens consommateurs tournent encore, et un nouveau consommateur lira des événements écrits il y a plusieurs mois. La structure du payload doit survivre dans les deux sens.
Les règles sont les mêmes que pour une API publique :
- Ajoutez, ne renommez pas et ne supprimez pas. Les nouveaux champs sont optionnels et les consommateurs leur attribuent une valeur par défaut.
- Versionnez lorsque la signification change. Un champ
versionpermet à un handler de bifurquer explicitement au lieu de devoir deviner. - Ne réutilisez jamais le nom d’un champ pour un concept différent. C’est ainsi que commence une corruption silencieuse des données.
- Considérez les anciens événements comme valides pour toujours. Le replay signifie que le payload d’hier doit toujours pouvoir être analysé aujourd’hui.
export type OrderPlacedV2 = {
type: "order.placed";
version: 2;
data: {
orderId: string;
totalCents: number;
currency?: string; // added later; v1 events simply lack it
};
};
À grande échelle, un schema registry transforme cela en un problème géré. Les producteurs enregistrent un schéma — Avro, Protobuf ou JSON Schema — et le registry lui assigne un id, impose un mode de compatibilité et rejette tout changement qui casserait les lecteurs existants. Même sans registry, le fait de conserver un fichier de schéma versionné dans le repo et de passer en revue ses modifications apporte la majeure partie de ces avantages.
Cette discipline s’avère payante précisément lors des incidents. Lorsqu’un déploiement casse un consommateur, la première question est de savoir si le producteur a modifié un payload d’une manière non convenue.
Les tests de contrat sont la version économique d’un registry. Conservez un fichier de fixtures contenant des événements réels par version, et faites en sorte que chaque consommateur les analyse tous dans la CI. Un consommateur qui échoue sur une fixture v1 échouera en production la première fois qu’un ancien événement sera rejoué. Cela ne coûte que quelques fichiers et permet de détecter le type de rupture qui, autrement, n’apparaîtrait que plusieurs jours plus tard sous forme de projection corrompue.
Chorégraphie et orchestration
Un processus métier multi-étapes basé sur des événements peut être coordonné de deux manières, et la différence est significative.
La chorégraphie signifie que chaque service écoute les événements et réagit, sans coordinateur central. Le service de commande publie order.placed ; le service d’inventaire réserve le stock et publie inventory.reserved ; le service de paiement débite le client et publie payment.captured ; le service d’expédition réagit à cela. Chaque service ne connaît que les événements qu’il consomme et produit. C’est une approche souple, extensible et facile pour ajouter une étape. En revanche, il est difficile d’avoir une vue d’ensemble du processus, car le flux n’existe qu’en tant que somme des abonnements de chacun.
L’orchestration signifie qu’un composant central — un orchestrateur de saga ou un gestionnaire de processus — indique explicitement à chaque service quoi faire et suit l’état du processus. Le flux est centralisé, ce qui le rend visible, testable et facile à analyser. Le coût est la présence d’un coordinateur dont chaque service doit dépendre, et qui peut devenir un goulot d’étranglement ainsi qu’un point de défaillance unique (single point of failure).
Aucune des deux approches n’est universellement supérieure :
- La chorégraphie convient aux réactions simples, largement indépendantes et aux étapes stables.
- L’orchestration convient aux processus longs avec de nombreuses branches conditionnelles, des timeouts et des compensations.
Un compromis pragmatique courant consiste à chorégraphier le “happy path” entre quelques services et à n’introduire un orchestrateur que pour le processus devenu suffisamment complexe pour en nécessiter un.
Le signal indiquant que la chorégraphie est allée trop loin est un changement qui nécessite de modifier plusieurs services simultanément, ou un processus que personne dans l’équipe ne peut décrire sans ouvrir cinq dépôts Git. Lorsque l’ajout d’une seule règle métier implique de toucher six consommateurs, le flux a cessé d’être un ensemble de réactions indépendantes pour devenir un programme distribué sans auteur. C’est alors le moment de le basculer vers un orchestrateur.
Sagas et compensations
Dans un monolithe, une opération en plusieurs étapes peut être encapsulée dans une transaction de base de données et annulée en cas d’échec. Entre plusieurs services, il n’y a pas de transaction partagée, donc un processus distribué ne peut pas simplement être interrompu. Si le paiement a réussi mais que l’expédition a échoué, vous ne pouvez pas annuler le paiement avec un ROLLBACK.
Le pattern saga permet de gérer cela. Une saga est une séquence de transactions locales, chacune publiant un événement qui déclenche l’étape suivante. Si une étape échoue, la saga exécute des actions de compensation pour les étapes qui ont déjà réussi : rembourser le paiement, libérer l’inventaire réservé, marquer la commande comme annulée. La compensation n’est pas un rollback — c’est une nouvelle action métier qui annule l’effet précédent, et c’est elle-même un événement qui doit être idempotent.
// Forward path
// order.placed -> inventory.reserved -> payment.captured -> order.confirmed
// If payment fails, compensate the steps that already ran.
await events.publish("payment.failed", { orderId, reason });
// inventory service listens and releases the reservation
Deux règles de conception permettent de garder les sagas gérables. Premièrement, chaque étape doit être idempotente, car une tentative de rejeu (retry) peut l’exécuter à nouveau. Deuxièmement, chaque étape doit avoir une action de compensation définie à l’avance — si une étape ne peut pas être annulée, la saga ne peut pas échouer en toute sécurité après celle-ci ; cette étape doit donc être placée en dernier ou nécessite une conception différente. Les sagas rendent également les états intermédiaires visibles, l’UI doit donc afficher “réservation du stock” et “en attente de paiement” plutôt que de prétendre que l’opération est atomique.
Une saga a besoin de son propre état. Soit l’orchestrateur stocke l’étape actuelle dans une table, soit chaque service suit les événements qu’il a reçus. C’est cet état qui permet à un processus de reprendre après un crash, de définir un timeout pour une étape qui n’a jamais répondu, et de savoir quelles compensations sont encore dues. Une saga sans état persisté est une séquence de messages qui finira par se bloquer dans un état que personne ne pourra reconstruire.
Les timeouts méritent une attention particulière, car une étape qui ne répond jamais est l’échec le plus courant. Si le paiement ne réussit ni n’échoue dans un délai imparti, la saga doit décider : réessayer, compenser ou mettre la commande en attente pour une revue manuelle. La laisser sans décision signifie que la commande reste dans le vide indéfiniment, bloquant un inventaire qui ne sera jamais libéré.
Files d’attente de lettres mortes (DLQ) et replay
Un événement qu’un consommateur ne peut pas traiter échouera à chaque tentative : payload malformé, bug dans le handler, ou ligne référencée inexistante. Le tenter indéfiniment consomme un slot de consommateur et bloque tout ce qui se trouve derrière lui dans la partition ou la file d’attente.
La solution est la dead-letter queue (DLQ). Après un nombre configuré de tentatives, le broker déplace l’événement — payload, headers, nombre de tentatives et dernière erreur — vers une file d’attente distincte qu’aucun consommateur ne lit. Le trafic sain continue de circuler, et un opérateur peut inspecter l’événement ayant échoué, corriger la cause et effectuer un replay.
Le replay est le super-pouvoir discret des systèmes event-driven. Comme les événements sont durables, vous pouvez retraiter l’historique après avoir corrigé un bug : reconstruire une projection qui a été mal calculée, alimenter rétroactivement un service ajouté tardivement, ou rejouer une journée d’événements avec une nouvelle logique. La condition est que les consommateurs soient idempotents, car le replay leur enverra des événements qu’ils ont peut-être déjà traités.
Considérez la DLQ comme une surface opérationnelle, pas comme un cimetière. Configurez des alertes sur sa profondeur, affichez-la sur votre dashboard et bâtissez un chemin de replay avant d’en avoir besoin à 2 heures du matin. Une dead-letter queue que personne ne surveille est l’endroit idéal pour cacher des bugs.
Le replay nécessite également une stratégie de rétention. Vous ne pouvez retraiter que les événements que le broker possède encore ; la fenêtre de rétention définit donc jusqu’où un correctif peut remonter dans le temps. Un topic qui conserve sept jours de données ne peut pas reconstruire une projection après un bug qui a persisté pendant deux semaines. Déterminez la rétention en fonction de la récupération dont vous avez réellement besoin, et n’oubliez pas que chaque octet est multiplié par la réplication et par le stockage propre à chaque consommateur.
Visualiser le flux d’événements
La partie la plus difficile des systèmes pilotés par les événements n’est pas de les construire, mais de comprendre ce qui s’est passé après qu’une erreur soit survenue. Une seule action utilisateur peut générer une douzaine d’événements répartis sur six services, et la panne peut se situer au niveau du troisième consommateur du cinquième événement.
Trois pratiques rendent ce flux observable :
- L’ID de corrélation (Correlation id). Générez un identifiant unique à l’entrée, ajoutez-le à chaque événement et à chaque ligne de log, et propagez-le à chaque étape. Un simple
greppermet alors de reconstruire l’intégralité du flux. - Le Tracing. OpenTelemetry et les outils similaires modélisent un événement comme un “span” lié à l’événement qui l’a provoqué, transformant ainsi le flux en un graphe lisible.
- Le retard du consommateur (Consumer lag) et la profondeur de la DLQ. Ces deux métriques détectent la plupart des problèmes avant même que l’utilisateur ne s’en aperçoive : un consommateur qui prend du retard, ou un gestionnaire (handler) qui commence à échouer.
await events.publish("order.placed", {
...event,
correlationId: req.id, // set once, carried everywhere
});
Enregistrez l’ID et le type de l’événement aussi bien côté publication que côté consommation. Sans cela, le débogage consiste à corréler des horodatages entre plusieurs services en espérant que les horloges soient synchronisées.
Un tableau de bord utile pour un flux d’événements comporte trois lignes : le taux de publication par type d’événement, le retard du consommateur par groupe, et la profondeur de la DLQ par consommateur. Un taux de publication qui tombe à zéro signifie qu’un producteur s’est arrêté ; un retard qui grimpe signifie qu’un consommateur ne suit plus la cadence ; une profondeur de DLQ qui augmente signifie qu’un gestionnaire est défectueux. Ensemble, ces trois signaux expliquent la plupart des incidents avant même l’ouverture d’un log.
Événements “minces”, événements “gras” et le contrat de payload
Une question de conception récurrente est de savoir quelle quantité de données un événement doit transporter. Un événement mince (thin) ne contient qu’un identifiant et un type — order.placed avec un orderId. Un événement gras (fat), ou enrichi, transporte un instantané complet : les lignes de commande, les totaux, l’adresse de livraison telle qu’elle était au moment de la commande.
Les événements gras rendent les consommateurs plus simples et plus robustes. Un indexeur de recherche qui reçoit la commande complète n’a pas besoin de rappeler le service de commandes, ce qui élimine une dépendance au runtime et un mode de défaillance. Cependant, ils élargissent également le contrat : chaque champ devient un élément dont un consommateur peut dépendre, rendant ainsi toute modification de structure plus difficile, et l’événement peut transporter des données qu’un consommateur spécifique n’est pas autorisé à voir.
Les événements minces maintiennent un contrat minimal et un payload réduit, mais chaque consommateur doit récupérer l’état actuel, ce qui réintroduit un couplage et peut mener à la lecture de données ayant été modifiées depuis. L’événement cesse d’être un fait complet pour devenir un simple pointeur.
Un choix par défaut viable consiste à inclure les champs qui définissent le fait et qui peuvent être partagés sans risque, tout en référençant tout le reste par un id. order.placed devrait transporter l’id de la commande, l’id du client et le total, car ceux-ci constituent le fait. Il ne devrait pas intégrer tout le profil du client. Le test est simple : un consommateur peut-il comprendre l’événement pour l’objectif qu’il annonce sans devenir une copie de votre base de données ?
Quel que soit votre choix, figez les valeurs qui ne doivent pas changer. Si un prix a été communiqué lors du paiement, l’événement doit transporter ce prix, même si l’instinct de l’événement mince suggère de le rechercher plus tard — car plus tard, le prix pourrait être différent, et l’événement décrirait alors un fait qui n’a jamais eu lieu.
Choisir un broker sans déclencher de guerre de frameworks
Le choix du broker est la décision la moins passionnante, et pourtant celle sur laquelle les équipes s’écharpent le plus. Trois familles couvrent pratiquement tous les cas de figure.
- Les brokers basés sur les logs (log-based), comme Kafka et NATS JetStream, conservent les événements pendant une certaine période et permettent à chaque groupe de consommateurs de suivre sa propre position. Choisissez-les lorsque le replay, un débit élevé ou la présence de nombreux lecteurs indépendants sont des exigences.
- Les brokers basés sur les échanges (exchange-based), comme RabbitMQ, routent les messages vers des files d’attente à l’aide de clés de routage, avec un acquittement par message, des priorités et des dead-letter exchanges. Choisissez-les lorsque le routage et la distribution des tâches sont plus importants que l’historique.
- Les files d’attente cloud (cloud queues), comme SQS et Pub/Sub, sont entièrement gérées et virtuellement illimitées, au prix d’API de plus bas niveau et d’un contrôle moindre sur l’ordonnancement et la planification.
Le conseil le plus honnête est de commencer avec ce que votre plateforme fait déjà tourner et ce que votre équipe comprend déjà. Un système correct sur un broker familier vaut mieux qu’un système théoriquement parfait sur un broker que personne ne sait exploiter. Migrez lorsqu’une limitation spécifique — replay, routage ou débit — devient réellement problématique, et non parce qu’une conférence a vanté un autre outil.
Quel que soit votre choix, masquez-le derrière une interface de publication légère dans votre propre code. publish(topic, event) constitue une couture stable ; un SDK fournisseur ne l’est pas. Cela permet de garder la décision du broker réversible et permet aux tests de publier vers un collecteur en mémoire plutôt que vers un cluster réel.
Tester un flux d’événements
Les événements sont asynchrones, ce qui rend les tests délicats tant que vous n’en séparez pas les composants.
Testez le producteur en effectuant des assertions sur l’outbox, et non sur le broker. Après avoir appelé le service, la ligne d’état et la ligne de l’outbox doivent toutes deux exister, et le payload doit correspondre au schéma. Aucun broker n’est nécessaire.
Testez le consommateur comme une fonction pure d’un événement. Envoyez-lui un payload et effectuez une assertion sur l’état résultant. Ensuite, envoyez-lui le même payload deux fois et vérifiez que la seconde exécution n’a aucun effet (no-op) — c’est le test d’idempotence, et il permet de détecter le type de bug qui n’apparaît que lors d’une redélivrance en production.
Testez le câblage avec un broker réel dans un container : publiez un événement, attendez que le consommateur le traite, et effectuez une assertion sur l’état final. Utilisez un group id ou une queue unique par exécution de test afin que les offsets commités ne fuitent jamais entre les runs, et basez vos assertions sur les enregistrements reçus plutôt que sur le timing.
test("reprocessing an event is a no-op", async () => {
await onOrderPlaced(event);
await onOrderPlaced(event); // redelivery
const { rows } = await pool.query(
"SELECT count(*)::int AS n FROM invoices WHERE order_id = $1",
[event.data.orderId],
);
expect(rows[0].n).toBe(1);
});
Gardez l’assertion asynchrone déterministe en interrogeant (polling) l’état attendu avec un timeout plutôt qu’en utilisant un sleep avec un intervalle fixe. Un test qui réussit simplement parce qu’il a attendu assez longtemps est un test qui deviendra instable (flake) sur une machine plus lente.
Quand l’architecture orientée événements est un atout et quand elle devient un fardeau
L’architecture orientée événements est un compromis, et il est important d’être conscient de ce que vous gagnez et perdez.
Elle excelle lorsque vous avez besoin d’un découplage entre des équipes qui déploient indépendamment, d’un mécanisme de fan-out vers de nombreux lecteurs, de la possibilité de rejouer l’historique, d’une piste d’audit durable, ou d’un flux naturel pour l’analytique et la recherche. Elle convient aux systèmes où une réaction en aval peut être légèrement différée et où l’ajout d’un nouveau consommateur ne doit pas nécessiter de modification du producteur.
Elle est problématique lorsque le domaine est restreint et suit un modèle CRUD, lorsqu’une opération doit être immédiatement et fortement cohérente, ou lorsque l’équipe est trop petite pour gérer un broker et raisonner en termes de cohérence éventuelle (eventual consistency). Dans ces cas-là, un monolithe bien structuré avec des modules clairs et une base de données unique est plus simple, plus rapide à construire et plus facile à déboguer. On peut toujours ajouter des événements plus tard, et un monolithe modulaire est un point de départ bien meilleur qu’un système distribué que personne ne sait tracer.
Une règle simple : n’introduisez pas de broker tant que vous ne pouvez pas nommer le problème spécifique qu’il résout. « Les microservices utilisent des événements » n’est pas l’énoncé d’un problème. Notez ce que vous espérez gagner — un nouveau consommateur sans toucher au producteur, un rejeu après un bug, une piste d’audit — et vérifiez plus tard si vous l’avez obtenu. Si la réponse honnête est « nous voulions paraître modernes », le broker est un coût sans retour sur investissement.
Si vous décidez de l’adopter, faites-le progressivement. Commencez par un seul événement qui résout un problème réel, faites-le tourner en production pendant un certain temps, et apprenez comment le lag, les doublons et les changements de schéma se comportent au sein de votre équipe et de votre infrastructure avant de faire des événements la colonne vertébrale de votre système.
Bonnes pratiques
- Nommez les événements au passé et gérez-les au niveau du producteur ; nommez les commandes séparément.
- Écrivez l’événement dans une outbox au sein de la même transaction que le changement d’état.
- Publiez via un relais ou un connecteur CDC, jamais directement depuis un gestionnaire de requête.
- Rendez chaque consommateur idempotent grâce à une clé de déduplication dérivée de l’id de l’événement.
- Choisissez une partition key qui correspond à l’ordre dont chaque consommateur a réellement besoin.
- Versionnez les payloads des événements et faites-les évoluer de manière additive ; ne réutilisez jamais un champ pour un autre usage.
- Gardez les consommateurs petits et mono-usage : un seul consommateur par projection ou réaction.
- Définissez des actions compensatoires pour chaque étape avant de construire une saga.
- Limitez le nombre de tentatives (retries), routez les événements épuisés vers une dead-letter queue et prévoyez un chemin de rejeu (replay).
- Propagez un correlation id sur chaque événement et loggez-le des deux côtés.
- Surveillez le lag des consommateurs ainsi que la profondeur de la DLQ, et configurez des alertes sur ces tendances.
- Commencez par un monolithe modulaire et ajoutez des événements lorsque le découplage ou le rejeu deviennent des besoins réels.
Erreurs courantes
- Appeler des commandes « événements » et se retrouver avec un appel de procédure distribué.
- Confondre event-driven, event sourcing et CQRS, et adopter les trois simultanément.
- Publier directement depuis un gestionnaire de requête et se heurter au problème du double write.
- Supposer une livraison « exactly-once » et facturer deux fois lors de la première redélivrance.
- Utiliser des clés d’événements aléatoires tout en s’attendant à un ordonnancement par entité.
- Renommer ou supprimer un champ du payload et casser les consommateurs sur une ancienne version.
- Mettre en place une chorégraphie sans aucun moyen de visualiser le processus de bout en bout.
- Retenter indéfiniment un « poison event » et bloquer la partition qui suit.
- Ne jamais consulter la dead-letter queue.
- Ajouter un broker pour une application CRUD et payer la taxe de complexité pour rien.
Et après ?
Le transport sous-jacent d’un système piloté par les événements est généralement un log ou un broker ; ainsi, Apache Kafka couvre le modèle de log rejouable et RabbitMQ couvre la messagerie axée sur le routage. Comme les événements sont le moyen de communication entre les services d’un système distribué, le guide sur les Microservices explique d’où proviennent initialement les limites de ces services. Si tout cela vous semble trop complexe pour votre problématique, le guide sur le Modular Monolith apporte un contrepoint honnête : la plupart des systèmes devraient rester une seule unité de déploiement jusqu’à ce qu’une raison concrète de les scinder apparaisse.