Outbox Pattern : pourquoi votre EventBus ne suffit pas
Pourquoi publier sur un bus après le commit perd des événements, et comment l'Outbox, l'Inbox, le CDC et la Dead Letter Queue garantissent une livraison au moins une fois.
Par Elias VarenMis à jour le 5 min de lectureMembres
Le serveur a committé la transaction, et l'événement est dans l'EventStore. Le processus essaie de publier vers le broker, et à ce moment précis la machine crashe.
L'événement est persisté, mais le broker ne l'a jamais reçu et les consommateurs ne le verront jamais. Les projections sont désynchronisées, les mails ne partent pas, le service externe ignore que quelque chose s'est passé. Un système dans lequel un événement peut être persisté sans être publié donne aux autres une version fausse de ce qui est arrivé.
Deux écritures, aucune transaction commune
Publier un événement vers un broker externe implique deux opérations distinctes :
- Écrire dans l'EventStore, une transaction SQL durable et atomique ;
- Publier vers le broker, un appel réseau hors de toute transaction.
Ces deux opérations ne peuvent pas tenir dans la même transaction atomique, sauf à recourir à une transaction distribuée (commit à deux phases) que la plupart des brokers ne proposent pas et que je n'ajouterais pas pour ce problème. Les enchaîner l'une après l'autre ouvre une fenêtre de défaillance entre les deux.
Séquence naïve :
1. BEGIN
2. INSERT INTO events ...
3. COMMIT ← succès, l'event est en base
4. broker.publish(event) ← crash ici → event perdu pour toujoursInverser l'ordre ne fait que déplacer le problème :
Séquence inverse :
1. broker.publish(event) ← succès
2. BEGIN
3. INSERT INTO events ...
4. COMMIT ← crash ici → event publié mais jamais persistéDans les deux cas, la base de données et le broker sont désynchronisés, et l'un des deux détient une information que l'autre n'a pas.
Les limites d'AsyncEventBus, un bus in-process
Dans l'implémentation de référence, AsyncEventBus est un bus in-process :
// src/core/event/AsyncEventBus.ts
export class AsyncEventBus implements IEventBus {
async publish(envelopes: EventEnvelope[]): Promise<void> {
for (const envelope of envelopes) {
const handlers = this.registry.getHandlers(envelope.eventType);
for (const handler of handlers) {
await handler.handle(envelope);
}
}
}
}Sources et historique
- PostgreSQL 17 — SELECT, The Locking Clause
- PostgreSQL 17 — INSERT, ON CONFLICT Clause
- PostgreSQL 17 — Logical Decoding Concepts (replication slots)
- Debezium — PostgreSQL connector
- Correction : L'inbox ne rend un doublon sans effet que si son insertion et l'effet du traitement sont commités ensemble ; pour un mail ou un appel à une API externe, il faut une clé d'idempotence. Le texte laissait croire l'inverse.
- Correction : Ajout de deux conditions omises : plusieurs pollers en SKIP LOCKED ne préservent pas l'ordre de publication, et le slot de réplication du CDC retient le WAL tant que Debezium ne l'a pas consommé.
Vérification technique : 6 octobre 2026. Une erreur ? Voici comment elle est corrigée.
Le dossier complet : Systèmes event-sourcés
À lire ensuite
Toute la rubrique ArchitectureArchitecture
Sagas : la compensation que personne ne teste
Une compensation s'exécute rarement, dans le pire état possible, et peut elle-même échouer. Comment énumérer les états de panne partielle d'une saga, ordonner les étapes autour d'un pivot, et tester chaque chemin de retour par injection de pannes.
8 minMembres
Architecture
Circuit breakers : pourquoi ils déçoivent
Pourquoi un disjoncteur binaire répond mal aux pannes partielles, comment il transforme 10 % d'erreurs en panne totale, et par quoi le remplacer dans la plupart des cas.
7 minLecture libre
Architecture
Le retry qui a tué la production
Comment trois couches qui rejouent chacune trois fois multiplient la charge par soixante-quatre, pourquoi le backoff n'y change rien, et comment un budget de retry borne l'amplification.
7 minLecture libre