20 dec 2025·7 min leestijd

Het outbox-patroon in PostgreSQL voor betrouwbare API-integraties

Leer het outbox-patroon: sla events op in PostgreSQL en lever ze daarna aan externe API's met retries, volgorde en deduplicatie.

Het outbox-patroon in PostgreSQL voor betrouwbare API-integraties

Waarom integraties falen, zelfs als je app werkt

Het komt vaak voor dat een actie in je app als “geslaagd” verschijnt, terwijl de integratie erachter stilletjes faalt. Je database-schrijfbewerking is snel en betrouwbaar. Een oproep naar een derdepartij-API is dat niet. Dat creëert twee verschillende werelden: jouw systeem zegt dat de wijziging heeft plaatsgevonden, maar het externe systeem heeft er nooit van gehoord.

Een typisch voorbeeld: een klant plaatst een bestelling, je app slaat die op in PostgreSQL en probeert vervolgens een verzendprovider te informeren. Als de provider 20 seconden time-outt en je verzoek opgeeft, is de bestelling nog steeds echt, maar de zending wordt nooit aangemaakt.

Gebruikers ervaren dit als verwarrend, inconsistent gedrag. Missende events lijken op “er gebeurde niets”. Dubbele events voelen als “waarom ben ik twee keer gefactureerd?”. Supportteams hebben er ook moeite mee omdat het lastig is te bepalen of het probleem bij jouw app, het netwerk of de partner lag.

Retries helpen, maar alleen retries garanderen geen correctheid. Als je opnieuw probeert na een timeout, kun je hetzelfde event twee keer sturen omdat je niet weet of de partner de eerste aanvraag heeft ontvangen. Als je in de verkeerde volgorde retryt, kun je bijvoorbeeld eerst “Order shipped” sturen voordat “Order paid” is aangekomen.

Deze problemen komen meestal voort uit normale concurrentie: meerdere workers die parallel verwerken, meerdere app-servers die tegelijk schrijven, en “best effort” wachtrijen waarbij de timing verandert onder load. De faalmodi zijn voorspelbaar: API's gaan down of worden traag, netwerken verliezen requests, processen crashen op het verkeerde moment, en retries maken duplicaten zolang er niets afdwingt dat alles idempotent is.

Het outbox-patroon bestaat omdat deze fouten normaal zijn.

Wat het outbox-patroon in eenvoudige woorden is

Het outbox-patroon is rechttoe rechtaan: wanneer je app een belangrijke wijziging doet (zoals het aanmaken van een order), schrijft het ook een klein “te verzenden event”-record naar een databasetabel, in dezelfde transactie. Als de database-commit slaagt, weet je dat zowel de bedrijfsdata als het eventrecord samen bestaan.

Daarna leest een aparte worker de outbox-tabel en levert die events aan derdepartij-API's. Als een API traag is, down is of time-outs geeft, slaagt je hoofdgebruikersverzoek nog steeds omdat het niet wacht op de externe oproep.

Dit voorkomt de onhandige staten die ontstaan als je een API binnen de request-handler aanroept:

  • De order is opgeslagen, maar de API-call faalt.
  • De API-call slaagt, maar je app crasht voordat de order is opgeslagen.
  • De gebruiker probeert opnieuw en je stuurt hetzelfde twee keer.

Het outbox-patroon helpt vooral tegen verloren events, partiële fouten (database ok, externe API niet ok), per ongeluk dubbel verzenden en veiligere retries (je kunt later opnieuw proberen zonder te raden).

Het lost niet alles op. Als je payload onjuist is, je businessregels fout zijn of de externe API de data afwijst, heb je nog steeds validatie, goede foutafhandeling en een manier nodig om mislukte events te inspecteren en te corrigeren.

Het ontwerpen van een outbox-tabel in PostgreSQL

Een goede outbox-tabel is opzettelijk saai. Hij moet makkelijk zijn om naar te schrijven, makkelijk te lezen en moeilijk te misbruiken.

Hier is een praktisch baseline-schema dat je kunt aanpassen:

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
);

