DEV Community

Cover image for The Transactional Outbox in Go: Reliable Events Without a Message Broker
Gabriel Anhaia
Gabriel Anhaia

Posted on

The Transactional Outbox in Go: Reliable Events Without a Message Broker


You ship an order service. The handler does two things: it
writes the order to Postgres, then it publishes an
OrderPlaced event so billing and email can react. The code
looks like this:

if err := repo.Save(ctx, order); err != nil {
    return err
}
return bus.Publish(ctx, "OrderPlaced", order.ID)
Enter fullscreen mode Exit fullscreen mode

Two network calls, no shared transaction. Now walk the failure
cases. The database commit succeeds and the publish call times
out: the order exists, nobody downstream ever hears about it.
Flip the order of the two lines and you get the mirror bug: you
publish first, then the commit fails, and now billing is
charging for an order that does not exist. This is the
dual-write problem, and no amount of retry logic fixes it,
because the two systems can fail independently between the two
calls.

The transactional outbox closes the gap. You write the event
into the same database, in the same transaction, as the state
change. Commit is atomic: either both the order and the event
land, or neither does. A separate Go process then reads
committed events and delivers them. You do not need Kafka,
RabbitMQ, or any broker to make this reliable. Postgres and a
polling loop are enough.

The outbox table

The event lives in a plain table next to your business data.
One row per event, with enough columns to deliver it and to
know whether it has been delivered.

CREATE TABLE outbox (
    id             bigserial PRIMARY KEY,
    aggregate_id   text        NOT NULL,
    event_type     text        NOT NULL,
    payload        jsonb       NOT NULL,
    created_at     timestamptz NOT NULL DEFAULT now(),
    published_at   timestamptz,
    attempts       int         NOT NULL DEFAULT 0
);

CREATE INDEX outbox_unpublished
    ON outbox (id) WHERE published_at IS NULL;
Enter fullscreen mode Exit fullscreen mode

The partial index matters. The relay only ever asks for rows
where published_at IS NULL, and once a table has millions of
delivered rows you do not want that scan reading all of them.
The index stays small because it only holds the backlog.

Writing the event with the state

Here is the part that removes the dual-write bug. The order
insert and the outbox insert share one *sql.Tx. If either
statement fails, the rollback takes both with it.

package order

import (
    "context"
    "database/sql"
    "encoding/json"
)

type Placed struct {
    OrderID    string `json:"order_id"`
    CustomerID string `json:"customer_id"`
    TotalCents int64  `json:"total_cents"`
}

func Place(
    ctx context.Context, db *sql.DB, o Order,
) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    const insertOrder = `
        INSERT INTO orders (id, customer_id, total_cents)
        VALUES ($1, $2, $3)`
    if _, err := tx.ExecContext(ctx, insertOrder,
        o.ID, o.CustomerID, o.TotalCents); err != nil {
        return err
    }

    payload, err := json.Marshal(Placed{
        OrderID:    o.ID,
        CustomerID: o.CustomerID,
        TotalCents: o.TotalCents,
    })
    if err != nil {
        return err
    }

    const insertEvent = `
        INSERT INTO outbox
            (aggregate_id, event_type, payload)
        VALUES ($1, $2, $3)`
    if _, err := tx.ExecContext(ctx, insertEvent,
        o.ID, "OrderPlaced", payload); err != nil {
        return err
    }

    return tx.Commit()
}
Enter fullscreen mode Exit fullscreen mode

defer tx.Rollback() after a successful Commit() is a no-op,
so this is the idiomatic Go shape: defer the rollback, return
early on any error, commit last. There is no window where the
order exists without its event, or the other way round.

The relay: a Go polling loop

The relay is a separate goroutine, or a separate process, that
does one job: pull unpublished events, deliver them, mark them
published. A time.Ticker drives it. context.Context shuts
it down cleanly.

package relay

import (
    "context"
    "database/sql"
    "log/slog"
    "time"
)

type Publisher interface {
    Publish(ctx context.Context, ev Event) error
}

type Event struct {
    ID        int64
    Type      string
    Aggregate string
    Payload   []byte
}

