20 déc. 2025·8 min de lecture

Le pattern outbox dans PostgreSQL pour des intégrations d'API fiables

Apprenez le pattern outbox : stocker les événements dans PostgreSQL puis les livrer aux APIs tierces avec retries, ordonnancement et déduplication.

Le pattern outbox dans PostgreSQL pour des intégrations d'API fiables

Pourquoi les intégrations échouent même quand votre application fonctionne

Il est courant de voir une action « réussie » dans votre application alors que l'intégration en arrière-plan a échoué silencieusement. Votre écriture en base est rapide et fiable. Un appel à une API tierce ne l'est pas. Cela crée deux mondes différents : votre système dit que le changement a eu lieu, mais le système externe ne l'a jamais reçu.

Un exemple typique : un client passe une commande, votre application la sauvegarde dans PostgreSQL, puis tente de prévenir un prestataire de livraison. Si le prestataire met 20 secondes à répondre et que votre requête abandonne, la commande est bien réelle, mais l'expédition n'est jamais créée.

Les utilisateurs vivent cela comme un comportement confus et incohérent. Des événements manquants ressemblent à « rien ne s'est passé ». Des événements en double provoquent des « pourquoi ai-je été facturé deux fois ? ». Les équipes support peinent aussi, car il est difficile de déterminer si le problème vient de votre app, du réseau ou du partenaire.

Les retries aident, mais les retries seuls ne garantissent pas la correction. Si vous réessayez après un timeout, vous pouvez envoyer le même événement deux fois parce que vous ne savez pas si le partenaire a bien reçu la première requête. Si vous réessayez hors d'ordre, vous pourriez envoyer « Order shipped » avant « Order paid ».

Ces problèmes proviennent généralement de la concurrence normale : plusieurs workers traitant en parallèle, plusieurs serveurs écrivant en même temps, et des files « best effort » où le timing change sous charge. Les modes de défaillance sont prévisibles : les APIs décrochent ou ralentissent, les réseaux perdent des requêtes, les processus plantent au mauvais moment, et les retries créent des doublons quand rien n'impose l'idempotence.

Le pattern outbox existe parce que ces échecs sont normaux.

Qu'est-ce que le pattern outbox, en clair

Le pattern outbox est simple : quand votre application effectue un changement important (comme créer une commande), elle écrit aussi un petit enregistrement « événement à envoyer » dans une table de base de données, dans la même transaction. Si le commit de la base réussit, vous savez que les données métier et l'événement existent ensemble.

Ensuite, un worker séparé lit la table outbox et délivre ces événements aux APIs tierces. Si une API est lente, indisponible ou timeoute, votre requête utilisateur principale réussit toujours parce qu'elle n'attend pas l'appel externe.

Cela évite les états gênants que vous obtenez quand vous appelez une API dans le handler de requête :

  • La commande est sauvegardée, mais l'appel API échoue.
  • L'appel API réussit, mais votre app plante avant de sauvegarder la commande.
  • L'utilisateur réessaie, et vous envoyez la même chose deux fois.

Le pattern outbox aide surtout contre les événements perdus, les échecs partiels (base ok, API externe non), les envois accidentels en double, et des retries plus sûrs (vous pouvez réessayer plus tard sans deviner).

Il ne corrige pas tout. Si votre payload est erroné, vos règles métier sont incorrectes, ou l'API tierce rejette les données, vous aurez toujours besoin de validation, d'un bon traitement des erreurs et d'un moyen d'inspecter et corriger les événements échoués.

Concevoir une table outbox dans PostgreSQL

Une bonne table outbox est volontairement ennuyeuse. Elle doit être facile à écrire, facile à lire et difficile à mal utiliser.

Voici un schéma de base pratique que vous pouvez adapter :

create table outbox_events (
  id            bigserial primary key,
  aggregate_id  text not null,
  event_type    text not null,
  payload       jsonb not null,
  status        text not null default 'pending',
  created_at    timestamptz not null default now(),
  available_at  timestamptz not null default now(),
  attempts      int not null default 0,
  locked_at     timestamptz,
  locked_by     text,
  meta          jsonb not null default '{}'::jsonb
);