Een ID kiezen

Het gebruik van bigserial (of bigint) maakt ordening simpel en indexes snel. UUID's zijn geweldig voor uniekheid tussen systemen, maar ze sorteren niet op creatietijd, wat polling onvoorspelbaarder kan maken en indexes zwaarder.

Een veelvoorkomende compromis is: houd id als bigint voor ordening, en voeg een aparte event_uuid toe als je een stabiele identifier nodig hebt om tussen services te delen.

Indexen die ertoe doen

Je worker zal hetzelfde querypatroon de hele dag door gebruiken. De meeste systemen hebben nodig:

  • Een index zoals (status, available_at, id) om de volgende pending events in volgorde op te halen.
  • Een index op (locked_at) als je van plan bent om verlopen locks te laten verlopen.
  • Een index zoals (aggregate_id, id) als je soms per aggregate in volgorde levert.

Houd payloads stabiel

Houd payloads klein en voorspelbaar. Sla op wat de ontvanger echt nodig heeft, niet je hele rij. Voeg een expliciete versie toe (bijvoorbeeld in meta) zodat je velden veilig kunt evolueren.

Gebruik meta voor routering en debugcontext zoals tenant ID, correlation ID, trace ID en een dedup-key. Die extra context levert later veel op wanneer support wil weten “wat is er met deze order gebeurd?”.

Hoe je events veilig opslaat met je business-write

De belangrijkste regel is simpel: schrijf businessdata en het outbox-event in dezelfde databasetransactie. Als de transactie commit, bestaan beide. Als hij rollt back, bestaat geen van beide.

Voorbeeld: een klant plaatst een order. In één transactie insert je de orderrij, de orderitems en één outbox-rij zoals order.created. Als een stap faalt, wil je niet dat een “created”-event de wereld in ontsnapt.

Eén event of meerdere?

Begin met één event per businessactie wanneer je kunt. Dat is makkelijker te begrijpen en goedkoper om te verwerken. Splits in meerdere events alleen als verschillende consumenten echt verschillende timing of payloads nodig hebben (bijvoorbeeld order.created voor fulfillment en payment.requested voor billing). Veel events genereren voor één klik vergroot retries, ordering-problemen en duplicate-handling.

Welke payload moet je opslaan?

Je kiest meestal tussen:

  • Snapshot: sla sleutelvelden op zoals ze waren op het moment van de actie (order totaal, currency, customer ID). Dit voorkomt extra reads later en houdt het bericht stabiel.
  • Reference ID: sla alleen de order ID op en laat de worker later details laden. Dit houdt de outbox klein, maar voegt reads toe en kan veranderen als de order wordt aangepast.

Een praktisch middenweg is identifiers plus een kleine snapshot van kritieke waarden. Dat helpt ontvangers snel te handelen en helpt bij debugging.

Houd de transactiegrens strak. Roep geen derdepartij-API's aan binnen dezelfde transactie.

Events afleveren aan derdepartij-API's: de worker-loop

Bouw een veiliger integratiestroom
Bouw betrouwbare integraties met een PostgreSQL-outbox en houd gebruikersverzoeken snel.
Probeer AppMaster

Zodra events in je outbox staan, heb je een worker nodig die ze leest en de derdepartij-API aanroept. Dit is het deel dat het patroon verandert in een betrouwbare integratie.

Polling is meestal de eenvoudigste optie. LISTEN/NOTIFY kan de latency verlagen, maar het voegt bewegende delen toe en heeft nog steeds een fallback nodig wanneer notificaties gemist worden of de worker herstart. Voor de meeste teams is steady polling met een kleine batch makkelijker te runnen en te debuggen.

Rijen veilig claimen

De worker moet rijen claimen zodat twee workers nooit hetzelfde event tegelijk verwerken. In PostgreSQL is de gebruikelijke aanpak een batch selecteren met row locks en SKIP LOCKED, en ze dan als in-progress markeren.

