guides/outbox.md

# Outbox Pattern

The outbox pattern guarantees that a message is published if and only if a database transaction commits — preventing the "dual-write" race condition.

## The problem

```elixir
# WRONG — race condition
Repo.insert!(order)
PhoenixMicro.publish("orders.placed", %{id: order.id})  # could fail or be lost
```

## The solution

```elixir
# CORRECT — atomic
Repo.transaction(fn ->
  order = Repo.insert!(Order.changeset(%Order{}, params))
  :ok = PhoenixMicro.Outbox.enqueue("orders.placed", %{id: order.id})
end)
```

If the transaction commits, the message row is guaranteed to exist.
The Relay GenServer polls for unrelayed rows and publishes them.

## Setup

### 1. Generate the migration

```bash
mix phoenix_micro.gen.migration
mix ecto.migrate
```

This creates an `outbox_messages` table with columns:
`id`, `topic`, `payload`, `headers`, `attempt`, `relayed_at`, `failed_at`, `last_error`, `inserted_at`, `updated_at`

### 2. Configure

```elixir
config :phoenix_micro,
  outbox: [
    schema: MyApp.OutboxMessage,
    repo: MyApp.Repo,
    poll_interval_ms: 1_000,
    batch_size: 100,
    max_attempts: 5
  ]
```

### 3. Create the Ecto schema (optional — use the generated one)

```elixir
defmodule MyApp.OutboxMessage do
  use Ecto.Schema

  schema "outbox_messages" do
    field :topic,      :string
    field :payload,    :map
    field :headers,    :map,     default: %{}
    field :attempt,    :integer, default: 0
    field :relayed_at, :utc_datetime_usec
    field :failed_at,  :utc_datetime_usec
    field :last_error, :string
    timestamps()
  end
end
```

### 4. Use in transactions

```elixir
def place_order(params) do
  Repo.transaction(fn ->
    order = Repo.insert!(Order.changeset(%Order{}, params))

    :ok = PhoenixMicro.Outbox.enqueue(
      "orders.placed",
      %{id: order.id, user_id: order.user_id, total: order.total},
      headers: %{"x-source" => "web"}
    )

    order
  end)
end
```

## Relay behaviour

- Polls every `poll_interval_ms` for rows where `relayed_at IS NULL AND failed_at IS NULL`
- Publishes via the active transport
- On success: sets `relayed_at`
- On failure: increments `attempt`, sets `last_error`
- After `max_attempts`: sets `failed_at` (row is abandoned, emit telemetry alert)

## Monitoring

```elixir
# Rows stuck in the outbox (not yet relayed)
pending = Repo.aggregate(MyApp.OutboxMessage, :count, :id,
  where: [relayed_at: nil, failed_at: nil])

# Failed rows (exhausted retries)
failed = Repo.aggregate(MyApp.OutboxMessage, :count, :id,
  where: [failed_at: {:not, nil}])
```