Outbox Pattern

architecture

Outbox Pattern

Definition and Overview

The Outbox Pattern is a distributed systems design pattern that ensures reliable event publishing by writing events to a dedicated outbox table within the same database transaction as the business data change. A separate process -- the outbox publisher -- polls this table and forwards events to downstream consumers, guaranteeing at-least-once delivery without requiring distributed transactions. The pattern solves the fundamental dual-write problem: the impossibility of atomically updating a database and publishing a message to a broker, where either operation can independently fail, leading to state inconsistency between systems.

The pattern was formalized in the context of microservice architectures where services need to communicate state changes reliably without coupling themselves to message broker availability. By treating the database as the single source of truth for both business state and pending events, the outbox pattern converts the distributed consistency problem into a local transaction, which databases solve with well-understood ACID guarantees. This conversion is the key insight: instead of solving a distributed coordination problem (which is provably difficult per the FLP impossibility result), the pattern reduces it to a local coordination problem (which databases solve routinely).

The Outbox Pattern occupies a specific position in the reliability-complexity spectrum of distributed event publishing. It is more reliable than direct publish (which suffers from dual-write failures), simpler than two-phase commit (which requires a distributed coordinator), less infrastructure-heavy than Change Data Capture (which requires tools like Debezium), and more controllable than event sourcing (which requires replaying entire event streams). For systems built on PostgreSQL with Ecto, the pattern integrates naturally through Ecto.Multi transactions.

Historical Context and Motivation

The dual-write problem has been a persistent challenge in distributed computing since the emergence of service-oriented architectures in the early 2000s. As monolithic applications were decomposed into services, the need for reliable inter-service communication became critical. The naive approach -- update the database, then publish an event -- fails in predictable ways: the publish can fail after the commit (losing the event), or the commit can fail after the publish (creating a phantom event).

Two-Phase Commit (2PC) protocols were the initial theoretical solution, but their practical deployment revealed fundamental limitations. 2PC is a blocking protocol: if the coordinator fails during the prepare phase, all participants are locked until the coordinator recovers. This makes 2PC unsuitable for high-availability systems where any component can fail independently. The CAP theorem formalized this limitation, demonstrating that distributed systems must choose between consistency and availability during network partitions.

The Outbox Pattern emerged as a pragmatic alternative that trades exact consistency for eventual consistency with at-least-once delivery -- a tradeoff that most distributed systems practitioners consider highly favorable. The pattern was popularized by Vaughn Vernon in "Implementing Domain-Driven Design" (2013) and has since become a standard technique in event-driven architectures.

Technical Deep Dive

Outbox Table Schema

The outbox table stores events with sufficient metadata for reliable delivery, idempotent processing, and operational debugging:

CREATE TABLE outbox_events (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  aggregate_type VARCHAR(255) NOT NULL,
  aggregate_id   VARCHAR(255) NOT NULL,
  event_type    VARCHAR(255) NOT NULL,
  payload       JSONB NOT NULL,
  metadata      JSONB DEFAULT '{}',
  created_at    TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
  published_at  TIMESTAMP WITH TIME ZONE,
  retry_count   INTEGER DEFAULT 0,
  max_retries   INTEGER DEFAULT 5,
  status        VARCHAR(20) DEFAULT 'pending',
  schema_version VARCHAR(10) DEFAULT '1.0'
);

-- Partial index for efficient pending event polling
CREATE INDEX idx_outbox_pending ON outbox_events (status, created_at)
  WHERE status = 'pending';

-- Index for aggregate-based event lookup
CREATE INDEX idx_outbox_aggregate ON outbox_events (aggregate_type, aggregate_id);

-- Index for dead-letter analysis
CREATE INDEX idx_outbox_failed ON outbox_events (status, retry_count)
  WHERE status = 'failed';

The schema design reflects several important considerations. The id field uses UUID to enable generation without coordination. The aggregate_type and aggregate_id fields support per-aggregate ordering and querying. The metadata JSONB field accommodates correlation IDs, causation IDs, and schema version information without requiring schema migrations. The partial index on status = 'pending' ensures that polling queries remain efficient even as the table grows with published events.