Een praktisch status-flow is:

  • pending: klaar om te verzenden
  • processing: gelocked door een worker (gebruik locked_by en locked_at)
  • sent: succesvol afgeleverd
  • failed: gestopt na max pogingen (of apart gezet voor handmatige review)

Houd batches klein om je database te ontzien. Een batch van 10 tot 100 rijen, elke 1 tot 5 seconden, is een veelvoorkomend startpunt.

Wanneer een call slaagt, markeer de rij sent. Als hij faalt, verhoog attempts, zet available_at in de toekomst (backoff), maak de lock leeg en zet het terug naar pending.

Logging die helpt (zonder geheimen te lekken)

Goede logs maken fouten actiegericht. Log de outbox id, event type, bestemmingsnaam, pogingentelling, timing en HTTP-status of foutklasse. Vermijd request bodies, auth-headers en volledige responses. Als je correlatie nodig hebt, sla dan een veilige request-ID of een hash op in plaats van ruwe payloaddata.

Orderingregels die werken in echte systemen

Ship integraties die je kunt debuggen
Koppel betalingen, messaging en externe API's terwijl integratiefouten inspecteerbaar blijven.
Aan de slag

Veel teams beginnen met “stuur events in dezelfde volgorde als waarin we ze aangemaakt hebben.” Het probleem is dat “dezelfde volgorde” zelden globaal is. Als je één globale queue forceert, kan één trage klant of flaky API iedereen ophouden.

Een praktische regel is: behoud volgorde per groep, niet voor het hele systeem. Kies een groepeersleutel die overeenkomt met hoe de buitenwereld over je data denkt, zoals customer_id, account_id of een aggregate_id zoals order_id. Garandeer then ordering binnen elke groep terwijl je veel groepen parallel verwerkt.

Parallelle workers zonder order te breken

Draai meerdere workers, maar zorg dat twee workers niet dezelfde groep tegelijk verwerken. De gebruikelijke aanpak is altijd het vroegste onverzonden event voor een gegeven aggregate_id leveren en parallelisme toestaan over verschillende aggregates.

Houd de claimingregels simpel:

  • Lever alleen het vroegste pending event per groep.
  • Sta parallelisme toe over groepen, niet binnen een groep.
  • Claim één event, stuur het, update status en ga dan verder.

Wanneer één event de rest blokkeert

Vroeg of laat zal één “giftig” event urenlang falen (verkeerde payload, ingetrokken token, provider-storing). Als je strikt volgorde per groep afdwingt, moeten latere events in die groep wachten, maar andere groepen moeten doorgaan.

Een werkbare compromis is om retries per event te cappen. Daarna markeer je het failed en pauzeer je alleen die groep totdat iemand de oorzaak oplost. Zo houdt je één kapotte klant ervan anderen te vertragen.

Retries zonder het erger te maken

Retries zijn waar een goede outbox-opzet ofwel betrouwbaar ofwel lawaaiig wordt. Het doel is simpel: probeer opnieuw wanneer het waarschijnlijk werkt, en stop snel wanneer het dat niet zal doen.

Gebruik exponentiële backoff en een harde limiet. Bijvoorbeeld: 1 minuut, 2 minuten, 4 minuten, 8 minuten en stop dan (of blijf met een maximale delay zoals 15 minuten). Zet altijd een maximum aantal pogingen zodat één fout event het systeem niet voor altijd kan verstoppen.

Niet elke fout verdient een retry. Maak de regels duidelijk:

  • Retry: network timeouts, connection resets, DNS-hiccups en HTTP 429 of 5xx responses.
  • Niet retryen: HTTP 400 (bad request), 401/403 (auth problemen), 404 (verkeind endpoint) of validatiefouten die je vóór verzending kunt detecteren.

Sla retry-state op de outbox-rij op. Verhoog attempts, zet available_at voor de volgende poging en noteer een korte, veilige foutsamenvatting (statuscode, foutklasse, ingekorte boodschap). Sla geen volledige payloads of gevoelige data in foutvelden op.

Rate limits vragen speciale behandeling. Als je HTTP 429 krijgt, respecteer Retry-After als die bestaat. Anders backoff je agressiever om een retry-storm te vermijden.

