L'architecture event-driven en production : leçons d'un pipeline de commandes à haut débit

La promesse de l'Event-Driven Architecture
L'Event-Driven Architecture est facile à vendre et difficile à opérer. Le pitch est séduisant : découpler vos services, scaler indépendamment, réagir en temps réel aux changements et construire des systèmes qui semblent vivants. La réalité est un empilement de compromis autour de l'ordering, de l'idempotence, de l'observabilité, de l'évolution des schémas et des modes de défaillance que la plupart des tutoriels passent sous silence.
Il y a deux ans, mon équipe a réécrit un processeur de commandes monolithique en pipeline event-driven. Le système traite aujourd'hui plusieurs millions d'événements par jour à travers le checkout, le paiement, l'inventaire, l'expédition et les notifications. La migration s'appuyait fortement sur les patterns établis de la taxonomie d'architecture event-driven de Martin Fowler et sur les leçons opérationnelles d'entreprises comme LinkedIn, Uber, Netflix et Slack qui font tourner des plateformes event-driven à l'échelle Internet. Cet article est le guide que j'aurais aimé avoir avant de commencer.
Pourquoi nous avons choisi les événements
L'ancien système était une chaîne synchrone d'appels API. Quand un client passait une commande, le handler web appelait le service d'inventaire, qui appelait le service de paiement, qui appelait le service d'expédition, qui appelait le service de notification. Chaque saut ajoutait de la latence et multipliait le rayon d'explosion d'une défaillance.
Si le service de notification était lent, le checkout tombait en timeout. Si le service d'inventaire avait un pic, les paiements échouaient. Si l'expédition était indisponible, toute la commande était rejetée alors que le paiement avait déjà été encaissé. Le système était si fortement couplé que les incidents opérationnels étaient prévisibles et la récupération lente.
Les événements ont résolu le problème de couplage immédiat. Le handler web publie un événement OrderPlaced et retourne immédiatement. L'inventaire, le paiement, l'expédition et les notifications consomment chacun l'événement indépendamment. Si la notification est lente, le checkout s'en fiche. Si l'expédition est temporairement hors ligne, les commandes peuvent toujours être acceptées et traitées plus tard.
C'était la théorie. La pratique nous a obligés à répondre à une longue liste de questions que nous n'avions pas pleinement envisagées.
Les quatre patterns de l'Event-Driven Architecture
Martin Fowler identifie quatre patterns distincts que les gens regroupent souvent sous l'étiquette "event-driven". Comprendre les différences est essentiel car chaque pattern résout un problème différent et introduit une structure de coûts différente.
Event Notification est le pattern le plus léger. Un service publie un petit message indiquant que quelque chose s'est passé, et les services en aval réagissent. L'événement contient généralement seulement un identifiant. Les consommateurs qui ont besoin de plus d'informations doivent interroger le producteur. Cela maintient les payloads petits mais conserve un certain couplage car les consommateurs doivent toujours savoir comment parler au producteur.
Event-Carried State Transfer inclut suffisamment de données dans l'événement pour que les consommateurs agissent sans rappeler. Un événement CustomerAddressChanged transporte la nouvelle adresse. Un événement OrderPlaced transporte les line items. Cela réduit le couplage et améliore la résilience car les consommateurs peuvent continuer à fonctionner même lorsque le producteur est indisponible. Le coût est des payloads plus grands, un état répliqué et l'eventual consistency.
Event Sourcing fait du journal d'événements la source de vérité. Au lieu de stocker l'état actuel dans une base de données et de publier des événements comme effet secondaire, vous ajoutez des événements à un log immuable et dérivez l'état actuel en les rejouant. Git est l'exemple canonique : l'arbre de travail est dérivé du log de commits. L'Event Sourcing offre des pistes d'audit solides, des requêtes temporelles et la capacité de reconstruire l'état, mais il ajoute une complexité significative autour de l'évolution des schémas, de l'intégration des systèmes externes et du snapshotting.
CQRS sépare le modèle utilisé pour les écritures du modèle utilisé pour les lectures. Ce n'est pas strictement une question d'événements, mais il s'associe naturellement à l'Event Sourcing et à l'Event-Carried State Transfer car les événements peuvent alimenter des projections optimisées pour la lecture. Le modèle d'écriture gère les commandes et émet des événements ; le modèle de lecture consomme les événements et maintient des projections adaptées à des requêtes spécifiques.
Notre pipeline utilise l'Event-Carried State Transfer pour le cycle de vie principal de la commande, l'Event Notification pour les déclencheurs légers et une forme limitée d'Event Sourcing pour un log d'audit. Nous avons délibérément évité le CQRS complet pour la plupart des services car les modèles de lecture et d'écriture n'étaient pas assez différents pour justifier la séparation.
Le design des événements est la décision la plus importante
La plus grande erreur des équipes est de traiter les événements comme de simples wrappers autour des changements de base de données. Elles émettent des événements OrderCreated, OrderUpdated et OrderDeleted qui reflètent une table CRUD. Cela fuite l'état interne et oblige les consommateurs à reconstruire l'intention à partir d'un flux de diffs.
Nous avons appris à designer les événements autour de l'intention métier, pas des lignes de base de données. Au lieu de OrderUpdated, nous utilisons OrderPlaced, PaymentConfirmed, InventoryReserved, OrderShipped et OrderCancelled. Chaque nom d'événement décrit quelque chose qui s'est passé dans l'entreprise, pas quelque chose qui a changé dans une table.
Un événement bien conçu répond à trois questions :
- Que s'est-il passé ? Utilisez un verbe au passé dans le nom de l'événement.
- Sur quoi ? Incluez l'identifiant d'agrégat et suffisamment de contexte pour comprendre le sujet.
- Dans quel but ? Incluez les données dont le consommateur a besoin pour agir, sans inclure tout ce dont vous pourriez un jour avoir besoin.
{
"eventId": "evt_01J8XQ...",
"eventType": "OrderPlaced",
"aggregateId": "order_48291",
"timestamp": "2026-06-10T14:23:11Z",
"payload": {
"orderId": "order_48291",
"customerId": "cust_7721",
"currency": "EUR",
"total": 149.95,
"lineItems": [
{ "sku": "SHOE-42-BLK", "quantity": 1, "unitPrice": 89.95 },
{ "sku": "SOCK-3PK", "quantity": 2, "unitPrice": 30.00 }
],
"shippingAddress": {
"country": "DE",
"postalCode": "10115"
}
}
}Notez ce qui n'est pas dans l'événement. Il n'y a pas de clé primaire de base de données interne, pas de champ de version, pas d'énumération de statut et pas de données dont un seul consommateur aurait besoin. L'événement est un contrat, et chaque champ est un engagement.
Chorégraphie versus orchestration
Une fois que vous avez des événements, vous devez décider qui coordonne le workflow. Il existe deux grandes approches.
La chorégraphie signifie que chaque service réagit aux événements indépendamment et émet de nouveaux événements lorsqu'il a terminé. Il n'y a pas de coordinateur central. Le service de commandes émet OrderPlaced ; l'inventaire le consomme et émet InventoryReserved ; le paiement consomme OrderPlaced et émet PaymentConfirmed ; l'expédition attend à la fois InventoryReserved et PaymentConfirmed avant d'expédier. C'est très découplé et ça scale bien, mais le workflow global est implicite. Vous ne pouvez pas lire un morceau de code et voir l'ensemble du processus.
L'orchestration introduit un coordinateur central, souvent appelé moteur de workflow ou orchestrateur de saga, qui séquence explicitement les étapes. L'orchestrateur envoie des commandes aux services et attend les réponses. Cela rend le workflow visible et plus facile à comprendre, mais cela réintroduit une dépendance centrale et peut devenir un goulot d'étranglement.
Nous utilisons la chorégraphie pour le flux de commandes standard car les étapes sont bien comprises et les services stables. Nous utilisons l'orchestration pour les workflows complexes de remboursement et d'échange car ils impliquent une logique de branchement, des actions compensatoires et des timeouts difficiles à exprimer comme une pure chorégraphie. De nombreux systèmes de production, dont ceux d'Uber et de Netflix, finissent par utiliser les deux.
L'ordering et le partitionnement ne sont pas optionnels
L'une des premières surprises a été que les consommateurs d'événements ne traitent pas automatiquement les événements dans l'ordre qu'un humain attendrait. Si un client passe une commande puis l'annule immédiatement, l'événement OrderCancelled peut arriver avant l'événement OrderPlaced dans une partition ou un groupe de consommateurs différent.
Nous avons dû décider quelles garanties d'ordering nous avions réellement besoin. Pour le paiement et l'inventaire, l'ordre compte énormément. Une annulation ne doit pas être traitée avant le placement original. Pour les notifications, l'ordre compte moins. Une confirmation d'expédition arrivant avant une confirmation de paiement est déroutante mais pas catastrophique.
Notre solution a été de partitionner les événements par identifiant d'agrégat. Chaque événement lié à la même commande va dans la même partition, ce qui garantit l'ordering au sein du flux de cette commande. Les consommateurs qui ont besoin d'un ordering strict s'abonnent à ces partitions. Les consommateurs qui n'en ont pas besoin peuvent traiter les événements de n'importe quelle partition en parallèle.
function partitionKeyFor(event: OrderEvent): string {
if (event.aggregateId.startsWith('order_')) {
return event.aggregateId;
}
return event.eventType;
}Ce n'est pas gratuit. Le partitionnement par commande signifie qu'une seule commande chaude ne peut pas être parallélisée. Pendant les ventes flash, quelques SKU peuvent créer des points chauds de partition et ralentir l'ensemble du pipeline. Nous atténuons cela en séparant les événements de réservation des événements généraux du cycle de vie des commandes, afin que la réservation d'inventaire puisse scaler indépendamment.
Le rebalancement des groupes de consommateurs est un autre piège d'ordering. Quand un consommateur rejoint ou quitte le groupe, les partitions sont réaffectées. Si un consommateur a déjà traité certains événements d'une partition mais n'a pas encore commité l'offset, le consommateur nouvellement assigné peut retraiter ces événements. C'est pourquoi l'idempotence et la sémantique at-least-once sont non négociables.
L'idempotence sauve votre santé mentale
Les événements sont livrés au moins une fois. Les à-coups réseau, les redémarrages de consommateurs et les retries produisent tous des doublons. Si votre consommateur n'est pas idempotent, vous expédierez la même commande deux fois, débiterez la même carte deux fois ou réserverez le même inventaire deux fois.
Chaque consommateur doit être idempotent par conception. Le pattern le plus simple consiste à suivre les identifiants d'événements traités dans un store de déduplication avec une TTL correspondant à votre fenêtre de rétention. Un pattern plus robuste consiste à rendre l'opération downstream elle-même idempotente.
Notre processeur de paiement accepte par exemple une clé idempotencyKey sur chaque demande de débit. Nous utilisons l'ID de l'événement comme clé. Si le même événement PaymentConfirmed est traité deux fois, le deuxième appel renvoie la charge précédemment créée au lieu d'en créer une nouvelle.
async function handlePaymentConfirmed(event: PaymentConfirmedEvent): Promise<void> {
const charge = await payments.charge({
amount: event.payload.amount,
currency: event.payload.currency,
source: event.payload.paymentMethod,
idempotencyKey: event.eventId,
});
await orderProjection.update(event.aggregateId, {
paymentStatus: 'confirmed',
chargeId: charge.id,
paidAt: charge.createdAt,
});
}La clé d'idempotence n'est pas un confort. C'est le contrat qui rend le consommateur d'événements sûr en cas de retry. Sans elle, vous construisez un système qui fonctionne la plupart du temps et échoue de façon catastrophique sous charge.
Sémantique exactly-once : utile ou overkill ?
Une tendance croissante dans les systèmes event-driven est la poussée vers la sémantique de traitement exactly-once. Kafka prend en charge les producteurs idempotents et les transactions. Flink et Kafka Streams fournissent des garanties exactly-once pour le stream processing. Pour les systèmes financiers, de santé et de commerce électronique, l'exactly-once peut éliminer le besoin de réconciliation manuelle.
Nous avons envisagé l'exactly-once pour le traitement des paiements mais sommes finalement restés sur de l'at-least-once plus des consommateurs idempotents. La raison était la simplicité opérationnelle. La sémantique exactly-once nécessite des producteurs transactionnels, une configuration consommateur soigneuse et une compréhension profonde de la façon dont les commits interagissent avec les effets secondaires. À notre échelle et avec la taille de notre équipe, les consommateurs idempotents plus des métriques claires étaient plus faciles à appréhender et à déboguer.
La décision dépend de votre tolérance aux doublons et de la maturité opérationnelle de votre équipe. Si un événement en double causerait un problème réglementaire ou financier qui ne peut pas être corrigé par l'idempotence, l'exactly-once vaut la complexité. Sinon, l'at-least-once avec des opérations idempotentes est généralement le choix pragmatique.
Gestion des erreurs et Dead Letter Queues
Dans un système synchrone, un appel échoué renvoie généralement une erreur à l'appelant. Dans un système event-driven, un consommateur en échec n'a pas d'appelant. L'événement reste là, retry, bloque la partition et empoisonne potentiellement les consommateurs en aval.
Nous avons mis en place une stratégie de gestion des erreurs à plusieurs niveaux :
- Les échecs transitoires sont retentés avec un backoff exponentiel et du jitter. Les timeouts réseau, la contention de base de données et les limites de débit des tiers entrent dans cette catégorie.
- Les violations de règles métier sont placées dans une file de quarantaine pour examen manuel. Ce ne sont pas des bugs ; ce sont des cas que le code ne sait explicitement pas gérer.
- Les échecs permanents vont dans une Dead Letter Queue après un petit nombre de retries. Une alerte se déclenche et un ingénieur investigate.
Le nombre de retries et la stratégie de backoff dépendent du consommateur. Le traitement des paiements retente trois fois en trente secondes car un réseau de cartes peut être brièvement indisponible. La réservation d'inventaire retente plus agressivement car la fenêtre de vente est courte. L'envoi de notifications retente pendant des heures car une panne temporaire du fournisseur ne doit pas faire perdre l'événement.
De manière cruciale, les retries ne doivent pas casser l'ordering. Si un consommateur échoue sur l'événement N et continue à retry, l'événement N+1 sur la même partition est bloqué. Nous utilisons un topic de retry avec un mécanisme de délai afin que les événements en échec soient réinsérés pendant que la partition continue.
Backpressure et flow control
Tous les consommateurs ne peuvent pas suivre le producteur. Pendant une campagne marketing, le service de checkout peut publier dix mille événements par seconde tandis que le consommateur d'analytics ne peut en traiter que deux mille. Sans backpressure, le consommateur d'analytics prend du retard, la mémoire grossit et le processus finit par crasher.
La première défense est le scaling. Nous exécutons plusieurs instances de consommateurs par groupe de consommateurs, limitées par le nombre de partitions. Si nous avons besoin de plus de débit, nous augmentons le nombre de partitions, ce qui demande de la planification car repartitionner un topic en production est perturbateur.
La deuxième défense est le flow control. Certains consommateurs peuvent sauter des événements non critiques pendant une surcharge. Notre consommateur d'analytics peut par exemple échantillonner les événements à haute fréquence quand le lag dépasse un seuil. Le traitement principal des commandes ne peut pas échantillonner, donc il scale out à la place.
La troisième défense est le circuit breaking. Si une dépendance downstream comme le processeur de paiement tombe, nous arrêtons de l'assaillir et laissons le topic de retry bufferiser les événements. Cela protège la dépendance et empêche le consommateur de brûler du CPU sur des requêtes vouées à l'échec.
L'observabilité change tout
Déboguer des systèmes event-driven est plus difficile que déboguer des systèmes synchrones. Vous ne pouvez pas suivre une seule requête à travers une pile d'appels. Vous devez reconstruire une histoire à partir de logs, de métriques et de traces dispersés.
Nous avons construit trois couches d'observabilité :
- Distributed Tracing avec un ID de corrélation qui se propage de la requête HTTP initiale à travers chaque événement produit et consommé. Une seule trace montre le cycle de vie complet d'une commande.
- Métriques d'événements incluant le taux de publication, le taux de consommation, le lag par partition, le taux de retry et le taux de dead letter. Ce sont les signes vitaux du pipeline.
- Journal d'audit de chaque événement publié et consommé, stocké dans un object store avec une longue fenêtre de rétention. C'est inestimable pour les investigations d'incidents et la conformité.
La métrique qui nous a le plus souvent sauvés a été le consumer lag. Une montée soudaine du lag indique qu'un consommateur prend du retard avant de tomber complètement en panne. Nous alertons sur les seuils de percentiles de lag, pas seulement sur les erreurs.
Notre runbook d'incident standard commence par trois questions : Le producteur publie-t-il ? Le consommateur suit-il ? La Dead Letter Queue grossit-elle ? Ces trois métriques nous disent généralement quelle partie du pipeline est malade.
Sérialisation et évolution des schémas
Les événements vivent plus longtemps que les services. Un an après avoir publié un événement, trois nouvelles équipes peuvent le consommer et deux producteurs originaux peuvent en avoir changé la forme. Si vous traitez les schémas d'événements comme une après-pensée, vous créez un champ de mines de dépendances implicites.
Nous avons commencé avec JSON pour la lisibilité et le débogage, puis sommes passés à Avro pour les topics critiques en performance. Une étude de Confluent a révélé que passer de JSON à Avro peut réduire la taille des payloads d'environ soixante-dix pour cent et couper la latence de quelques dizaines de millisecondes par événement dans des environnements à haute fréquence. Pour une pipeline traitant des millions d'événements par jour, cette différence compte.
Nous utilisons un schema registry avec des vérifications de compatibilité forward et backward. Chaque événement a un schéma versionné. Les producteurs valident les événements contre le schéma avant publication. Les consommateurs déclarent quelles versions de schéma ils supportent.
La règle que nous appliquons est simple : seuls des changements additifs, sauf si chaque consommateur a été migré. Vous pouvez ajouter des champs optionnels. Vous ne pouvez pas supprimer des champs ou changer le type de champs existants sans un cycle de dépréciation formel.
# schemas/order-placed/v1.yaml
name: OrderPlaced
version: 1
type: record
fields:
- name: orderId
type: string
- name: customerId
type: string
- name: total
type: decimal
scale: 2
- name: lineItems
type: array
items:
type: record
fields:
- name: sku
type: string
- name: quantity
type: int
- name: unitPrice
type: decimal
scale: 2Quand nous avons dû ajouter un champ giftMessage, nous avons créé une v2 avec le nouveau champ optionnel, mis à jour les producteurs vers v2 et donné aux consommateurs deux sprints pour migrer. Comme le champ était optionnel, les anciens consommateurs pouvaient lire les événements v2 sans modification. Cette discipline semble bureaucratique jusqu'à votre premier breaking change. Ensuite, elle semble être la seule raison pour laquelle le système fonctionne encore.
Quand l'Event-Driven est le mauvais choix
L'Event-Driven Architecture n'est pas une amélioration universelle. Nous avons appris à l'éviter dans trois situations.
Premièrement, quand une forte cohérence est requise et que le métier ne peut tolérer l'eventual consistency. Si une action doit immédiatement se refléter dans chaque read model, la coordination synchrone est souvent plus simple et plus sûre.
Deuxièmement, quand le workflow est fondamentalement séquentiel et que chaque étape doit se terminer avant la suivante. Forcer cela dans des événements ajoute de la complexité sans découpler quoi que ce soit de significatif.
Troisièmement, quand l'équipe n'a pas la maturité opérationnelle pour faire tourner des systèmes distribués. Les systèmes event-driven échouent de manière subtile. Si vous n'avez pas une bonne observabilité, des runbooks et une rotation d'astreinte, vous passerez plus de temps à vous battre contre l'architecture qu'à en bénéficier.
Ce que nous ferions différemment
Si nous recommencions, nous designerions le catalogue d'événements avant d'écrire un seul consommateur. Nous avons écrit le code d'abord et nommé les événements ensuite, ce qui a produit un vocabulaire incohérent entre les services. Renommer des événements est douloureux car les consommateurs dépendent des noms.
Nous investirions également plus tôt dans l'alerting sur le consumer lag. La première fois qu'un consommateur a pris du retard pendant une vente, nous avons découvert le problème par des plaintes clients, pas par des métriques. L'alerting sur le lag est aujourd'hui l'un de nos signaux opérationnels les plus importants.
Enfin, nous utiliserions un schema registry dès le premier jour. Adapter la validation de schéma sur un flux d'événements en production a signifié coordonner des déploiements entre équipes et accepter temporairement des risques que nous aurions pu éviter. Nous aurions également choisi Avro pour les topics à haut débit plus tôt, au lieu de traîner l'overhead JSON jusqu'à ce que le lag devienne visible.
Takeaways
L'Event-Driven Architecture est un outil puissant pour le découplage et le scaling, mais elle déplace la complexité du call graph vers le modèle opérationnel. Comprennez quel pattern vous utilisez : notification, event-carried state transfer, event sourcing ou CQRS. Designez les événements autour de l'intention métier, pas des lignes de base de données. Choisissez la chorégraphie ou l'orchestration en fonction de la complexité du workflow. N'imposez l'ordering que là où il est vraiment nécessaire. Rendez chaque consommateur idempotent. Traitez les retries, les Dead Letter Queues, la backpressure et l'observabilité comme des préoccupations de premier ordre. Gouvernez les schémas formellement. Et soyez honnête sur le fait que votre problème ait réellement besoin d'événements ou qu'un design synchrone plus simple ne vous conviendrait pas mieux.
Le but n'est pas d'utiliser plus d'événements. Le but est de construire des systèmes qui tombent moins souvent en panne, se rétablissent plus vite et restent compréhensibles à mesure qu'ils grandissent.

Écrit par
Miracle Kalu
Senior Full Stack Engineer
Vous avez aimé cet article ?
Je suis disponible pour des rôles d'ingénierie senior et du conseil technique. Parlons-en.
Prendre contact →Publié le 13 juin 2026 · 16 min de lecture