Het Transactional Outbox-patroon in Go met PostgreSQL

Schrijf het gebeurtenis met de gegevens. Splits ze nooit.

Inhoud

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.

Transactioneel outbox-patroon – gebeurtenis en data samen geschreven

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 door events.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_type en aggregate_id beschrijven welke entiteit het gebeurtenisrecord betreft. Handig voor volgordegaranties en routing.
  • event_type is de gebeurtenisnaam die je consumenten verwachten.
  • payload JSONB bewaart de gebeurtenislichaam. Gebruik JSONB in plaats van TEXT zodat je het kunt queryen indien nodig.
  • attempts bijhoudt hoeveel keer de relay heeft geprobeerd deze rij te publiceren. Gebruikt voor retry-limieten en dead-letter-behandeling.
  • processed_at is NULL voor 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 maxAttempts in op 5-10, afhankelijk van hoe duur retries zijn.
  • Overweeg exponentiële backoff: neem een retry_after-kolop op en sla rijen over waar retry_after > NOW().
  • Alert op COUNT(*) FROM outbox_dead_letters die een drempel overschrijdt.
  • Bied een handmatige retry-pad: een admin-endpoint of script dat attempts = 0 en retry_after = NULL reset 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 > 1000 voor meer dan twee minuten: relay valt achter of is vastgelopen.
  • outbox_events_pending groeit 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 LOCKED gebruikt 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 attempts in plaats van de batch te laten afbreken

Idempotentie

Observability

  • outbox_events_pending gauge wordt gemonitord en gealert
  • Dead-letter-aantal wordt gealert
  • Relay batch-duur wordt bijgehouden
  • Gestructureerde logs bevatten event_id, event_type en aggregate_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.

Bronnen

Abonneren

Ontvang nieuwe berichten over systemen, infrastructuur en AI-engineering.