Choisir un ID

Utiliser bigserial (ou bigint) garde l'ordre simple et les index rapides. Les UUID sont excellents pour l'unicité entre systèmes, mais ils ne se trient pas dans l'ordre de création, ce qui peut rendre le polling moins prévisible et alourdir les index.

Un compromis courant : conservez id en bigint pour l'ordonnancement, et ajoutez un event_uuid séparé si vous avez besoin d'un identifiant stable à partager entre services.

Index importants

Votre worker interrogera les mêmes motifs toute la journée. La plupart des systèmes ont besoin de :

  • Un index comme (status, available_at, id) pour récupérer les prochains événements en attente dans l'ordre.
  • Un index sur (locked_at) si vous prévoyez d'expirer des verrous obsolètes.
  • Un index comme (aggregate_id, id) si vous livrez parfois par agrégat dans l'ordre.

Garder les payloads stables

Conservez des payloads petits et prévisibles. Stockez ce dont le récepteur a réellement besoin, pas toute votre ligne. Ajoutez une version explicite (par exemple dans meta) pour pouvoir faire évoluer les champs en toute sécurité.

Utilisez meta pour le routage et le contexte de debug, comme l'ID du tenant, le correlation ID, le trace ID et une clé de déduplication. Ce contexte supplémentaire paie plus tard quand le support doit répondre à « que s'est-il passé pour cette commande ? »

Comment stocker les événements en toute sécurité avec votre écriture métier

La règle la plus importante est simple : écrivez les données métier et l'événement outbox dans la même transaction de base de données. Si la transaction commit, les deux existent. Si elle rollbacke, aucun des deux n'existe.

Exemple : un client passe une commande. Dans une seule transaction, vous insérez la ligne de commande, les lignes d'articles, et une ligne outbox comme order.created. Si une étape échoue, vous ne voulez pas qu'un événement « créé » s'échappe dans la nature.

Un événement ou plusieurs ?

Commencez par un événement par action métier quand c'est possible. C'est plus simple à raisonner et moins coûteux à traiter. Scindez en plusieurs événements seulement quand différents consommateurs ont réellement besoin de timings ou de payloads différents (par exemple order.created pour la logistique et payment.requested pour la facturation). Générer beaucoup d'événements pour un clic augmente les retries, les problèmes d'ordonnancement et la gestion des doublons.

Quel payload stocker ?

Vous choisissez généralement entre :

  • Snapshot : stocker les champs clés tels qu'ils étaient au moment de l'action (total de la commande, devise, ID client). Cela évite des lectures supplémentaires plus tard et rend le message stable.
  • Référence : stocker uniquement l'ID de la commande et laisser le worker charger les détails ensuite. Cela garde l'outbox léger, mais ajoute des lectures et peut changer si la commande est modifiée.

Un milieu pratique : identifiants plus un petit snapshot des valeurs critiques. Cela aide les récepteurs à agir vite et facilite le debug.

Gardez la frontière transactionnelle serrée. N'appelez pas d'APIs tierces à l'intérieur de la même transaction.

Délivrer les événements aux APIs tierces : la boucle du worker

Déployez là où votre équipe travaille
Déployez sur AppMaster Cloud ou sur votre propre AWS, Azure ou Google Cloud.
Essayez AppMaster

Une fois les événements dans votre outbox, vous avez besoin d'un worker qui les lit et appelle l'API tierce. C'est ce qui transforme le pattern en une intégration fiable.

Le polling est généralement l'option la plus simple. LISTEN/NOTIFY peut réduire la latence, mais ajoute des éléments en mouvement et nécessite toujours un repli si des notifications sont manquées ou si le worker redémarre. Pour la plupart des équipes, un polling régulier avec un petit lot est plus facile à exploiter et à déboguer.

Réclamer des lignes en toute sécurité

Le worker doit réclamer des lignes pour que deux workers ne traitent jamais le même événement en même temps. Dans PostgreSQL, l'approche classique est de sélectionner un lot en utilisant des verrous de ligne et SKIP LOCKED, puis de les marquer en cours de traitement.

