ruby 124 lines · 4 tabs

Transactional Outbox for Reliable Event Publishing

Shared by codesnips Jan 2026
4 tabs
class CreateOutboxEvents < ActiveRecord::Migration[7.1]
  def change
    create_table :outbox_events do |t|
      t.string :event_type, null: false
      t.string :aggregate_type, null: false
      t.string :aggregate_id, null: false
      t.string :dedupe_key, null: false
      t.jsonb :payload, null: false, default: {}
      t.datetime :published_at
      t.integer :attempts, null: false, default: 0
      t.timestamps
    end

    add_index :outbox_events, :dedupe_key, unique: true
    add_index :outbox_events, :id,
              where: "published_at IS NULL",
              name: "index_outbox_events_unpublished"
  end
end
4 files · ruby Explain with highlit

The transactional outbox pattern solves a classic distributed-systems problem: a service needs to update its database and publish an event to a broker (Kafka, SNS, RabbitMQ), but there is no shared transaction across the two systems. If the code writes to the DB and then publishes, a crash in between drops the event; if it publishes first, a rolled-back transaction leaves a phantom event. The outbox sidesteps this by writing the event into a table inside the same transaction as the business change, then relaying it asynchronously.

The create_outbox_events migration sets up that durable queue. Each row carries an aggregate_type/aggregate_id for ordering context, a JSONB payload, and a dedupe_key with a unique index so retries never enqueue the same logical event twice. The partial index on published_at IS NULL keeps the relay's polling query fast even as the table grows, since only unpublished rows are indexed.

In OutboxEvent model, record! is the enrolment point. It is called from within the caller's transaction so the event and the aggregate commit atomically. The dedupe_key defaults to a deterministic hash of the event type and aggregate, and insert_all with unique_by makes insertion idempotent under concurrent writers. unpublished orders by id to preserve rough causal order, and mark_published! stamps published_at so the relay skips it next cycle.

The OrderService shows the pattern in use: inside a single transaction block it persists the Order and calls OutboxEvent.record!. Because both writes share the transaction, either both land or neither does — there is no window where an order exists without its event, or vice versa.

The OutboxRelayJob is the poller. It claims a batch with FOR UPDATE SKIP LOCKED, which lets multiple relay workers run in parallel without processing the same row twice. Each event is handed to the broker and marked published individually, so a publish failure only affects one row and leaves the rest recoverable. The job re-enqueues itself when a full batch is drained, draining backlog aggressively. The key trade-off is at-least-once delivery: consumers must be idempotent, which is why the dedupe_key is propagated into the payload envelope.


Related snips

Share this code

Here's the card — post it anywhere.

Transactional Outbox for Reliable Event Publishing — share card
Link copied