func Run(
    ctx context.Context,
    db *sql.DB,
    pub Publisher,
    every time.Duration,
) {
    t := time.NewTicker(every)
    defer t.Stop()
    for {
        select {
        case <-ctx.Done():
            return
        case <-t.C:
            if err := drain(ctx, db, pub); err != nil {
                slog.Error("relay drain", "err", err)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

drain reads a batch and delivers it row by row. The claim
query is the load-bearing line: FOR UPDATE SKIP LOCKED lets
you run several relay instances at once without any of them
fighting over the same rows.

func drain(
    ctx context.Context, db *sql.DB, pub Publisher,
) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    const claim = `
        SELECT id, event_type, aggregate_id, payload
        FROM outbox
        WHERE published_at IS NULL
        ORDER BY id
        LIMIT 100
        FOR UPDATE SKIP LOCKED`
    rows, err := tx.QueryContext(ctx, claim)
    if err != nil {
        return err
    }

    var batch []Event
    for rows.Next() {
        var e Event
        if err := rows.Scan(&e.ID, &e.Type,
            &e.Aggregate, &e.Payload); err != nil {
            rows.Close()
            return err
        }
        batch = append(batch, e)
    }
    rows.Close()
    if err := rows.Err(); err != nil {
        return err
    }

    for _, e := range batch {
        if err := pub.Publish(ctx, e); err != nil {
            // Delivery failed. Bump this row's
            // attempt count and commit what we
            // have, so earlier successes and the
            // counter both stick.
            const bump = `
                UPDATE outbox
                SET attempts = attempts + 1
                WHERE id = $1`
            if _, uerr := tx.ExecContext(ctx, bump,
                e.ID); uerr != nil {
                return uerr
            }
            slog.Warn("publish failed",
                "id", e.ID, "err", err)
            return tx.Commit()
        }
        const mark = `
            UPDATE outbox
            SET published_at = now()
            WHERE id = $1`
        if _, err := tx.ExecContext(ctx, mark,
            e.ID); err != nil {
            return err
        }
    }
    return tx.Commit()
}
Enter fullscreen mode Exit fullscreen mode

Order by id so events go out in the order they were written.
The LIMIT 100 caps how much a single tick holds locked, which
keeps the claim short and lets other relay workers pick up the
rest. If a delivery fails mid-batch, the loop bumps that row's
attempt count, commits the events already sent, and leaves the
rest for the next tick.

Where the "no broker" part lives

The Publisher is any interface you want. Without a message
broker, the natural target is an HTTP webhook, a direct call to
the downstream service, or a NOTIFY on another Postgres
channel. Here is a webhook publisher.

package relay

import (
    "bytes"
    "context"
    "fmt"
    "net/http"
)

type Webhook struct {
    URL    string
    Client *http.Client
}

func (w Webhook) Publish(
    ctx context.Context, ev Event,
) error {
    req, err := http.NewRequestWithContext(
        ctx, http.MethodPost, w.URL,
        bytes.NewReader(ev.Payload))
    if err != nil {
        return err
    }
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("X-Event-Type", ev.Type)
    req.Header.Set("X-Event-Id",
        fmt.Sprintf("%d", ev.ID))

    resp, err := w.Client.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    if resp.StatusCode >= 300 {
        return fmt.Errorf(
            "webhook %s: status %d",
            w.URL, resp.StatusCode)
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Swap this for a gRPC client, a Postgres NOTIFY, or a real
broker later. The outbox and the relay do not change. That is
the point of the split: the reliability guarantee lives in the
database, and the transport is a detail behind an interface.

At-least-once, and why the consumer must dedupe

Look again at the delivery loop. It publishes, then marks the
row. If the relay crashes after the webhook returns 200 but
before the UPDATE commits, the next tick reads that row again
and delivers it a second time. You cannot close this window
with more code, because the external call and the database
write are, once again, two systems that fail independently. The
outbox gives you at-least-once delivery, never exactly-once.

So the contract is: the relay may send any event more than
once, and the consumer must be idempotent. Send a stable event
id (the X-Event-Id header above is the outbox row id) and let
the receiver skip duplicates.

func (h *Handler) Receive(
    ctx context.Context, eventID int64, body []byte,
) error {
    const ins = `
        INSERT INTO processed_events (event_id)
        VALUES ($1)
        ON CONFLICT (event_id) DO NOTHING`
    res, err := h.db.ExecContext(ctx, ins, eventID)
    if err != nil {
        return err
    }
    n, _ := res.RowsAffected()
    if n == 0 {
        return nil // already handled, skip
    }
    return h.apply(ctx, body)
}
Enter fullscreen mode Exit fullscreen mode

The ON CONFLICT DO NOTHING on a unique event_id is the
whole dedupe. First delivery inserts the row and runs apply.
Every repeat inserts nothing, affects zero rows, and returns
early. The consumer is now safe against the duplicate the
outbox is allowed to send.

Retries and the poison event

attempts on the outbox row is not decoration. Each failed
delivery bumps that counter and commits, so it climbs by one
every tick a webhook keeps rejecting the event. The relay
delivers in id order and stops at the first failure, so a
poison event holds up everything queued behind it. That is the
price of ordered delivery, and the counter is what bounds it.
Add the counter to the claim so a row that has failed too many
times drops out of the hot path:

WHERE published_at IS NULL
  AND attempts < 20
Enter fullscreen mode Exit fullscreen mode

Once a row crosses the limit it stays unpublished but no longer
gets claimed, so the events queued behind it start flowing
again. A separate query lists the parked rows for a human, or a
dead-letter table holds them for replay.

What you actually built

Count the moving parts. One table. One transaction that writes
state and event together. One Go loop that polls, delivers, and
marks. One idempotent consumer. No broker to run, no ZooKeeper,
no partition rebalancing at 3 a.m. For a service that already
has Postgres, this is often all the eventing you need, and you
can put a broker behind the Publisher interface the day the
throughput asks for one.

The tradeoff is real and worth naming. Polling adds latency
equal to your tick interval, and a single Postgres is a
throughput ceiling. When either of those hurts, you graduate to
change-data-capture (Debezium reading the WAL) or a broker. The
outbox pattern survives that move intact, because the write
side never knew how the event got delivered.


If this was useful

The outbox lives at a boundary: the domain writes an event, and
something outside decides how it reaches the world. Keeping that
seam clean is what Hexagonal Architecture in Go is about — the
Publisher interface, the relay as an adapter, the domain that
stays ignorant of transport. The Complete Guide to Go
Programming
covers the runtime pieces this leans on: database/sql
transactions, context cancellation, tickers, and the goroutine
that runs the relay for the life of the process.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)