Un flux d'état pratique est :

  • pending : prêt à être envoyé
  • processing : verrouillé par un worker (utilisez locked_by et locked_at)
  • sent : livré avec succès
  • failed : arrêté après le nombre max d'essais (ou mis de côté pour revue manuelle)

Gardez les lots petits pour être gentil avec la base. Un lot de 10 à 100 lignes, toutes les 1 à 5 secondes, est un point de départ courant.

Quand un appel réussit, marquez la ligne sent. Quand il échoue, incrémentez attempts, fixez available_at dans le futur (backoff), libérez le verrou et remettez-la en pending.

Logs utiles (sans fuiter de secrets)

De bons logs rendent les échecs exploitables. Loggez l'id outbox, le type d'événement, le nom de la destination, le nombre de tentatives, le temps et le statut HTTP ou la classe d'erreur. Évitez les corps de requête, les headers d'auth et les réponses complètes. Si vous avez besoin de corrélation, stockez un ID de requête sûr ou un hash au lieu des données brutes.

Règles d'ordonnancement qui fonctionnent en production

Transformez votre workflow en logiciel
Générez un backend, une interface web et des apps mobiles depuis un projet unique avec un code source propre.
Construire l'application

Beaucoup d'équipes commencent par « envoyer les événements dans le même ordre que nous les avons créés ». Le piège : « le même ordre » n'est rarement global. Si vous forcez une file globale, un seul client lent ou une API capricieuse peut bloquer tout le monde.

Une règle pratique : préservez l'ordre par groupe, pas pour tout le système. Choisissez une clé de regroupement correspondant à la façon dont le monde extérieur pense vos données, comme customer_id, account_id, ou un aggregate_id comme order_id. Garantissez ensuite l'ordre à l'intérieur de chaque groupe tout en délivrant de nombreux groupes en parallèle.

Workers parallèles sans casser l'ordre

Faites tourner plusieurs workers, mais assurez-vous que deux workers ne traitent pas le même groupe en même temps. L'approche usuelle : toujours livrer l'événement non envoyé le plus ancien pour un aggregate_id donné et permettre le parallélisme entre agrégats différents.

Gardez les règles de réclamation simples :

  • Ne livrez que l'événement pending le plus ancien par groupe.
  • Autorisez le parallélisme entre groupes, pas à l'intérieur d'un groupe.
  • Réclamez un événement, envoyez-le, mettez à jour le statut, puis passez au suivant.

Quand un événement bloque les suivants

Tôt ou tard, un « poison » event échouera pendant des heures (payload invalide, token révoqué, panne du prestataire). Si vous imposez strictement l'ordre par groupe, les événements suivants de ce groupe attendront, mais les autres groupes doivent continuer.

Un compromis opérationnel : plafonnez les retries par événement. Après cela, marquez-le failed et mettez en pause seulement ce groupe jusqu'à correction. Cela empêche un client cassé de ralentir tout le monde.

Retries sans empirer la situation

Les retries sont le point où un bon outbox devient fiable ou bruyant. Le but est simple : réessayer quand ça a des chances de marcher, et arrêter rapidement quand ça ne marchera pas.

Utilisez un backoff exponentiel et un plafond dur. Par exemple : 1 minute, 2 minutes, 4 minutes, 8 minutes, puis arrêter (ou continuer avec un délai max comme 15 minutes). Fixez toujours un nombre maximal de tentatives pour qu'un événement problématique ne puisse pas encombrer le système indéfiniment.

Tous les échecs ne méritent pas d'être retentés. Gardez des règles claires :

  • Retry : timeouts réseau, resets de connexion, problèmes DNS et réponses HTTP 429 ou 5xx.
  • Ne pas retry : HTTP 400 (bad request), 401/403 (auth), 404 (endpoint erroné) ou erreurs de validation détectables avant envoi.

