L'outbox transactionnelle en pratique : des e-mails et effets de bord fiables avec Next.js et PostgreSQL
Fiabiliser e-mails et appels tiers dans une application Next.js et PostgreSQL via une outbox transactionnelle : verrous, baux, relances, suivi par destinataire.
Points clés
- Envoyer un e-mail ou appeler une API tierce pendant une transaction métier rend vos données dépendantes de la disponibilité d'un autre service. L'envoyer après la validation, c'est risquer de le perdre. L'outbox transactionnelle règle les deux problèmes.
- Le principe : écrire l'effet de bord comme une ligne d'une table d'outbox dans la même transaction que la modification métier, puis l'exécuter séparément (microservices.io).
- Avec Next.js, envoyez juste après la réponse grâce à
after()pour la rapidité, et faites tourner un processus de relève pour les relances et la reprise.- Rendez-la sûre sur plusieurs instances : prise en charge conditionnelle, bail qui expire, relances à délai croissant et nombre maximal de tentatives.
- Pour un e-mail à plusieurs destinataires, enregistrez la livraison par destinataire, pour qu'une relance ne renvoie jamais le message à ceux qui l'ont déjà reçu.
Nous avons utilisé ce modèle dans la plateforme de suivi d'échantillons cliniques réalisée pour une société de biosciences, où neuf types de notifications par e-mail et chaque invitation d'utilisateur passent par une outbox. Les exigences étaient simples à formuler : l'enregistrement d'un échantillon ne doit jamais échouer parce que le fournisseur d'e-mails est lent, et un coordinateur ne doit jamais manquer une notification sans que personne ne le sache.
Cet article détaille la conception et les compromis retenus. Il s'applique à toute application Node.js adossée à PostgreSQL, mais les exemples utilisent Next.js.
Pourquoi ne pas simplement envoyer l'e-mail après l'enregistrement ?
Parce qu'il existe toujours un instant, entre la validation et l'envoi, où le processus peut s'arrêter. Prenons les deux options naïves :
Envoyer pendant la transaction. Si l'API d'e-mail met dix secondes à répondre, la transaction reste ouverte dix secondes. Si elle échoue, vous annulez une modification métier parfaitement valide, ou vous ignorez l'erreur. Votre chemin d'écriture dépend désormais de la disponibilité d'un tiers.
Envoyer après la validation. La modification métier est en sécurité, mais si le serveur redémarre, si un déploiement se produit ou si l'appel échoue entre la validation et l'envoi, la notification est perdue et rien n'indique qu'elle était due.
C'est le problème classique de la double écriture : deux systèmes (votre base de données et le fournisseur d'e-mails) doivent changer ensemble, et aucune transaction ne couvre les deux. Le modèle de l'outbox transactionnelle, décrit par Chris Richardson, l'évite en faisant de la base de données le seul système écrit pendant la requête.
Le principe en un paragraphe
Quand une modification métier exige un effet de bord, le service insère un événement d'outbox dans la même transaction que la modification. L'événement indique ce qui doit se produire (envoyer cette notification, créer cette invitation) et pour qui. Si la transaction est validée, l'événement existe ; si elle est annulée, il n'a jamais existé. Un processeur distinct lit les événements en attente, exécute l'effet de bord, puis marque chaque événement comme terminé ou planifie une nouvelle tentative.
La garantie obtenue est une livraison au moins une fois : chaque modification validée finit par produire son effet de bord, et un doublon n'est possible que dans une fenêtre très étroite, sur laquelle nous reviendrons.
Que contient une ligne d'outbox ?
Une table d'outbox minimale comprend :
| Colonne | Rôle |
|---|---|
type | Ce qu'il faut faire (par exemple un type de notification, ou « créer une invitation ») |
payload | Les identifiants nécessaires pour le faire |
status | En attente, en cours, terminé ou en échec |
attemptCount | Le nombre de tentatives de livraison |
availableAt | Le moment où l'événement peut être repris (sert aux délais de relance) |
lockedBy, lockedAt | Quel processus l'a pris en charge, et quand |
lastError | La dernière erreur, pour l'exploitation |
Deux ajouts ont fait une vraie différence en pratique.
Une liste de destinataires figée. Les destinataires sont déterminés à la création de l'événement, pas à l'envoi, et enregistrés sur la ligne. Si un coordinateur perd son rôle cinq minutes après l'enregistrement d'un échantillon, la notification concernant cet échantillon part quand même vers les personnes qui y avaient droit à ce moment-là. L'historique des notifications est aussi plus fiable : on voit exactement à qui chaque événement était adressé.
La liste des destinataires déjà servis. Un même événement peut concerner plusieurs personnes. Chaque envoi réussi est enregistré immédiatement sur la ligne. Nous verrons pourquoi dans la partie sur les échecs partiels.
Écrire l'événement : même transaction, à chaque fois
Avec Prisma, la modification métier et l'insertion dans l'outbox partagent une même transaction interactive :
await prisma.$transaction(async (tx) => {
const sample = await tx.sample.create({ data: input });
await tx.auditEvent.create({ data: auditFor(sample) });
await tx.outboxEvent.create({
data: {
type: "SAMPLE_CREATED",
payload: { sampleId: sample.id },
recipientEmails: await resolveRecipients(tx, sample),
},
});
});La règle : aucun chemin de code n'exécute l'effet de bord directement. Le formulaire web, l'import de tableur et l'API machine appellent tous le même service, et écrivent donc tous le même événement d'outbox. Si un seul chemin envoie l'e-mail directement « parce que c'est plus simple », les garanties disparaissent pour ce chemin.
L'exécuter : tout de suite, puis plus tard
Une outbox a besoin d'un processeur. Nous déclenchons la même fonction de traitement de deux façons.
Juste après la réponse, avec after()
Next.js fournit after() pour planifier un travail une fois la réponse envoyée. Dans une Server Action ou un Route Handler, après la validation de la transaction :
import { after } from "next/server";
after(() => processNotificationOutbox({ limit: 20 }));L'utilisateur voit immédiatement sa modification enregistrée, et l'e-mail part généralement en une ou deux secondes. Si cette tentative échoue ou si le processus s'arrête, rien n'est perdu : l'événement attend toujours dans la base.
Un processus de relève pour les relances et la reprise
Un petit processus interne, démarré depuis le fichier instrumentation.ts de Next.js au lancement du serveur, exécute le même traitement toutes les 60 secondes. Il reprend les événements en échec, ceux dont le délai de relance est écoulé, et ceux créés quand l'envoi immédiat n'a pas eu lieu.
Ce choix a une conséquence à souligner : un processus de relève interne exige un serveur Node qui tourne en continu, comme un conteneur Docker ou next start. Sur une plateforme serverless, aucun processus ne tourne entre deux requêtes. Dans ce cas, désactivez-le et appelez le traitement depuis un planificateur externe (une tâche cron qui appelle une route protégée).
Fonctionner sans risque sur plusieurs instances
Dès que deux instances de l'application tournent, deux processeurs peuvent tenter de livrer le même événement. Trois mécanismes l'empêchent.
Une prise en charge conditionnelle
Un processus prend un événement en charge avec une mise à jour qui ne réussit que si l'événement est encore en attente :
const claimed = await prisma.outboxEvent.updateMany({
where: { id: event.id, status: "PENDING" },
data: { status: "PROCESSING", lockedBy: workerId, lockedAt: new Date() },
});
if (claimed.count !== 1) return; // un autre processus l'a pris avantUn seul processus peut faire passer une ligne donnée de « en attente » à « en cours » : chaque événement n'est traité que par un seul processus à la fois.
Des baux qui expirent
Si un processus plante après avoir pris un événement, la ligne resterait « en cours » indéfiniment. La prise en charge est donc un bail : au début de chaque passage, le processeur remet en attente les événements verrouillés depuis plus d'un certain délai (15 minutes chez nous), ou les marque en échec s'ils ont épuisé leurs tentatives.
Les baux introduisent un risque subtil. Si un événement prend plus de temps que le bail (beaucoup de destinataires, chacun avec un fournisseur lent), un autre processus peut le reprendre pendant que le premier envoie encore. Deux garde-fous ferment cette brèche : le processus renouvelle son verrou après chaque destinataire, et chaque écriture de résultat (terminé, en échec, destinataire servi) n'est appliquée que s'il détient toujours le verrou. Un processus qui a perdu son bail arrête d'envoyer.
Des relances espacées et un nombre maximal de tentatives
Une livraison en échec repasse en attente avec un availableAt repoussé, en doublant à chaque fois : chez nous 30 secondes, puis 1, 2 et 4 minutes. Après la cinquième tentative infructueuse, l'événement passe en échec et reste visible dans l'historique des notifications avec sa dernière erreur. Les exceptions imprévues suivent le même chemin : un événement ne doit jamais être relancé à l'infini.
Échecs partiels : suivre la livraison par destinataire
Imaginons un événement adressé à quatre personnes, dont la troisième adresse est refusée. Sans suivi par destinataire, la relance renvoie l'e-mail aux quatre, et les deux premiers reçoivent un doublon à chaque tentative.
Enregistrer chaque destinataire servi sur l'événement dès que l'envoi réussit règle le problème. À la relance, le processeur ignore ceux qui ont déjà reçu le message. Une adresse invalide ne bloque plus les autres, et les autres ne sont pas inondés pendant qu'on la corrige.
Cela définit aussi précisément la seule fenêtre de doublon restante : un e-mail en double n'est possible que si le processus s'arrête entre le moment où le fournisseur accepte le message et celui où l'application l'enregistre. C'est une fenêtre de quelques millisecondes, le prix d'une livraison au moins une fois. Si un effet de bord ne doit absolument pas être répété (débiter une carte, par exemple), le système destinataire a aussi besoin d'une clé d'idempotence.
L'exploiter au quotidien
Une outbox est aussi un outil d'exploitation, puisque chaque effet de bord en attente ou en échec est une ligne que l'on peut interroger :
-- Événements en attente et en échec, par type
SELECT type, status, count(*) FROM "OutboxEvent" GROUP BY 1, 2 ORDER BY 1, 2;Quatre habitudes la gardent en bonne santé :
- Alerter sur les lignes en échec. Un événement en échec signifie que quelqu'un n'a pas reçu un message qui lui était dû.
- Afficher l'historique dans le produit. Sur cette plateforme, les équipes voient chaque notification, ses destinataires et son état de livraison, sans ouvrir de console de base de données.
- Purger régulièrement les événements terminés. Les événements terminés ou en échec sont supprimés après une durée de conservation (deux ans par défaut sur ce projet), tandis que les lignes en attente ou en cours ne sont jamais touchées.
- Ne jamais mettre de secrets ni de données personnelles dans le payload. Stockez des identifiants et construisez le message au moment de l'envoi.
Quand une outbox est-elle superflue ?
Quand rien d'important ne dépend de l'effet de bord. Un accusé de réception « merci pour votre message » sur un site vitrine peut être envoyé directement et oublié. Une outbox se justifie quand un effet de bord manqué ou dupliqué a un coût : une notification sur laquelle un centre clinique doit agir, une invitation que quelqu'un attend, un webhook dont dépend un autre système.
Elle ajoute aussi un élément à surveiller. Pour une application de taille modeste, une table, une fonction de traitement et un processus de relève suffisent : il n'y a pas de courtier de messages à faire tourner.
Questions fréquentes
Faut-il Kafka ou RabbitMQ pour une outbox transactionnelle ?
Non. Dans les architectures microservices, l'outbox est souvent associée à un courtier de messages, mais l'outbox elle-même n'est qu'une table. Pour une application qui envoie des e-mails et appelle quelques API, un processeur qui lit directement la table suffit.
Faut-il utiliser SELECT ... FOR UPDATE SKIP LOCKED plutôt que des mises à jour conditionnelles ?
Les deux fonctionnent avec PostgreSQL. SKIP LOCKED est efficace pour prendre des lots en charge sous forte concurrence ; un UPDATE ... WHERE status = 'PENDING' conditionnel est plus simple avec un ORM et largement assez rapide aux volumes d'une messagerie de notifications. Dans les deux cas, conservez le bail et la vérification du verrou.
after() remplace-t-il le processus de relève ?
Non. after() rend l'envoi rapide, pas fiable. Si le processus s'arrête avant la fin du rappel, seul le processus de relève (ou un planificateur externe) reprendra l'événement.
Quel rapport avec FormRoute ?
Sur la plateforme de suivi d'échantillons, le processeur de l'outbox confie chaque e-mail à FormRoute, notre service d'envoi transactionnel. L'outbox garantit que le message est dû et relancé ; FormRoute se charge de l'envoyer.
L'outbox est l'un des modèles au cœur de la plateforme de suivi d'échantillons cliniques réalisée pour une société de biosciences. Si les e-mails, invitations ou webhooks de votre application se perdent sous la charge ou pendant les déploiements, parlons-en.