Deduplicatie en idempotentie basics

Zet je workflow om in software
Genereer een backend, web-UI en mobiele apps vanuit één project met schone broncode.
Bouw app

Als je betrouwbare API-integraties bouwt, ga ervan uit dat hetzelfde event twee keer verzonden kan worden. Een worker kan crasht na de HTTP-call maar vóór het registreren van succes. Een timeout kan een succes verbergen. Een retry kan overlappen met een trage eerste poging. Het outbox-patroon vermindert gemiste events, maar voorkomt duplicaten niet vanzelf.

De veiligste aanpak is idempotentie: herhaalde leveringen geven hetzelfde resultaat als één levering. Wanneer je een derdepartij-API aanroept, voeg dan een idempotentiekey toe die stabiel blijft voor dat event en die bestemming. Veel API's ondersteunen een header; zo niet, zet de key in de request-body.

Een eenvoudige key is bestemming plus event ID. Voor een event met ID evt_123, gebruik altijd iets als destA:evt_123.

Aan jouw kant voorkom je dubbele sends door een outbound delivery-log bij te houden en een unieke regel af te dwingen zoals (destination, event_id). Zelfs als twee workers racen, kan slechts één de “we sturen dit” record aanmaken.

Webhooks dupliceren ook

Als je webhook-callbacks ontvangt (zoals “delivery confirmed” of “status updated”), behandel ze hetzelfde. Providers retryen en je ziet mogelijk hetzelfde payload meerdere keren. Sla verwerkte webhook-IDs op, of bereken een stabiele hash van het bericht-ID van de provider en wijs herhalingen af.

Hoe lang data bewaren

Bewaar outbox-rijen totdat je succes hebt geregistreerd (of een finale failure die je accepteert). Bewaar delivery-logs langer, want dat is je audittrail wanneer iemand vraagt “Hebben we dit verzonden?”.

Een veelgebruikte aanpak:

  • Outbox-rijen: delete of archiveer na succes plus een kleine safety window (dagen).
  • Delivery-logs: bewaar weken of maanden, afhankelijk van compliance en supportbehoeften.
  • Idempotentie-keys: bewaar ten minste zo lang als retries kunnen plaatsvinden (en langer voor webhook-duplicates).

Stappenplan: het outbox-patroon implementeren

Bepaal wat je publiceert. Houd events klein, gericht en makkelijk later opnieuw af te spelen. Een goede vuistregel is één businessfeit per event, met genoeg data zodat de ontvanger kan handelen.

Bouw de basis

Kies duidelijke event-namen (bijvoorbeeld order.created, order.paid) en versioneer je payloadschema (zoals v1, v2). Versionering laat je velden later toevoegen zonder oudere consumenten te breken.

Maak je PostgreSQL-outbox-tabel en voeg indexes toe voor de queries die je worker het meest gaat draaien, vooral (status, available_at, id).

Werk je write-flow bij zodat de business-wijziging en de outbox-insert in dezelfde database-transactie gebeuren. Dat is de kerngarantie.

Voeg delivery en controle toe

Een eenvoudig implementatieplan:

  • Definieer eventtypes en payload-versies die je langdurig wilt ondersteunen.
  • Maak de outbox-tabel en indexen.
  • Insert een outbox-rij naast de hoofddatawijziging.
  • Bouw een worker die rijen claimt, naar de derdepartij-API stuurt en vervolgens de status bijwerkt.
  • Voeg retry-scheduling met backoff toe en een failed-status wanneer attempts op zijn.

Voeg basis-metrics toe zodat je problemen vroeg ziet: lag (oudste onverzonden event), send rate en failure rate.

Een simpel voorbeeld: order-events naar externe services sturen

Maak een outbox in minuten
Modelleer je outbox-tabel in de Data Designer en verstuur events zonder checkout te blokkeren.
Begin met bouwen

Een klant plaatst een order in je app. Twee dingen moeten buiten je systeem gebeuren: de billing-provider moet de kaart innen en de verzendprovider moet een zending aanmaken.