Stockez l'état de retry sur la ligne outbox. Incrémentez attempts, fixez available_at pour la prochaine tentative et enregistrez un court résumé d'erreur sûr (code de statut, classe d'erreur, message tronqué). N'enregistrez pas les payloads complets ni les données sensibles dans les champs d'erreur.

Les limites de taux demandent un traitement spécial. Si vous recevez un HTTP 429, respectez Retry-After s'il existe. Sinon, reculez plus agressivement pour éviter un pic de retries.

Déduplication et bases de l'idempotence

Implémentez le pattern visuellement
Utilisez la logique métier visuelle pour écrire les données et mettre en file les événements dans une seule transaction.
Créer un backend

Si vous construisez des intégrations fiables, supposez que le même événement peut être envoyé deux fois. Un worker peut planter après l'appel HTTP mais avant d'enregistrer le succès. Un timeout peut masquer un succès. Un retry peut chevaucher une première tentative lente. Le pattern outbox réduit les événements manqués, mais ne prévient pas les doublons en soi.

L'approche la plus sûre est l'idempotence : des livraisons répétées produisent le même résultat qu'une seule livraison. Lors de l'appel à une API tierce, incluez une clé d'idempotence stable pour cet événement et cette destination. Beaucoup d'APIs acceptent un header ; sinon, mettez la clé dans le corps.

Une clé simple est destination + ID d'événement. Pour un événement evt_123, utilisez toujours quelque chose comme destA:evt_123.

De votre côté, empêchez les envois en double en maintenant un journal de livraison et en faisant respecter une contrainte unique comme (destination, event_id). Même si deux workers se concurrencent, un seul pourra créer l'enregistrement « nous envoyons ceci ».

Les webhooks dupliquent aussi

Si vous recevez des callbacks webhook (comme « livraison confirmée » ou « statut mis à jour »), traitez-les de la même façon. Les prestataires réessaient, et vous pouvez voir plusieurs fois le même payload. Stockez les IDs de webhook traités, ou calculez un hash stable depuis l'ID message du fournisseur et rejetez les répétitions.

Combien de temps garder les données

Conservez les lignes outbox jusqu'à avoir enregistré le succès (ou un échec final accepté). Gardez les journaux de livraison plus longtemps, car ce sont vos traces d'audit quand quelqu'un demande « Est-ce qu'on l'a envoyé ? »

Une approche courante :

  • Lignes outbox : supprimer ou archiver après succès plus une courte fenêtre de sécurité (jours).
  • Journaux de livraison : conserver des semaines ou des mois, selon conformité et besoins du support.
  • Clés d'idempotence : garder au moins aussi longtemps que les retries peuvent se produire (et plus longtemps pour les doublons webhook).

Étape par étape : implémenter le pattern outbox

Décidez ce que vous publierez. Gardez les événements petits, ciblés et faciles à rejouer. Une bonne règle : un fait métier par événement, avec assez de données pour que le récepteur agisse.

Construire la fondation

Choisissez des noms d'événements clairs (par exemple order.created, order.paid) et versionnez vos schémas de payload (comme v1, v2). La version permet d'ajouter des champs plus tard sans casser les consommateurs plus anciens.

Créez votre table outbox PostgreSQL et ajoutez des index pour les requêtes que votre worker exécutera le plus, en particulier (status, available_at, id).

Mettez à jour votre flux d'écriture pour que le changement métier et l'insertion outbox se produisent dans la même transaction. C'est la garantie centrale.

Ajouter la livraison et le contrôle

Un plan d'implémentation simple :

  • Définir les types d'événements et les versions de payload que vous pouvez supporter à long terme.
  • Créer la table outbox et les index.
  • Insérer une ligne outbox en même temps que la modification des données principales.
  • Construire un worker qui réclame les lignes, envoie vers l'API tierce, puis met à jour le statut.
  • Ajouter la planification des retries avec backoff et un état failed lorsque les tentatives sont épuisées.

Ajoutez des métriques de base pour détecter les problèmes tôt : lag (âge du plus ancien événement non envoyé), taux d'envoi et taux d'échec.

Un exemple simple : envoyer des événements de commande vers des services externes

Donnez de la visibilité à l'équipe support
Ajoutez l'auth et construisez des outils d'administration pour consulter les événements en attente, envoyés et échoués.
Commencer maintenant

Un client passe une commande dans votre application. Deux choses doivent se produire hors de votre système : le prestataire de facturation doit débiter la carte, et le prestataire d'expédition doit créer un envoi.

Avec le pattern outbox, vous n'appelez pas ces APIs dans la requête de checkout. Vous sauvegardez la commande et une ligne outbox dans la même transaction PostgreSQL, ainsi vous évitez « commande sauvegardée, mais pas de notification » (ou l'inverse).

