Het Transactional Outbox-patroon in Go met PostgreSQL
Schrijf het gebeurtenis met de gegevens. Splits ze nooit.
Twee schrijfacties die samen zouden moeten slagen, zullen uiteindelijk afzonderlijk falen.
Uw orderservice slaat de bestelling op in de database en publiceert vervolgens een order.created-gebeurtenis naar een message broker.
Deze twee bewerkingen worden na elkaar uitgevoerd.
Tussen deze twee momenten kunnen dingen misgaan: de broker is uit de lucht, het netwerk loopt time-out, het proces wordt herstart of de container wordt verwijderd. De databaseschrijving is geslaagd. De publicatie niet. De downstream-service die op de hoogte moet zijn van de nieuwe bestelling, blijft dit onbekend. Niemand merkte het op tot een klant belde.
Dit is het dual-write-probleem, en het is een van de meest voorkomende oorzaken van stille dataverlies in gedistribueerde systemen. Het transactionele outbox-patroon is de standaardoplossing.

Het dual-write-probleem
Het faalpatroon is makkelijk te doorgronden zodra je het ziet:
BEGIN;
INSERT INTO orders ... -- slaagt
COMMIT;
PUBLISH order.created ... -- faalt, crasht of wordt nooit bereikt
De database en de message broker delen geen transactiegrens. Er is geen rollback die beide dekt. Elke service die save -> publish sequentieel uitvoert, heeft deze hiaat. Het patroon verschijnt in vele vormen:
db.Save(order)gevolgd doorevents.Publish(OrderCreated{...})- HTTP-handler die een transactie commit en vervolgens een externe webhook aanroept
- Worker die een record uit één wachtrij verwerkt en resultaten naar een andere schrijft
Het resultaat is in alle gevallen hetzelfde: de ene kant slaagt terwijl de andere faalt, en het systeem belandt in een staat die onzichtbaar is voor monitoring omdat beide individuele bewerkingen op een gegeven moment succes hebben geretourneerd.
Een retry-loop lost dit niet op. Het opnieuw proberen van de publicatie na de database-commit werkt alleen als de retry zelf betrouwbaar is – wat precies de duurzaamheidsgarantie vereist die je niet hebt.
Wat het transactionele outbox-patroon doet
Het outbox-patroon elimineert de hiaat door de directe publicatie volledig te verwijderen. In plaats van de broker binnen je bedrijfslogica aan te roepen, schrijf je een gebeurtenisrecord in een outbox-tabel in dezelfde database-transactie als de bedrijfsdata. Een apart achtergrondproces – de relay – leest uit de outbox-tabel en publiceert naar de broker.
BEGIN;
INSERT INTO orders ... -- bedrijfsdata
INSERT INTO outbox_events ... -- gebeurtenisrecord
COMMIT;
-- Relay-proces (apart):
SELECT ... FROM outbox_events FOR UPDATE SKIP LOCKED;
PUBLISH order.created ...
UPDATE outbox_events SET processed_at = NOW() WHERE id = $1;
Beide schrijfacties slagen of falen samen. De transactiegarantie die je al hebt van PostgreSQL dekt nu ook het gebeurtenisrecord. De relay kan het publiceren zo vaak opnieuw proberen als nodig is, omdat het gebeurtenisrecord in duurzame opslag zit. Als de relay mid-flight crasht, start deze opnieuw en probeert het opnieuw. Het ergste resultaat is dat het gebeurtenisrecord meer dan één keer wordt gepubliceerd – wat wordt afgehandeld door consumenten idempotent te maken (zie Idempotentie in Gedistribueerde Systemen).
PostgreSQL-schema voor de outbox-tabel
Het schema is opzettelijk eenvoudig:
CREATE TABLE outbox_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aggregate_type VARCHAR(100) NOT NULL,
aggregate_id VARCHAR(100) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
attempts INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ
);
-- Partiële index: indexeert alleen onbewerkte rijen, blijft klein naarmate rijen als afgerond worden gemarkeerd
CREATE INDEX idx_outbox_unprocessed
ON outbox_events (created_at)
WHERE processed_at IS NULL;
De partiële index op created_at WHERE processed_at IS NULL is belangrijk. Zonder deze index groeit de index met elk geschreven gebeurtenisrecord en wordt de polling-query van de relay met de tijd langzamer. Met deze index dekt de index alleen de uitstaande rijen, wat in een stabiele staat een kleine, begrensde set is, ongeacht hoeveel gebeurtenissen zijn gepubliceerd.
Belangrijke veldkeuzes:
aggregate_typeenaggregate_idbeschrijven welke entiteit het gebeurtenisrecord betreft. Handig voor volgordegaranties en routing.event_typeis de gebeurtenisnaam die je consumenten verwachten.payload JSONBbewaart de gebeurtenislichaam. GebruikJSONBin plaats vanTEXTzodat je het kunt queryen indien nodig.attemptsbijhoudt hoeveel keer de relay heeft geprobeerd deze rij te publiceren. Gebruikt voor retry-limieten en dead-letter-behandeling.processed_atisNULLvoor uitstaande rijen en wordt ingesteld wanneer de relay succesvol publiceert.
Bedrijfsdata en outbox-gebeurtenis in één transactie schrijven
De bedrijfslogica schrijft beide records binnen een enkele BeginTx / Commit-aanroep. Hier is geen publish-aanroep – alleen databaseschrijfacties.
type OrderService struct {
db *sql.DB
}
func (s *OrderService) CreateOrder(ctx context.Context, order Order) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `
INSERT INTO orders (id, customer_id, total, created_at)
VALUES ($1, $2, $3, NOW())
`, order.ID, order.CustomerID, order.Total); err != nil {
return fmt.Errorf("insert order: %w", err)
}
payload, err := json.Marshal(map[string]any{
"order_id": order.ID,
"customer_id": order.CustomerID,
"total": order.Total,
})
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO outbox_events
(aggregate_type, aggregate_id, event_type, payload)
VALUES ($1, $2, $3, $4)
`, "order", order.ID, "order.created", payload); err != nil {
return fmt.Errorf("insert outbox event: %w", err)
}
return tx.Commit()
}
Als tx.Commit() faalt, wordt noch de order-rij noch de outbox-rij gepersisteerd. Als het slaagt, is gegarandeerd dat beide in de database zitten. De relay kan het gebeurtenisrecord op elk moment daarna publiceren – onmiddellijk, over een seconde, of na een restart van de relay na een crash.
Dit is de enige codeverandering die nodig is in je bedrijfslaag. De rest van het patroon leeft in de relay.
Go relay-implementatie
De relay is een achtergrondworker die de outbox-tabel op een timer pollt. Deze haalt een batch van onbewerkte rijen op, publiceert elk ervan en markeert het als afgerond. Houd het in hetzelfde binary als je applicatie of voer het uit als een apart proces – beide werken, maar hetzelfde binary is eenvoudiger te beheren.
type OutboxRelay struct {
db *sql.DB
publisher Publisher
logger *slog.Logger
batchSize int
pollInterval time.Duration
maxAttempts int
}
func (r *OutboxRelay) Run(ctx context.Context) error {
ticker := time.NewTicker(r.pollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if err := r.processBatch(ctx); err != nil {
r.logger.Error("outbox relay batch failed", "err", err)
}
}
}
}
De relay respecteert context-annulering, wat het eenvoudig maakt om te integreren met graceful shutdown. Voor een gedetailleerde behandeling van context-leven en annuleringspatronen, zie Go context.Context Done Right.
FOR UPDATE SKIP LOCKED: het concurrente worker-patroon
De processBatch-functie gebruikt FOR UPDATE SKIP LOCKED om concurrente relay-workers veilig af te handelen:
func (r *OutboxRelay) processBatch(ctx context.Context) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
rows, err := tx.QueryContext(ctx, `
SELECT id, aggregate_type, aggregate_id, event_type, payload
FROM outbox_events
WHERE processed_at IS NULL
AND attempts < $1
ORDER BY created_at
LIMIT $2
FOR UPDATE SKIP LOCKED
`, r.maxAttempts, r.batchSize)
if err != nil {
return fmt.Errorf("query outbox: %w", err)
}
defer rows.Close()
type row struct {
id string
aggregateType string
aggregateID string
eventType string
payload json.RawMessage
}
var batch []row
for rows.Next() {
var e row
if err := rows.Scan(
&e.id, &e.aggregateType, &e.aggregateID, &e.eventType, &e.payload,
); err != nil {
return fmt.Errorf("scan row: %w", err)
}
batch = append(batch, e)
}
if err := rows.Err(); err != nil {
return err
}
for _, e := range batch {
if err := r.publisher.Publish(ctx, e.eventType, e.aggregateID, e.payload); err != nil {
r.logger.Error("publish failed", "event_id", e.id, "err", err)
if _, err := tx.ExecContext(ctx,
`UPDATE outbox_events SET attempts = attempts + 1 WHERE id = $1`, e.id,
); err != nil {
r.logger.Error("increment attempts failed", "event_id", e.id, "err", err)
}
continue
}
if _, err := tx.ExecContext(ctx,
`UPDATE outbox_events SET processed_at = NOW() WHERE id = $1`, e.id,
); err != nil {
return fmt.Errorf("mark processed: %w", err)
}
}
return tx.Commit()
}
FOR UPDATE SKIP LOCKED doet twee dingen. Ten eerste, FOR UPDATE vergrendelt de geselecteerde rijen voor de duur van de transactie, waardoor geen andere transactie ze kan selecteren. Ten tweede, SKIP LOCKED betekent dat als een rij al is vergrendeld door een andere transactie, de query deze overslaat in plaats van te wachten. Het resultaat is dat meerdere relay-workers parallel kunnen draaien en elk een niet-overlappend subset van rijen oppakt.
Zonder SKIP LOCKED zou een tweede worker blokkeren tot de eerste transactie committeert voordat hij dezelfde rijen ziet – op dat moment zouden ze al als afgerond gemarkeerd zijn. Met SKIP LOCKED pakt de tweede worker onmiddellijk andere rijen op in plaats van te wachten, wat je veilige horizontale schaling geeft.
Let op de scheiding tussen scannen en publiceren in de bovenstaande code: alle rijen worden gescand in een slice voordat de publish-loop start. Dit voorkomt dat een open *sql.Rows-cursor wordt vastgehouden tijdens netwerkoproepen naar de broker, wat de transactie langer open zou houden dan nodig.
Idempotentie en deduplicatie
De relay publiceert minstens één keer. Als het een gebeurtenis publiceert en vervolgens crasht voordat het processed_at-update committeert, zal het dezelfde gebeurtenis opnieuw publiceren bij het herstarten. Dit is onvermijdelijk – exactly-once levering over een database en een message broker zonder een gedistribueerde transactiecoördinator vereist deze afweging.
Consumenten moeten idempotent zijn. De eenvoudigste aanpak is om verwerkte gebeurtenis-ID’s bij te houden in een processed_events-tabel:
CREATE TABLE processed_events (
event_id UUID PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
func (h *OrderHandler) HandleOrderCreated(ctx context.Context, eventID string, payload []byte) error {
// Dedupliceren met gebruik van de event-ID als natuurlijke sleutel
_, err := h.db.ExecContext(ctx, `
INSERT INTO processed_events (event_id) VALUES ($1)
ON CONFLICT (event_id) DO NOTHING
`, eventID)
if err != nil {
return fmt.Errorf("dedup check: %w", err)
}
// Controleer of de insert daadwerkelijk plaatsvond (1 rij) of een no-op was (0 rijen)
// Een eenvoudigere aanpak: gebruik RETURNING of controleer aangevaste rijen
// Als 0 rijen aangevasts, is dit een duplicaat -- sla het over
...
}
In de praktijk vertrouwen veel teams op de eigen deduplicatie-headers van de broker (zoals het key-veld van Kafka voor log-gecomprimeerde topics, of de message-id-header van RabbitMQ) en beschouwen ze database-niveau deduplicatie als een fallback. Beide zijn geldige lagen om toe te passen.
Neem de outbox-gebeurtenis id (een UUID) op in het gepubliceerde bericht als deduplicatiesleutel. Consumenten kunnen deze dan gebruiken, ongeacht welk deduplicatiemechanisme ze voorkeur geven.
Retry-beleid en poison messages
De attempts-kolom drijft het retry-beleid. De relay slaat rijen over waar attempts >= maxAttempts en behandelt die rijen als dead letters. Een apart proces of operator-alert handelt ze af.
Een eenvoudige dead-letter-weergave:
CREATE VIEW outbox_dead_letters AS
SELECT *
FROM outbox_events
WHERE attempts >= 5
AND processed_at IS NULL
ORDER BY created_at;
Een goed productie retry-beleid:
- Stel
maxAttemptsin op 5-10, afhankelijk van hoe duur retries zijn. - Overweeg exponentiële backoff: neem een
retry_after-kolop op en sla rijen over waarretry_after > NOW(). - Alert op
COUNT(*) FROM outbox_dead_lettersdie een drempel overschrijdt. - Bied een handmatige retry-pad: een admin-endpoint of script dat
attempts = 0enretry_after = NULLreset voor specifieke rijen.
Poison messages – rijen die consistent falen door een bug in de consument of een schema-mismatch – mogen geen gezonde berichten blokkeren. Omdat de relay een batch per tick verwerkt en faalangsten markeert met een pogingsincrement in plaats van ze uit de wachtrij te verwijderen, gaan gezonde rijen normaal door terwijl vergiftigde rijen pogingen accumuleren tot ze de dead-letter-drempel bereiken.
Gebeurtenisvolgorde en partitie
De polling-query sorteert op created_at, wat first-in-first-out-volgorde binnen een batch geeft. Voor de meeste use cases is dat genoeg. Wanneer strikte per-entiteit volgorde belangrijk is – bijvoorbeeld, ervoor te zorgen dat order.updated nooit wordt gepubliceerd voordat order.created voor dezelfde bestelling – heb je per-aggregate volgorde nodig.
Voeg aggregate_id toe aan de ORDER BY-clause en gebruik het als message-key bij publiceren naar een gepartitioneerde topic zoals Apache Kafka. Kafka routeert alle berichten met dezelfde key naar dezelfde partitie, en partities worden in volgorde geconsumeerd. Dit geeft je per-aggregate volgordegaranties zonder globale volgorde, wat één enkele relay-instance zou vereisen.
ORDER BY aggregate_id, created_at
Voor brokers die geen gepartitioneerde volgorde ondersteunen (zoals basis AMQP-wachtrijen), zijn single-instance relay of applicatie-niveau volgordecontroles in de consument de praktische alternatieven.
Polling-latentie verminderen met LISTEN/NOTIFY
Een polling-interval van één seconde betekent een gemiddelde gebeurtenislatentie van 500 milliseconden. Voor de meeste workloads is dat prima. Voor gevallen waarbij je bijna-zero latency nodig hebt, laat het LISTEN/NOTIFY-mechanisme van PostgreSQL de relay onmiddellijk wakker wanneer een nieuwe outbox-rij wordt ingevoegd.
Voeg een trigger toe aan de outbox-tabel:
CREATE OR REPLACE FUNCTION notify_outbox_insert() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify('outbox_event', NEW.id::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER outbox_insert_notify
AFTER INSERT ON outbox_events
FOR EACH ROW EXECUTE FUNCTION notify_outbox_insert();
Luister in de relay op het kanaal en word wakker op notifications, terwijl je terugvalt op periodieke polling:
func (r *OutboxRelay) Run(ctx context.Context) error {
listener := pq.NewListener(r.dsn, 10*time.Second, time.Minute, nil)
defer listener.Close()
if err := listener.Listen("outbox_event"); err != nil {
return fmt.Errorf("listen: %w", err)
}
ticker := time.NewTicker(5 * time.Second) // fallback poll
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-listener.Notify:
if err := r.processBatch(ctx); err != nil {
r.logger.Error("outbox batch failed (notify)", "err", err)
}
case <-ticker.C:
if err := r.processBatch(ctx); err != nil {
r.logger.Error("outbox batch failed (poll)", "err", err)
}
}
}
}
De fallback-ticker handelt alle notifications af die gemist zijn tijdens een relay-restart of netwerkstoring. Houd het fallback-interval op een paar seconden in plaats van milliseconden – zijn taak is herstel, niet lage latentie.
Observability: metrics, logs en alerts
De outbox is infrastructuur. Behandel het als infrastructuur en instrumenteer het dienovereenkomstig.
Belangrijkste metrics:
var (
outboxPublished = prometheus.NewCounter(prometheus.CounterOpts{
Name: "outbox_events_published_total",
Help: "Totaal aantal succesvol gepubliceerde outbox-gebeurtenissen.",
})
outboxFailed = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "outbox_events_failed_total",
Help: "Totaal aantal outbox-publicatiefouten per gebeurtenistype.",
}, []string{"event_type"})
outboxPending = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "outbox_events_pending",
Help: "Huidig aantal onbewerkte outbox-gebeurtenissen.",
})
outboxBatchDuration = prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "outbox_batch_duration_seconds",
Help: "Duur van elke outbox-verwerkingsbatch.",
Buckets: prometheus.DefBuckets,
})
)
Gauge refresh: voer een periodieke query uit om outbox_events_pending nauwkeurig te houden:
SELECT COUNT(*) FROM outbox_events WHERE processed_at IS NULL;
Te overwegen alert-drempels:
outbox_events_pending > 1000voor meer dan twee minuten: relay valt achter of is vastgelopen.outbox_events_pendinggroeit monotoon: broker is uit de lucht of relay is gecrasht.- Dead-letter-aantal is niet-nul: schema- of consumentbug heeft onderzoek nodig.
outbox_batch_duration_seconds p95 > 5s: database is traag of batch-grootte is te groot.
Gestructureerde logvelden: neem event_id, event_type, aggregate_id en attempt op in elke logregel van de relay. Deze velden laten je een mislukte publicatie correleren met de specifieke outbox-rij en de downstream-consument trace.
Outbox vs. directe queue vs. saga
Het outbox-patroon is niet het juiste gereedschap voor elk coördinatieprobleem. Hier is de vergelijking:
| Aanpak | Atomiciteit | Complexiteit | Wanneer te gebruiken |
|---|---|---|---|
| Directe publicatie | Geen | Laag | Acceptabel om af en toe gebeurtenissen te verliezen |
| Transactionele outbox | Sterk | Medium | Betrouwbare gebeurtenislevering vanuit één service |
| Saga-patroon | Eventueel | Hoog | Multi-service transacties die meerdere databases overspannen |
| Two-phase commit | Sterk | Zeer hoog | Zelden praktisch; vermeden in de meeste gedistribueerde systemen |
Het outbox-patroon garandeert dat één service betrouwbaar gebeurtenissen uitzendt die zijn eigen staatswijzigingen weerspiegelen. Het coördineert geen staatswijzigingen over meerdere services – dat is waar het Saga-patroon voor is. De keuze van broker – of het nu RabbitMQ, SQS, of Kafka – is onafhankelijk van het outbox-patroon zelf; de relay publiceert naar welke broker je systeem ook gebruikt.
Als je een saga bouwt, is het outbox-patroon nog steeds nuttig: elke deelnemer in de saga schrijft zijn lokale staatswijziging en zijn saga-gebeurtenis in één transactie met behulp van de outbox, en de saga-orkestrator of choreografie leest die gebeurtenissen betrouwbaar.
WAL-gebaseerde CDC als alternatieve relay
In plaats van te pollen, kun je de Write-Ahead Log (WAL) van PostgreSQL tailen en outbox-inserts direct lezen uit de replicatiestroom. Tools zoals Debezium doen dit. De voordelen zijn lagere latentie en geen lock-druk op de outbox-tabel. De nadelen zijn operationele complexiteit, een dedicated PostgreSQL-replicatieslot, en een externe service om te draaien en te monitoren.
Voor de meeste teams is de hierboven beschreven polling-relay het juiste startpunt. WAL-tailing heeft zin wanneer je hoge outbox-inserttarieven hebt (tientallen duizenden per seconde), sub-100ms gebeurtenislatentie nodig hebt, of al Debezium draait voor andere change-capture-behoeften.
sqlc-integratie
Als je sqlc gebruikt voor type-safe Go database-code, passen de outbox-queries natuurlijk:
-- name: InsertOutboxEvent :exec
INSERT INTO outbox_events (aggregate_type, aggregate_id, event_type, payload)
VALUES (@aggregate_type, @aggregate_id, @event_type, @payload);
-- name: FetchOutboxBatch :many
SELECT id, aggregate_type, aggregate_id, event_type, payload
FROM outbox_events
WHERE processed_at IS NULL
AND attempts < @max_attempts
ORDER BY created_at
LIMIT @batch_size
FOR UPDATE SKIP LOCKED;
-- name: MarkOutboxProcessed :exec
UPDATE outbox_events SET processed_at = NOW() WHERE id = @id;
-- name: IncrementOutboxAttempts :exec
UPDATE outbox_events SET attempts = attempts + 1 WHERE id = @id;
-- name: OutboxPendingCount :one
SELECT COUNT(*) FROM outbox_events WHERE processed_at IS NULL;
sqlc genereert type-safe functies voor elke query, wat string-interpolatiefouten voorkomt en de outbox-query-logica co-locaat houdt met de rest van je database-toegangslaag.
Productie checklist
Gebruik dit voordat je een outbox-implementatie uitrolt:
Database
- Outbox-tabel heeft de partiële index op
created_at WHERE processed_at IS NULL -
attempts-kolom aanwezig met een default van 0 - Dead-letter-weergave of query gedefinieerd
- Oude verwerkte rijen worden periodiek gearchiveerd of verwijderd (een nachtelijke cleanup-job volstaat)
Relay
-
FOR UPDATE SKIP LOCKEDgebruikt in de polling-query - Relay draait binnen een transactie (begin voor query, commit na alle updates)
- Batch-grootte is begrensd (50-200 rijen is typisch)
- Relay respecteert context-annulering voor graceful shutdown
- Mislukte publicaties incrementeren
attemptsin plaats van de batch te laten afbreken
Idempotentie
- Gepubliceerd bericht bevat de outbox
idals deduplicatiesleutel - Consumenten zijn idempotent of de broker biedt deduplicatie
- Zie Idempotentie in Gedistribueerde Systemen voor deduplicatiepatronen
Observability
-
outbox_events_pendinggauge wordt gemonitord en gealert - Dead-letter-aantal wordt gealert
- Relay batch-duur wordt bijgehouden
- Gestructureerde logs bevatten
event_id,event_typeenaggregate_id
Operaties
- Handmatige retry-pad bestaat voor dead-letter-rijen
- Relay-restartgedrag is getest (publiceert het correct opnieuw?)
- Broker-uitvalgedrag is getest (groeit de outbox en draineert deze correct?)
Eindgedachten
Het dual-write-probleem is makkelijk af te doen als een edge case tot het een incident veroorzaakt. Het transactionele outbox-patroon lost het op met gereedschap dat je al hebt: een PostgreSQL-transactie, een achtergrondgoroutine, en één extra tabel. De relay is eenvoudig te bouwen, eenvoudig te beheren, en eenvoudig te doorgronden.
De kosten zijn dat consumenten moeten worden ontworpen voor at-least-once levering. Dat is een redelijke afweging. Exactly-once levering over een database en een broker zonder gedistribueerde transacties is in de praktijk niet haalbaar – en het doen alsof leidt tot systemen die onder faalcondities stil gebeurtenissen laten vallen of dubbel verwerken.
Schrijf het gebeurtenisrecord met de data. Relay het betrouwbaar. Maak consumenten idempotent. Dat is het hele patroon.
Dit artikel is onderdeel van de App Architecture in Production cluster.