Met het outbox-patroon roep je die API's niet aan binnen het checkout-verzoek. In plaats daarvan sla je de order en een outbox-event op in dezelfde PostgreSQL-transactie, zodat je nooit in de situatie komt van “order opgeslagen, maar geen notificatie” (of andersom).

Een typische outbox-rij voor een order-event bevat een aggregate_id (de order ID), een event_type zoals order.created en een JSONB-payload met totals, items en afleveradres.

Een worker pikt dan pending rijen op en roept de externe services aan (ofwel in een gedefinieerde volgorde of door aparte events te emitten zoals payment.requested en shipment.requested). Als één provider down is, registreert de worker de poging, plant de volgende poging door available_at naar de toekomst te zetten en gaat door. De order bestaat nog steeds en het event wordt later opnieuw geprobeerd zonder nieuwe checkouts te blokkeren.

Ordering is meestal “per order” of “per klant”. Zorg dat events met dezelfde aggregate_id één voor één worden verwerkt zodat order.paid nooit vóór order.created aankomt.

Deduplicatie voorkomt dat je twee keer factureert of twee zendingen aanmaakt. Stuur een idempotentiekey wanneer de derde partij dat ondersteunt en houd een destination delivery-record bij zodat een retry na een timeout geen tweede actie triggert.

Snelchecks voordat je live gaat

Voeg retries toe zonder duplicaten
Zet een sender-achtig worker op die veilig opnieuw probeert en bezorgstatus vastlegt.
Probeer het nu

Voordat je een integratie vertrouwt met geld, klantnotificaties of datasync, test de randgevallen: crashes, retries, duplicaten en meerdere workers.

Checks die de veelvoorkomende fouten vangen:

  • Bevestig dat de outbox-rij is aangemaakt in dezelfde transactie als de business-wijziging.
  • Verifieer dat de sender veilig is om in meerdere instanties te draaien. Twee workers mogen niet tegelijk hetzelfde event versturen.
  • Als ordering belangrijk is, definieer de regel in één zin en dwing die af met een stabiele sleutel.
  • Voor elke bestemming: bepaal hoe je duplicaten voorkomt en hoe je aantoont “we hebben het verzonden”.
  • Definieer de exit: na N pogingen zet je het event op failed, bewaar je de laatste foutsamenvatting en bied je een eenvoudige reprocess-actie.

Een realiteitscheck: Stripe kan een request accepteren maar je worker crasht voordat succes is opgeslagen. Zonder idempotentie kan een retry dubbel uitvoeren. Met idempotentie plus een opgeslagen delivery-record wordt de retry veilig.

Volgende stappen: dit uitrollen zonder je app te verstoren

Rollout is waar outbox-projecten meestal slagen of vastlopen. Begin klein zodat je echt gedrag ziet zonder je hele integratielaag op het spel te zetten.

Begin met één integratie en één eventtype. Bijvoorbeeld: stuur alleen order.created naar één vendor-API terwijl de rest ongewijzigd blijft. Dat geeft je een helder referentiepunt voor throughput, latency en falingspercentages.

Maak problemen vroeg zichtbaar. Voeg dashboards en alerts toe voor outbox-lag (hoeveel events wachten en hoe oud is de oudste) en failure rate (hoeveel zitten vast in retry).

Heb een veilig reprocess-plan voordat het eerste incident gebeurt. Bepaal wat “reprocess” betekent: dezelfde payload opnieuw proberen, de payload reconstrueren vanaf huidige data of het event naar handmatige review sturen. Documenteer welke gevallen veilig opnieuw kunnen worden verzonden en welke menselijke controle nodig hebben.

Als je dit bouwt met een no-code platform zoals AppMaster (appmaster.io), blijft dezelfde structuur gelden: schrijf je businessdata en een outbox-rij samen in PostgreSQL, en draai vervolgens een afzonderlijk backend-proces om te leveren, te retryen en events als sent of failed te markeren.

FAQ

Wanneer moet ik het outbox-patroon gebruiken in plaats van direct de API aan te roepen?