Une ligne outbox typique pour un événement de commande contient un aggregate_id (l'ID de la commande), un event_type comme order.created et un payload JSONB avec totaux, articles et détails de destination.

Un worker récupère ensuite les lignes pending et appelle les services externes (soit dans un ordre défini, soit en émettant des événements séparés comme payment.requested et shipment.requested). Si un prestataire est down, le worker enregistre la tentative, planifie la prochaine tentative en repoussant available_at et passe à la suite. La commande existe toujours, et l'événement sera réessayé plus tard sans bloquer de nouveaux checkouts.

L'ordonnancement est généralement « par commande » ou « par client ». Assurez-vous que les événements avec le même aggregate_id sont traités un par un pour que order.paid n'arrive jamais avant order.created.

La déduplication vous évite des doubles prélèvements ou des envois en double. Envoyez une clé d'idempotence quand le tiers le supporte, et conservez un enregistrement de livraison par destination pour qu'un retry après un timeout n'entraîne pas une deuxième action.

Vérifications rapides avant mise en production

Conservez une sortie vers le code source
Exportez du vrai code Go, Vue3 et Kotlin ou SwiftUI lorsque vous avez besoin d'un contrôle total.
Générer le code

Avant de faire confiance à une intégration pour déplacer de l'argent, notifier des clients ou synchroniser des données, testez les bords : crashs, retries, doublons et workers multiples.

Vérifications qui captent les échecs courants :

  • Confirmez que la ligne outbox est créée dans la même transaction que la modification métier.
  • Vérifiez que l'expéditeur est sûr à exécuter en plusieurs instances. Deux workers ne doivent pas envoyer le même événement en même temps.
  • Si l'ordonnancement importe, définissez la règle en une phrase et appliquez-la avec une clé stable.
  • Pour chaque destination, décidez comment prévenir les doublons et comment prouver « nous l'avons envoyé ».
  • Définissez la sortie : après N tentatives, déplacez l'événement en failed, conservez le dernier résumé d'erreur et fournissez une action simple de retraitement.

Un rappel réaliste : Stripe peut accepter une requête mais votre worker planter avant d'enregistrer le succès. Sans idempotence, un retry peut provoquer une double action. Avec l'idempotence et un enregistrement de livraison, le retry devient sûr.

Étapes suivantes : déployer sans perturber votre application

Le déploiement est l'étape où les projets outbox réussissent ou stagnent. Commencez petit pour observer un comportement réel sans risquer toute la couche d'intégration.

Démarrez avec une intégration et un type d'événement. Par exemple, n'envoyez que order.created à une API fournisseur pendant que tout le reste reste inchangé. Cela vous donne une base propre pour mesurer débit, latence et taux d'échec.

Rendez les problèmes visibles tôt. Ajoutez des dashboards et alertes pour le lag de l'outbox (combien d'événements attendent et quel âge a le plus ancien) et le taux d'échec (combien sont bloqués en retry).

Ayez un plan de retraitement sûr avant le premier incident. Décidez ce que signifie « retraiter » : réessayer le même payload, reconstruire le payload à partir des données actuelles, ou l'envoyer en revue manuelle. Documentez quels cas peuvent être renvoyés en toute sécurité et lesquels nécessitent une vérification humaine.

Si vous construisez cela avec une plateforme no-code comme AppMaster (appmaster.io), la même structure s'applique : écrivez vos données métier et une ligne outbox ensemble dans PostgreSQL, puis lancez un processus backend séparé pour délivrer, réessayer et marquer les événements comme envoyés ou échoués.

