Outboxer

A transactional outbox for Elixir and Ecto.

Write a message to the outbox in the same database transaction as your business data. Outboxer relays it to Kafka, SQS, Oban or your own sink, in order, retrying until it gets through.

Repo.transaction(fn ->
  order = Repo.insert!(changeset)
  {:ok, _} = Outboxer.publish(%OrderPlaced{order_id: order.id, total: order.total})
  order
end)

If the transaction commits, the message will be delivered. If it rolls back, the message never existed. That is the guarantee "save to the database, then publish to the broker" cannot give: a crash between the two steps either loses the message or publishes one for data that was never saved.

Features

Installation

def deps do
  [
    {:outboxer, "~> 0.1"},

    # the driver your Repo already uses
    {:postgrex, ">= 0.0.0"},        # or {:ecto_sqlite3, ">= 0.0.0"}

    # add only the relay you need
    {:brod, "~> 4.0"},               # Outboxer.Relay.Kafka
    {:ex_aws_sqs, "~> 3.4"},         # Outboxer.Relay.SQS
    {:oban, "~> 2.18"}               # Outboxer.Relay.Oban
  ]
end

A relay module exists only if its dependency is in your project when Outboxer is compiled. If you add one later, run mix deps.compile outboxer --force.

Requires Elixir 1.18 or later.

Quick start

1. Create the table.

defmodule MyApp.Repo.Migrations.AddOutboxer do
  use Ecto.Migration

  def up, do: Outboxer.Migration.up()
  def down, do: Outboxer.Migration.down()
end

2. Define a message.

defmodule MyApp.Events.OrderPlaced do
  use Outboxer.Message, outbox: :orders

  defstruct [:order_id, :total]

  # Optional. Messages with the same group key are delivered in order.
  @impl true
  def group_key(%__MODULE__{order_id: id}), do: "order:#{id}"
end

3. Add Outboxer to your supervision tree, after the repo.

children = [
  MyApp.Repo,
  {Outboxer,
   repo: MyApp.Repo,
   outboxes: [
     orders: [
       relay: {Outboxer.Relay.Kafka, client: :my_brod_client, topic: "orders"},
       partitions: 4
     ]
   ]}
]

4. Publish inside your transaction, with Outboxer.publish/2 as above or in an Ecto.Multi:

Ecto.Multi.new()
|> Ecto.Multi.insert(:order, changeset)
|> Outboxer.publish_multi(:event, fn %{order: order} ->
  %OrderPlaced{order_id: order.id, total: order.total}
end)
|> Repo.transaction()

Two common layouts

One table per outbox. Each outbox can have its own table; without table they share outboxer_messages. Create each with Outboxer.Migration.up(table: "order_outbox").

outboxes: [
  orders:   [table: "order_outbox",   relay: {Outboxer.Relay.Kafka, client: :my_brod_client, topic: "orders"}],
  invoices: [table: "invoice_outbox", relay: {Outboxer.Relay.SQS, queue_url: "..."}]
]

Publish in one pod, relay in another. Start the same outboxes everywhere and turn delivery off where it is not wanted. A relay: false node starts no processes and needs no broker settings or dependencies.

{Outboxer,
 repo: MyApp.Repo,
 relay: System.get_env("OUTBOXER_RELAY", "true") == "true",
 outboxes: [...]}

See Storage and Operations for the details.

Try it

A demo that needs nothing but this repository (SQLite, no broker):

mix deps.get
mix run examples/demo.exs

It publishes order events in transactions, rolls one back, and relays through a sink that fails one time in four, so you can see ordering and retries.

What it is good at, and what it is not

Good at

Not good at / not for

The guides go into each of these:

Performance

Measured with mix bench, 200-byte payloads, and a relay that only counts, so these are the outbox's own costs:

What Result
Publish, 1 message per transaction, 10 writers 5,500 txn/s
Publish, 100 messages per transaction 57,000 msg/s
Relay, 1 partition, batch_size: 100 32,000 msg/s
Relay, 4 partitions 100,000 msg/s
One poll with 10,000 pending rows 0.8 ms
One poll with 1,000,000 pending rows 0.9 ms

The run was on this setup:

Machine MacBook Pro, Apple M3 Max (14 cores), 96 GB RAM
OS macOS 26.6
Database PostgreSQL 17.11 in Docker 27.4 (Linux VM limited to 2 CPUs and 2 GB RAM), default settings
Runtime Elixir 1.20.4, Erlang/OTP 29

A poll costs the same at any backlog depth, and relay speed stays flat from the first message of a backlog to the last. Real throughput is set by your database and the destination, so run it on your own hardware:

docker compose up -d postgres
mix bench                    # Postgres
BENCH_REPO=sqlite mix bench  # SQLite

The benchmark is bench/outbox_bench.exs. The full results, including the slow spots, are in Operations.

Development

mix setup
docker compose up -d postgres      # Postgres for the test suite
mix test                           # SQLite + Postgres, relays against fake clients
mix test --exclude postgres        # SQLite only, no Docker
docker compose up -d               # + Kafka and MiniStack (SQS)
mix test.integration               # everything, against the real services
mix precommit                      # compile (warnings as errors), format, credo --strict, test

Building brod's dependencies needs cmake (brew install cmake).

License

MIT. See LICENSE.