Le request/response synchrone est simple à raisonner. Le service A appelle le service B, attend, obtient une réponse ou une erreur. Ce modèle casse quand une action doit se propager vers plusieurs systèmes indépendants, quand ils scalent à des rythmes différents, ou quand une panne temporaire d'une dépendance ne doit pas bloquer tout le checkout.
L'architecture event-driven change le contrat : un producteur publie un fait sur quelque chose qui s'est déjà produit, et les consommateurs réagissent à leur rythme. Vous gagnez du découplage et de la scalabilité horizontale. Vous payez en cohérence éventuelle, en debug plus dur, et en modes de panne qui ne tiennent pas dans une seule stack trace.
Ce billet est une carte pour ingénieurs backend sur des systèmes à forte charge : la séparation événement/commande, les brokers, les consommateurs idempotents, le pattern outbox, et les cas où les événements sont le mauvais outil.
Ce que « event-driven » veut vraiment dire
Dans un design event-driven, les composants communiquent en émettant et en réagissant à des événements, en général via un message broker ou un log. Les producteurs ne savent pas qui consomme. Les consommateurs ne rappellent pas le producteur pour le même fait. Le couplage passe de « je dois connaître ton API et être en ligne quand je t'appelle » à « nous devons nous entendre sur un schéma de message et sur la reprise quand la livraison est en retard ou dupliquée. »
Formes typiques sous forte charge :
| Forme | Exemple | Pourquoi les événements aident |
|---|---|---|
| Fan-out après un write | Commande passée → stock, email, analytics, fidélité | Un write, beaucoup d'effets de bord indépendants |
| Handoff asynchrone | Upload terminé → antivirus → miniature → index de recherche | Le travail lent quitte le chemin de la requête |
| Intégration | Webhook de paiement → ledger + notification | Les systèmes externes arrivent à leur rythme |
| Stream processing | Click stream → score fraude → feature store | Continu, haut volume, peu de travail par item |
Les événements ne veulent pas dire « remplace chaque API HTTP par une file. » La plupart des systèmes restent hybrides : synchrone pour les lectures/écritures utilisateur qui demandent une réponse immédiate, asynchrone pour le travail qui peut finir plus tard.
Événements vs commandes
Les gens mélangent les deux. Ce sont des contrats différents.
Événement : un fait déjà survenu
Un événement est au passé et immuable une fois publié. Le producteur a déjà validé un changement d'état (ou au moins décidé que le fait est vrai). Les consommateurs peuvent :
- mettre à jour leurs propres modèles
- déclencher des workflows
- ignorer l'événement s'il ne les concerne pas
Exemples :
{
"type": "OrderPlaced",
"eventId": "evt_01J8K2...",
"occurredAt": "2026-02-04T10:15:30Z",
"orderId": "ord_9f3a",
"customerId": "cus_12",
"totalCents": 4599,
"currency": "USD"
}
Le nom reste au passé : OrderPlaced, PaymentCaptured, UserEmailChanged. Le payload doit porter assez de données pour que les consommateurs agissent sans rappeler le producteur pour chaque champ (avec bon sens). Des événements trop fins, avec seulement un id, forcent chaque consommateur dans un chemin de lecture bavard sous charge.
Commande : une instruction à exécuter
Une commande est impérative. Un émetteur veut qu'un récepteur précis fasse une action. Elle peut être acceptée, rejetée ou échouer. Le résultat n'est pas encore un fait.
Exemples : PlaceOrder, ChargeCard, ReserveInventory.
Les commandes voyagent souvent sur des files avec un seul groupe de consommateurs logique (ou un type de worker connu). Les événements vont souvent sur des topics avec beaucoup d'abonnés indépendants.
| Événement | Commande | |
|---|---|---|
| Temps | Passé (OrderPlaced) |
Impératif (PlaceOrder) |
| Propriétaire du résultat | Déjà décidé par le producteur | Décidé par le handler |
| Couplage | Le producteur ignore les consommateurs | L'émetteur cible une responsabilité |
| Fan-out | Naturel (beaucoup d'abonnés) | En général un type de handler |
| Échec | Chaque consommateur retente son travail | La commande peut être rejetée ou compensée |
En pratique, un handler de commande qui réussit émet souvent un événement. PlaceOrder OK → publication de OrderPlaced. Cette séparation garde l'intention d'écriture et la diffusion du fait distinctes.
Brokers et logs : ce que vous achetez vraiment
Le broker (ou log) est l'infrastructure partagée entre producteurs et consommateurs. Le produit compte moins que la sémantique de livraison pour laquelle vous concevez.
Options courantes (modèle mental, pas un match)
| Système | Modèle | Bon défaut pour |
|---|---|---|
| Kafka / Redpanda | Log append-only, offsets consommateur, partitions | Haut débit, replay, beaucoup de consumer groups |
| RabbitMQ | Files, exchanges, routing keys | Work queues, routage complexe, volume plus bas |
| SQS (+ SNS) | Files managées / fan-out | Workers natifs AWS, ops simples |
| NATS / JetStream | Messaging léger + persistance optionnelle | Faible latence, topologies plus simples |
| Google Pub/Sub | Topics/abonnements managés | Fan-out d'événements natif GCP |
Garanties de livraison avec lesquelles vous vivrez
Presque tous les brokers de production donnent de l'at-least-once sur les chemins d'échec qui comptent. Un consommateur peut crasher après traitement mais avant l'ack. Le même message revient.
Concevez pour :
- At-least-once comme base.
- Consommateurs idempotents (section suivante).
- Ordre seulement là où c'est nécessaire (souvent par clé d'agrégat, ex.
orderId), pas un ordre global du système entier. - Rétention / replay si vous devez reconstruire un consommateur ou vous remettre d'un bug.
Le marketing « exactly-once » est en général une combinaison soignée de producteurs transactionnels, de consommateurs idempotents et de fonctionnalités broker. Traitez-le comme une propriété du pipeline entier, pas une case à cocher sur une fiche produit.
Topics, partitions et clés
Pour les systèmes basés sur un log :
- Placez les événements qui doivent rester ordonnés pour une entité sur la même partition key (ex.
orderId). - Gardez les partitions équilibrées. Une clé chaude devient une partition chaude.
- Séparez les topics d'intégration publics (schémas stables) des topics internes que vous pouvez casser plus librement.
Pour les systèmes basés sur des files :
- Préférez des competing consumers sur une work queue pour paralléliser.
- Utilisez des dead-letter queues (DLQ) pour les messages poison après N échecs.
- Limitez la concurrence pour qu'un pic ne fasse pas fondre la base derrière les workers.
Idempotence : les consommateurs verront des doublons
Si vous ne retenez qu'une règle opérationnelle : chaque consommateur doit tolérer le même événement deux fois.
Les doublons apparaissent quand :
- le broker relivre après un crash ou un blip réseau
- un producteur retente un publish qui a en fait réussi
- vous rejouez une partition après un correctif
- l'at-least-once rencontre un ack lent
Patterns pratiques
1. Clé d'idempotence stockée avant les effets de bord
Utilisez un id stable du message (eventId, ou une clé naturelle comme paymentId + status). Dans la même transaction base que votre write :
INSERT INTO processed_events (event_id, consumer, processed_at)
VALUES ($1, 'inventory-service', now())
ON CONFLICT (event_id, consumer) DO NOTHING;
-- if insert did nothing, skip business work
Si l'insert gagne, appliquez le changement métier dans la même transaction. S'il perd, vous avez déjà traité cet événement.
2. Idempotence naturelle dans le domaine
Certains writes sont sûrs à répéter par nature :
SET status = 'shipped' WHERE order_id = $1 AND status = 'paid'- Upsert par primary key avec le même payload
- « Ajoute l'item s'il manque » plutôt que « toujours incrémenter »
Préférez les checks de domaine quand ils collent. Ils se lisent mieux qu'une énorme table latérale pour chaque micro-update.
3. Effets de bord sortants (email, webhooks, charges)
Les API externes sont la partie dure. Un second envoi peut double-facturer ou spammer un utilisateur.
- Passez un client request id / clé d'idempotence aux fournisseurs qui le supportent (les API de paiement le font souvent).
- Enregistrez « notification déjà envoyée pour cet événement » avant ou après l'appel, avec une règle claire pour les pannes partielles.
- Préférez des tables « envoyer une fois » au fire-and-forget dans la boucle du consommateur.
Ce qu'il ne faut pas faire
Ne vous fiez pas à « le broker a dit exactly once. » N'utilisez pas seulement des sets en mémoire « j'ai déjà vu ça » sur un consommateur multi-instances. Ne traitez pas l'ordre des messages comme un substitut d'idempotence ; réordonnancement et relivraison arrivent sous charge.
Le problème dual-write et l'outbox
Voici l'échec classique :
1. BEGIN; INSERT order; COMMIT;
2. publish OrderPlaced to broker
Si l'étape 2 échoue après le commit, la commande existe et aucun consommateur n'en entend parler. Si vous inversez l'ordre et que le write DB échoue après le publish, les consommateurs traitent une commande fantôme.
Publier dans la même transaction DB n'est pas disponible sur la plupart des brokers. Deux systèmes indépendants ne partagent pas un commit atomique sans aide.
Outbox transactionnelle
Écrivez la ligne métier et une ligne outbox dans la même transaction base de données. Un processus séparé (ou le CDC) publie les lignes outbox vers le broker, puis les marque envoyées.
BEGIN;
INSERT INTO orders (id, customer_id, total_cents, status)
VALUES ($1, $2, $3, 'placed');
INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, payload, created_at)
VALUES ($4, 'order', $1, 'OrderPlaced', $5::jsonb, now());
COMMIT;
Boucle du relay (simplifiée) :
1. SELECT pending outbox rows (FOR UPDATE SKIP LOCKED)
2. publish to broker
3. mark published_at (or delete)
Propriétés souhaitées :
| Préoccupation | Approche |
|---|---|
| Atomicité état + intention de publier | Même transaction DB |
| Pas d'événements perdus après commit | Le relay retente jusqu'au succès du publish |
| Pas de lignes bloquées sous concurrence | SKIP LOCKED, tailles de batch limitées |
| Publish dupliqué encore possible | Les consommateurs restent idempotents |
| Observabilité | Métriques de lag outbox, âge du plus ancien non envoyé |
CDC comme variante d'outbox
Le Change Data Capture (Debezium et compagnie) lit le log de la base et transforme les changements de lignes en événements. Même idée : la source de vérité est le commit log, pas un publish best-effort de l'app après le commit. Vous concevez toujours schémas, filtres et idempotence des consommateurs.
Inbox (miroir optionnel)
Certaines équipes utilisent aussi une table inbox côté consommateur comme stockage durable de « j'ai reçu l'événement X », puis traitent depuis là. Même thème : rendre le marqueur « traité » transactionnel avec le write de domaine.
Pannes, retries et messages poison
Les systèmes à forte charge échouent de façon partielle. Concevez la boucle du consommateur comme si chaque dépendance pouvait timeout.
- Retry avec backoff pour les erreurs transitoires (lock DB, blip réseau). Plafonnez les tentatives.
- DLQ après N échecs pour qu'un mauvais payload ne bloque pas la partition ou la file pour toujours.
- Alertez sur la profondeur DLQ et le lag outbox. Un lag silencieux est pire qu'une panne bruyante.
- Gardez les handlers courts. Des handlers longs augmentent la chance de relivraison en vol.
- Séparez « traiter l'événement » de « appeler un tiers fragile » quand vous pouvez : traitez vite, enfilez un job dédié pour l'appel fragile.
Sur des logs style Kafka, un consommateur bloqué sur un message poison peut stopper toute la partition. C'est pourquoi la DLQ (ou skip-and-metric avec prudence) n'est pas optionnelle à l'échelle.
Schéma et évolution
Du JSON lâche sans contrat devient une douleur de production après le troisième consommateur.
Règles pratiques :
- Versionnez le type d'événement ou le schéma (
OrderPlaced.v1, ou un champschemaVersion). - Préférez les changements additifs : nouveaux champs optionnels. Évitez de renommer ou de réutiliser des champs.
- Utilisez un registry (Avro/Protobuf/JSON Schema) quand beaucoup d'équipes partagent des topics.
- Documentez quels champs sont requis pour la correction vs une dénormalisation de confort.
- Ne mettez pas de secrets dans les payloads d'événements. Les événements sont souvent retenus et lisibles dans l'org.
Quand un breaking change est inévitable, faites du dual-publish un moment ou créez un nouveau topic et migrez les consommateurs volontairement.
Quand ne pas utiliser un design event-driven
Les événements sont un compromis, pas une promotion. Évitez-les ou limitez-les quand :
| Situation | Préférez plutôt |
|---|---|
| L'utilisateur a besoin d'une réponse immédiate et correcte dans la même requête | API synchrone + transaction DB |
| Une équipe possède un seul déployable et il n'y a pas de fan-out | Appels in-process ou monolithe modulaire |
| Une cohérence forte entre plusieurs agrégats est requise en un clic | Une seule frontière de transaction, ou des sagas seulement si vous acceptez la complexité |
| L'équipe n'a pas d'ops pour brokers, métriques de lag, DLQ, revue de schéma | Architecture plus simple jusqu'à pouvoir opérer la plomberie |
| Le debug est fragile et le trafic est bas | Le request/response est plus facile à tracer de bout en bout |
| Vous n'avez besoin que d'un rapport nocturne | Job batch, pas un topic temps réel |
Évitez aussi le « monolithe distribué sur Kafka » : chaque service a encore besoin des événements de tous les autres pour terminer une seule action utilisateur, sans ownership clair. Vous avez les modes de panne des systèmes distribués sans les bénéfices d'isolation.
Un test utile : si perdre le broker 10 minutes rend le produit core inutilisable plutôt que de retarder seulement les effets de bord, vous avez peut-être mis de la logique de chemin critique sur le mauvais transport.
Checklist minimale de production
Avant d'expédier un chemin d'événements sur un flux à forte charge :
- Événement vs commande est nommé et possédé correctement.
- Le schéma est documenté ; les consommateurs connaissent les champs requis.
- Le producteur utilise outbox (ou CDC), pas l'espoir du dual-write.
- Le consommateur est idempotent face à l'at-least-once.
- L'ordre est défini par clé s'il compte ; pas supposé global.
- Retries + DLQ + alertes de lag existent et sont testés.
- Backpressure : consommateurs et pools DB ne fondent pas sous un replay ou un pic de trafic.
- Vous savez rejouer une journée d'événements après un bug sans double-facturer les utilisateurs.
Pour finir
L'architecture event-driven mérite sa place quand des systèmes indépendants doivent réagir aux mêmes faits à des vitesses différentes, et quand les chemins de requête ne peuvent pas attendre chaque effet de bord. Le coût d'ingénierie est réel : brokers, schémas, idempotence, outboxes et suivi du lag font partie de la feature, pas des extras.
Commencez avec un fait clair (OrderPlaced), une outbox, un consommateur idempotent et des métriques de lag. Élargissez seulement quand le prochain fan-out fait plus mal en appel synchrone qu'en abonné supplémentaire. Cette séquence garde les systèmes à forte charge flexibles sans transformer chaque write en mystère distribué.