FAQ

Quand devrais-je utiliser le pattern outbox plutôt que d'appeler l'API directement ?

Utilisez le pattern outbox lorsqu'une action utilisateur met à jour votre base de données et doit déclencher du travail dans un autre système. Il est particulièrement utile quand les timeouts, réseaux instables ou pannes de prestataires peuvent créer des situations « sauvegardé chez nous, manquant chez eux ».

Pourquoi l'insertion dans l'outbox doit-elle se faire dans la même transaction que l'écriture métier ?

Écrire la ligne métier et la ligne outbox dans la même transaction de base de données vous donne une garantie simple : soit les deux existent, soit aucun des deux. Cela évite les échecs partiels comme « l'appel API a réussi mais la commande n'a pas été sauvegardée » ou « commande sauvegardée mais l'appel API n'a jamais eu lieu ».

Quels champs une table outbox devrait-elle inclure pour être pratique ?

Un bon choix par défaut est id, aggregate_id, event_type, payload, status, created_at, available_at, attempts, plus des champs de verrou comme locked_at et locked_by. Cela simplifie l'envoi, la planification des retries et la concurrence sans complexifier la table.

Quels index sont les plus importants pour une table outbox dans PostgreSQL ?

Une base commune est un index sur (status, available_at, id) pour que les workers puissent récupérer rapidement le prochain lot d'événements envoyables dans l'ordre. N'ajoutez d'autres index que si vous interrogez vraiment ces champs, car les index supplémentaires ralentissent les inserts.

Mon worker doit-il interroger la table outbox ou utiliser LISTEN/NOTIFY ?

Le polling est l'approche la plus simple et la plus prévisible pour la plupart des équipes. Commencez par de petits lots et un intervalle court, puis ajustez selon la charge et le retard ; vous pourrez ajouter des optimisations plus tard, mais une boucle simple est plus facile à déboguer quand ça tourne mal.

Comment empêcher deux workers d'envoyer le même événement outbox ?

Réclamez les lignes avec des verrous au niveau ligne pour que deux workers ne puissent pas traiter le même événement simultanément, généralement avec SKIP LOCKED. Ensuite marquez la ligne comme processing avec un timestamp et un ID de worker, envoyez-la, puis marquez-la sent ou remettez-la en pending avec un available_at futur.

Quelle est la stratégie de retry la plus sûre pour les livraisons outbox ?

Utilisez un backoff exponentiel avec un plafond d'essais, et réessayez uniquement les échecs probablement temporaires. Timeouts, erreurs réseau et réponses HTTP 429/5xx sont de bons candidats au retry ; les erreurs de validation et la plupart des 4xx doivent être traitées comme finales jusqu'à correction des données ou de la configuration.

Le pattern outbox garantit-il une livraison exactement une fois ?

Supposez que des doublons restent possibles, surtout si un worker plante après l'appel HTTP mais avant d'enregistrer le succès. Utilisez une clé d'idempotence stable par destination et par événement, et conservez un journal de livraison (avec une contrainte d'unicité) pour qu'en cas de course deux workers ne puissent pas créer deux envois.

Comment gérer l'ordonnancement sans ralentir tout le système ?

Préservez l'ordre à l'intérieur d'un groupe, pas globalement. Utilisez une clé de regroupement comme aggregate_id (ID de commande) ou customer_id, ne traitez qu'un événement à la fois par groupe et autorisez le parallélisme entre groupes pour qu'un client lent ne bloque pas tout le monde.

Que faire d'un « poison » event qui échoue sans cesse ?

Après un nombre maximal de tentatives, marquez l'événement failed, conservez un court résumé d'erreur sûr et stoppez le traitement des événements suivants pour ce même groupe jusqu'à correction manuelle. Cela limite la surface affectée et évite des retries sans fin, tout en laissant les autres groupes avancer.

Facile à démarrer
Créer quelque chose d'incroyable

Expérimentez avec AppMaster avec un plan gratuit.
Lorsque vous serez prêt, vous pourrez choisir l'abonnement approprié.

Démarrer