Transactional Write with Ecto.Multi

The business operation and event creation execute within a single database transaction using Ecto's Multi abstraction:

defmodule PrismaticPerimeter.AssetDiscovery do
  @moduledoc """
  Asset discovery service that records discovered assets and
  publishes discovery events through the transactional outbox pattern.
  Both the asset record and the outbox event are written atomically.
  """

  alias Ecto.Multi
  alias PrismaticStorage.Repo
  alias PrismaticPerimeter.{Asset, OutboxEvent}

  @spec record_discovery(map()) :: {:ok, map()} | {:error, term()}
  def record_discovery(asset_params) do
    Multi.new()
    |> Multi.insert(:asset, Asset.changeset(%Asset{}, asset_params))
    |> Multi.insert(:outbox_event, fn %{asset: asset} ->
      OutboxEvent.changeset(%OutboxEvent{}, %{
        aggregate_type: "asset",
        aggregate_id: asset.id,
        event_type: "asset.discovered",
        payload: %{
          domain: asset.domain,
          ip_addresses: asset.ip_addresses,
          discovery_source: asset.discovery_source,
          discovered_at: DateTime.utc_now()
        },
        metadata: %{
          correlation_id: Ecto.UUID.generate(),
          causation_id: nil,
          schema_version: "1.0"
        }
      })
    end)
    |> Repo.transaction()
  end

  @spec record_update(Asset.t(), map()) :: {:ok, map()} | {:error, term()}
  def record_update(asset, changes) do
    Multi.new()
    |> Multi.update(:asset, Asset.changeset(asset, changes))
    |> Multi.insert(:outbox_event, fn %{asset: updated_asset} ->
      OutboxEvent.changeset(%OutboxEvent{}, %{
        aggregate_type: "asset",
        aggregate_id: updated_asset.id,
        event_type: "asset.updated",
        payload: %{
          domain: updated_asset.domain,
          changes: changes,
          updated_at: DateTime.utc_now()
        },
        metadata: %{
          correlation_id: Ecto.UUID.generate(),
          schema_version: "1.0"
        }
      })
    end)
    |> Repo.transaction()
  end
end

The Multi abstraction is critical: it ensures that the asset record and the outbox event are written in a single PostgreSQL transaction. If the asset insert succeeds but the outbox insert fails (due to a constraint violation, for example), the entire transaction rolls back and neither record persists. This atomicity guarantee is the foundation of the pattern's reliability.

Outbox Publisher

The publisher is implemented as a GenServer that polls the outbox table and forwards events to downstream consumers:

defmodule PrismaticPerimeter.OutboxPublisher do
  @moduledoc """
  Polls the outbox table for pending events and forwards them to
  downstream consumers via PubSub. Uses PostgreSQL's FOR UPDATE
  SKIP LOCKED for concurrent publisher safety.
  """
  use GenServer

  require Logger

  @poll_interval :timer.seconds(1)
  @batch_size 100

  @type state :: %{
    topic: String.t(),
    published_count: non_neg_integer(),
    error_count: non_neg_integer()
  }

  @spec start_link(keyword()) :: GenServer.on_start()
  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @impl GenServer
  def init(opts) do
    schedule_poll()

    {:ok,
     %{
       topic: Keyword.get(opts, :topic, "perimeter:events"),
       published_count: 0,
       error_count: 0
     }}
  end

  @impl GenServer
  def handle_info(:poll, state) do
    new_state =
      case fetch_pending_events(@batch_size) do
        [] ->
          state

        events ->
          Enum.reduce(events, state, fn event, acc ->
            case publish_event(event, acc.topic) do
              :ok ->
                mark_published(event.id)
                %{acc | published_count: acc.published_count + 1}

              {:error, reason} ->
                Logger.warning("Outbox publish failed",
                  event_id: event.id,
                  reason: inspect(reason)
                )

                increment_retry(event.id)
                %{acc | error_count: acc.error_count + 1}
            end
          end)
      end

    schedule_poll()
    {:noreply, new_state}
  end

  @spec fetch_pending_events(pos_integer()) :: [map()]
  defp fetch_pending_events(limit) do
    import Ecto.Query

    PrismaticStorage.Repo.all(
      from(e in "outbox_events",
        where: e.status == "pending" and e.retry_count < e.max_retries,
        order_by: [asc: e.created_at],
        limit: ^limit,
        lock: "FOR UPDATE SKIP LOCKED"
      )
    )
  end

  @spec publish_event(map(), String.t()) :: :ok | {:error, term()}
  defp publish_event(event, topic) do
    Phoenix.PubSub.broadcast(
      Prismatic.PubSub,
      topic,
      {:outbox_event, event.event_type, event.payload, event.metadata}
    )
  end

  @spec mark_published(Ecto.UUID.t()) :: {non_neg_integer(), nil}
  defp mark_published(event_id) do
    import Ecto.Query

    PrismaticStorage.Repo.update_all(
      from(e in "outbox_events", where: e.id == ^event_id),
      set: [status: "published", published_at: DateTime.utc_now()]
    )
  end

  @spec increment_retry(Ecto.UUID.t()) :: {non_neg_integer(), nil}
  defp increment_retry(event_id) do
    import Ecto.Query

    PrismaticStorage.Repo.update_all(
      from(e in "outbox_events", where: e.id == ^event_id),
      inc: [retry_count: 1]
    )
  end

  defp schedule_poll do
    Process.send_after(self(), :poll, @poll_interval)
  end
end

The FOR UPDATE SKIP LOCKED clause is critical for horizontal scalability. When multiple publisher instances run concurrently (for high-throughput scenarios), this clause ensures each instance claims a disjoint batch of pending events without contention. If one publisher is processing a batch, another publisher's query skips those locked rows and claims the next available batch.

Consumer Idempotency

Because the outbox pattern provides at-least-once delivery (not exactly-once), consumers must be idempotent. The standard approach is tracking processed event IDs:

defmodule PrismaticPerimeter.SecurityRatingConsumer do
  @moduledoc """
  Consumes asset discovery events and triggers security rating
  recalculation. Implements idempotent processing to handle
  duplicate event delivery from the outbox publisher.
  """
  use GenServer

  @spec handle_event(map()) :: :ok | :duplicate | {:error, term()}
  def handle_event(%{metadata: %{correlation_id: correlation_id}} = event) do
    case already_processed?(correlation_id) do
      true ->
        :duplicate

      false ->
        case process_event(event) do
          {:ok, _result} ->
            mark_processed(correlation_id)
            :ok

          {:error, reason} ->
            {:error, reason}
        end
    end
  end

  @spec already_processed?(String.t()) :: boolean()
  defp already_processed?(correlation_id) do
    import Ecto.Query

    PrismaticStorage.Repo.exists?(
      from(p in "processed_events", where: p.correlation_id == ^correlation_id)
    )
  end

  @spec mark_processed(String.t()) :: {non_neg_integer(), nil}
  defp mark_processed(correlation_id) do
    import Ecto.Query

    PrismaticStorage.Repo.insert_all("processed_events", [
      %{correlation_id: correlation_id, processed_at: DateTime.utc_now()}
    ])
  end

  defp process_event(%{event_type: "asset.discovered", payload: payload}) do
    PrismaticPerimeter.Rating.Engine.recalculate(payload.domain)
  end

  defp process_event(%{event_type: "asset.updated", payload: payload}) do
    PrismaticPerimeter.Rating.Engine.recalculate(payload.domain)
  end
end

Dead Letter Handling

Events that exceed their retry limit must be moved to a dead-letter queue for manual investigation rather than being silently dropped:

defmodule PrismaticPerimeter.OutboxDeadLetter do
  @moduledoc """
  Handles events that have exceeded their maximum retry count.
  Moves failed events to dead-letter status and emits telemetry
  for alerting and operational visibility.
  """

  require Logger

  @spec process_dead_letters() :: {:ok, non_neg_integer()}
  def process_dead_letters do
    import Ecto.Query

    {count, _} =
      PrismaticStorage.Repo.update_all(
        from(e in "outbox_events",
          where: e.status == "pending" and e.retry_count >= e.max_retries
        ),
        set: [status: "dead_letter"]
      )

    if count > 0 do
      Logger.warning("Moved #{count} events to dead letter queue")

      :telemetry.execute(
        [:prismatic_outbox, :dead_letter],
        %{count: count},
        %{source: __MODULE__}
      )
    end

    {:ok, count}
  end
end

Implementation in Prismatic Platform

The Prismatic Platform leverages the Outbox Pattern through Ecto and PostgreSQL for reliable cross-application event propagation across its umbrella applications. The pattern is central to several subsystems:

Perimeter Asset Discovery: When the EASM scanner discovers new assets, it writes both the asset record and a discovery event to the outbox within a single Ecto.Multi transaction. The outbox publisher distributes events via PubSub to the security rating engine, compliance assessor, and vulnerability scanner.

Agent Coordination: Agent state transitions (activation, deactivation, error recovery) are recorded as outbox events, ensuring that monitoring dashboards and the Quality Floor Guardian receive reliable notification of agent lifecycle changes.

Quality Gate Results: Quality gate pass/fail results are written to the outbox, enabling the Quality DNA system to update cross-session state and the SEADF evolution framework to track quality trends.

Compliance Assessment: When compliance checks complete for NIS2 or ZKB frameworks, results are published through the outbox to ensure that dashboard updates, alert generation, and report compilation all receive the assessment data reliably.

Outbox Table Maintenance

Without maintenance, the outbox table grows indefinitely, degrading polling query performance. A periodic cleanup process archives or deletes published events beyond the retention window:

defmodule PrismaticPerimeter.OutboxMaintenance do
  @moduledoc """
  Periodic maintenance for the outbox table. Archives published
  events beyond the retention window and compacts the table.
  """

  @retention_days 7

  @spec cleanup() :: {:ok, non_neg_integer()}
  def cleanup do
    import Ecto.Query

    cutoff = DateTime.add(DateTime.utc_now(), -@retention_days * 86_400, :second)

    {deleted_count, _} =
      PrismaticStorage.Repo.delete_all(
        from(e in "outbox_events",
          where: e.status == "published" and e.published_at < ^cutoff
        )
      )

    {:ok, deleted_count}
  end
end

Comparison with Alternatives

ApproachConsistencyComplexityAvailabilityOrderingInfrastructure
Outbox PatternAt-least-once, local ACIDModerate (idempotent consumers)High (no external deps for write)Per-aggregate via tableDatabase only
Two-Phase CommitExactly-onceHigh (coordinator required)Low (blocking protocol)Strong global orderingCoordinator + participants
Event SourcingEvent store is source of truthHigh (full event replay)High (append-only)Strong per-aggregateEvent store
Change Data CaptureAt-least-once from WALLow (infrastructure handles)Medium (depends on CDC tool)WAL orderingKafka Connect + Debezium
Saga PatternEventual (compensating actions)High (compensation logic)High (decoupled steps)No guaranteed orderingOrchestrator or choreography
Direct Message PublishBest-effort, dual-write riskLowMedium (broker dependency)Broker-dependentMessage broker

Change Data Capture (CDC) using tools like Debezium is the closest alternative, reading events directly from the database's write-ahead log. While CDC avoids the outbox table entirely, it requires additional infrastructure (Kafka Connect, Debezium) and provides less control over event schema and metadata. For Elixir/Phoenix systems that already use PostgreSQL and PubSub, the outbox pattern provides equivalent reliability with less operational overhead.

Monitoring and Observability

Effective outbox operation requires monitoring several key metrics:

MetricDescriptionAlert Threshold
Publisher lagAge of oldest pending event> 30 seconds
Pending countNumber of unprocessed events> 1,000
Dead letter countEvents exceeding retry limit> 0
Publish throughputEvents published per second< expected baseline
Retry ratePercentage of events requiring retry> 5%
Consumer lagTime between publish and consumption> 5 seconds

Best Practices

Schema Versioning: Include a schema version field in event metadata. When event structures evolve, consumers can detect version mismatches and apply appropriate deserialization logic without breaking existing processing.

Bounded Retry: Set maximum retry counts and implement dead-letter handling for events that exceed retry limits. Unbounded retries can mask permanent failures and consume publisher resources indefinitely.

Correlation and Causation IDs: Include both correlation IDs (tying related events across services) and causation IDs (identifying the event that caused this event) for distributed tracing and debugging.

Outbox Table Maintenance: Archive or delete published events periodically. Without maintenance, the outbox table grows indefinitely, degrading polling query performance.

Monitoring Publisher Lag: Track the age of the oldest pending event as a key metric. Growing lag indicates publisher throughput is insufficient or downstream consumers are causing backpressure.

Idempotency Keys: Use deterministic idempotency keys (aggregate ID + sequence number, or content hashes) rather than random UUIDs for events that may be regenerated during recovery scenarios.

Batch Size Tuning: Balance batch size against latency requirements. Larger batches improve throughput but increase per-event latency. Smaller batches provide lower latency but may underutilize database connections.

Common Pitfalls

Forgetting consumer idempotency: The outbox guarantees at-least-once delivery, which means consumers may receive the same event multiple times. Without idempotent processing, this leads to duplicate side effects.

Unbounded table growth: Published events accumulate in the outbox table without cleanup, eventually degrading query performance and consuming storage. Implement periodic cleanup with appropriate retention windows.

Single publisher bottleneck: A single publisher process can become a throughput bottleneck under high load. Use FOR UPDATE SKIP LOCKED to enable concurrent publishers that claim disjoint event batches.

Ignoring dead letters: Events that exceed retry limits indicate a systemic problem. Silently dropping them hides operational issues. Always route dead letters to a monitoring system.

Schema evolution without versioning: Changing event payload structure without version markers causes consumer deserialization failures. Always include schema version metadata.

Use Cases

Microservice State Synchronization: Services that need to keep their local state synchronized with changes in other services. The outbox ensures that state change notifications are reliably delivered even during service outages or network partitions.

Audit Trail Generation: Compliance-sensitive operations that require a complete, tamper-evident record of all state changes. The outbox table serves as both the event source and a durable audit log.

Event-Driven Aggregation: Systems that compute derived state (dashboards, reports, search indexes) from streams of events. The outbox guarantees that no source event is lost.

Cross-Boundary Notifications: Umbrella applications that need to notify other applications of state changes without introducing runtime coupling. The outbox + PubSub combination provides reliable notification while keeping applications independently testable.

CQRS Write-Side Events: Command-Query Responsibility Segregation architectures where the write side needs to publish events for the read side to consume. The outbox ensures every write-side command that commits also produces a corresponding event.

  • Event Sourcing -- Event-centric architecture that the outbox pattern supports
  • Ecto -- Database toolkit managing outbox table transactions through Multi
  • PostgreSQL -- Primary database hosting outbox tables with FOR UPDATE SKIP LOCKED
  • Eventual Consistency -- Consistency model achieved through outbox event propagation
  • CQRS -- Command-query separation where outbox publishes command-side events
  • Circuit Breaker -- Complementary pattern for publisher failure handling
  • Idempotency -- Consumer property required for at-least-once delivery safety
  • PubSub -- Distribution mechanism for outbox events within the platform
  • GenServer -- Process abstraction backing the outbox publisher
  • Supervisor -- Fault tolerance for the publisher process

See Also


Connect & Contribute

Created by Tomas Korcak (korczis) | Open Source under GHL