Gebruik het outbox-patroon wanneer een gebruikersactie je database bijwerkt en werk in een ander systeem moet triggeren. Het is vooral nuttig wanneer timeouts, wankele netwerken of externe storingen kunnen zorgen voor situaties als “in onze app opgeslagen, maar bij hen ontbreekt het”.

Waarom moet de outbox-insert in dezelfde transactie als de business-write zitten?

Het gelijktijdig schrijven van de businessrij en de outbox-rij in dezelfde database-transactie geeft één duidelijke garantie: of beide bestaan, of geen van beide. Dat voorkomt partiële fouten zoals “API-call geslaagd maar de order was niet opgeslagen” of “order opgeslagen maar de API-aanroep heeft nooit plaatsgevonden”.

Welke velden moet een outbox-tabel praktisch bevatten?

Een goed uitgangspunt is id, aggregate_id, event_type, payload, status, created_at, available_at, attempts, plus lock-velden zoals locked_at en locked_by. Dat houdt verzenden, retry-scheduling en veilige concurrency simpel zonder de tabel onnodig complex te maken.

Welke indexen zijn het belangrijkst voor een outbox-tabel in PostgreSQL?

Een veelgebruikte baseline is een index op (status, available_at, id) zodat workers snel het volgende verstuurbare batchje events op volgorde kunnen ophalen. Voeg andere indexes alleen toe als je echt op die velden zoekt; extra indexes vertragen inserts.

Moet mijn worker de outbox-tabel pollen of LISTEN/NOTIFY gebruiken?

Polling is de eenvoudigste en meest voorspelbare aanpak voor de meeste teams. Begin met kleine batches en een korte interval en tune op basis van load en lag; je kunt later optimalisaties toevoegen, maar een simpele loop is makkelijker te debuggen als er iets misgaat.

Hoe voorkom ik dat twee workers hetzelfde outbox-event versturen?

Claim rijen met row-level locks zodat twee workers niet hetzelfde event tegelijk kunnen verwerken, meestal met SKIP LOCKED. Markeer de rij dan als processing met een lock-timestamp en worker-ID, stuur het event en zet het daarna sent of geef het terug aan pending met een toekomstige available_at.

Wat is de veiligste retry-strategie voor outbox-leveringen?

Gebruik exponentiële backoff met een harde limiet op het aantal pogingen, en probeer alleen opnieuw bij fouten die waarschijnlijk tijdelijk zijn. Timeouts, netwerkerrors en HTTP 429/5xx zijn goede kandidaten voor retry; validatiefouten en de meeste 4xx-responses behandel je als definitief totdat data of configuratie is gefixt.

Garandeert het outbox-patroon exactly-once levering?

Veronderstel dat duplicaten nog steeds kunnen optreden, vooral als een worker crasht na de HTTP-call maar vóór het opslaan van succes. Gebruik een idempotentiekey die stabiel is per bestemming en per event, en houd een delivery-log bij (met een unieke constraint) zodat raceende workers niet twee sends kunnen aanmaken.

Hoe ga ik om met ordering zonder het hele systeem te vertragen?

Preserveer standaard de volgorde binnen een groep, niet globaal. Gebruik een groepeer-sleutel zoals aggregate_id (order ID) of customer_id, verwerk slechts één event tegelijk per groep en laat parallelisme toe over verschillende groepen zodat één trage klant niet iedereen blokkeert.

Wat moet ik doen met een ‘poison’ event dat steeds faalt?

Markeer het event als failed nadat het maximaal aantal pogingen is bereikt, bewaar een korte veilige foutsamenvatting en stop met het verwerken van latere events voor diezelfde groep totdat iemand de oorzaak oplost. Dit beperkt de impact en voorkomt eindeloze retry-ruis, terwijl andere groepen blijven doorwerken.

Gemakkelijk te starten
Maak iets geweldigs

Experimenteer met AppMaster met gratis abonnement.
Als je er klaar voor bent, kun je het juiste abonnement kiezen.

Aan de slag