Le Transactional Outbox est un motif d'architecture (pattern) utilisé pour garantir la cohérence entre l'état d'une base de données et les messages/événements publiés dans les systèmes distribués (architectures microservices, event-driven). Il résout le problème dit du « dual write » : l'impossibilité d'écrire atomiquement dans deux systèmes distincts (la base de données ET le broker de messages).

Contexte et Problème
Une opération métier (ex. : « créer une commande ») doit mettre à jour la base locale ET publier un événement (CommandeCreee) pour les autres services. Ces deux systèmes n'ont pas de transaction commune, donc :

Base OK, message perdu : crash entre le COMMIT et l'envoi les autres services ignorent la commande (incohérence durable).
Message envoyé, base annulée : l'événement annonce un fait qui n'existe pas (« ghost event »).
Un try/catch autour de l'envoi ne suffit pas : il reste toujours une fenêtre où le processus peut mourir.

Solution

Écrire l'événement dans la même base et la même transaction que les données métier, dans une table outbox.
Publier ensuite de façon asynchrone via un processus séparé (worker de polling, ou CDC).
Atomicité obtenue localement : soit la commande et son événement sont enregistrés, soit rien ne l'est.

Garantie réelle : at-least-once. Un crash après la publication mais avant la mise à jour du statut provoque une republication. Le motif élimine les pertes, pas les doublons : les consommateurs (ou une table inbox) doivent être idempotents. L'« exactly-once » de bout en bout n'est pas fourni par ce motif seul.

Implémentation Typique

Table outbox

CREATE TABLE outbox (
id UUID PRIMARY KEY,
aggregate_type VARCHAR NOT NULL, Ex. : "Commande"
aggregate_id UUID NOT NULL, ID de l'entité
event_type VARCHAR NOT NULL, Ex. : "CommandeCreee"
event_payload JSONB NOT NULL,
status VARCHAR NOT NULL, "pending", "published", "failed"
created_at TIMESTAMP NOT NULL,
published_at TIMESTAMP,
retries INT DEFAULT 0
);

Workflow

Étape 1 — transaction métier :
BEGIN;
INSERT INTO commandes (...) VALUES (...);

INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, event_payload, status, created_at)
VALUES (..., 'Commande', ..., 'CommandeCreee', ..., 'pending', NOW());
COMMIT;

Étape 2 — relais (worker) : lit les événements pending, les publie sur le broker, puis marque published. En cas d'échec : incrément de retries et nouvelle tentative avec backoff exponentiel, puis mise en failed / dead-letter au-delà d'un seuil.

Points de vigilance

Verrouillage : SELECT ... FOR UPDATE SKIP LOCKED (PostgreSQL) pour éviter que plusieurs workers publient le même événement.
Ordre : publier par aggregate_id (clé de partition Kafka) et par created_at/sequence_id croissant pour conserver l'ordre par agrégat.
Idempotence côté consommateur : déduplication sur event_id.

Avantages

Pas de perte d'événement : l'événement est durable dès le commit métier.
Cohérence : l'événement existe si et seulement si l'écriture métier a réussi.
Découplage : le broker peut être indisponible sans bloquer la transaction métier.
Résilience : les échecs temporaires sont absorbés par les réessais.
Auditabilité : la table conserve une trace des événements émis.

Variantes

Polling : un worker interroge la table (simple, portable, latence de l'ordre de l'intervalle de scrutation).
CDC / Transaction Log Tailing : Debezium lit le WAL/binlog de la base et publie les insertions dans outbox — faible latence, pas de charge de requêtes, mais dépendance à la base et à l'infrastructure Kafka Connect.
Outbox + Inbox : côté consommateur, une table inbox enregistre les event_id traités pour rejeter les doublons.
Outbox « éphémère » : INSERT puis DELETE immédiat dans la même transaction — le message n'existe que dans le log, capté par CDC, ce qui évite le nettoyage.

Exemple (pseudocode)

Transaction métier
def creer_commande(commande):
with transaction:
db.execute(
"INSERT INTO commandes (id, client_id, montant) VALUES (?, ?, ?)",
commande.id, commande.client_id, commande.montant
)
db.execute(
"""
INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, event_payload, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, NOW())
""",
uuid4(), "Commande", commande.id, "CommandeCreee",
json.dumps({"commande_id": commande.id, "montant": commande.montant}),
"pending"
)

Worker de publication
def publier_evenements():
while True:
evenements = db.fetch_all("""
SELECT FROM outbox
WHERE status = 'pending'
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED
""")

for event in evenements:
try:
broker.publish(event.event_type, event.event_payload, key=event.aggregate_id)
db.execute(
"UPDATE outbox SET status = 'published', published_at = NOW() WHERE id = ?",
event.id
)
except Exception as e:
db.execute("UPDATE outbox SET retries = retries + 1 WHERE id = ?", event.id)
log.error(f"Échec publication événement {event.id}: {e}")

sleep(1)

Cas d'usage
Microservices : propager un changement d'état (commande paiement livraison).
Sagas : émettre de façon fiable les commandes/événements coordonnant une transaction longue.
Event Sourcing / CQRS : alimenter les projections et les consommateurs externes.
Intégration : notifier un système tiers, envoyer un e-mail « exactement une fois logiquement ».

Limites

Latence : le polling introduit un délai (atténué par le CDC).
Doublons inévitables : garantie at-least-once, l'idempotence est obligatoire côté consommateur.
Complexité opérationnelle : worker supplémentaire, monitoring, purge.
Charge sur la base : écritures et scrutations additionnelles ; la table doit être indexée (status, created_at) et purgée.
Ordre global impossible : seul l'ordre par agrégat est raisonnablement garanti.
Non applicable tel quel si l'écriture métier ne va pas dans une base transactionnelle.

Alternatives

Two-Phase Commit (2PC / XA) : atomicité réelle entre base et broker, mais couplage fort, verrous longs, faible scalabilité, support limité (Kafka ne le fait pas).
CDC pur : publier directement les changements des tables métier, sans table outbox — mais les événements sont alors dictés par le schéma physique, pas par le métier.
Listen to yourself : publier d'abord l'événement, puis se l'appliquer à soi-même en le consommant.
Event Sourcing : le journal d'événements est la source de vérité, la question du double write disparaît.

Bonnes pratiques

event_id stable transporté dans le message pour la déduplication.
Monitoring : alerte sur l'âge du plus vieux pending et sur la profondeur de la file.
Nettoyage : purge/archivage des published (ex. : après 7 jours), idéalement par partitionnement temporel.
Dead-letter : isoler les événements en échec permanent après N tentatives.
Tests de chaos : tuer le worker entre la publication et le UPDATE pour valider l'idempotence des consommateurs.

Résumé : le Transactional Outbox rend atomique, via une seule transaction locale, l'écriture métier et l'enregistrement de l'événement à publier ; un relais asynchrone (polling ou CDC) se charge ensuite de l'envoi au broker. Il garantit qu'aucun événement n'est perdu (at-least-once) mais pas l'absence de doublons : la déduplication idempotente côté consommateur fait partie intégrante du motif.