Guide · Ruby on Rails

Save state and queue work together in Rails

Use a transactional outbox to prevent lost jobs after a Rails commit. Learn how Solid Objects saves state, database writes, and effects together.

A database commit and a job enqueue are two separate steps. If the process stops between them, the database keeps the data, but the queue receives no work. The transactional outbox pattern writes the work into the same database transaction as the data. A separate process delivers the work later. A Solid Objects actor, from the solid_objects gem, commits its state, its database writes, and its staged effects in one transaction.

Reproduce the lost job

class Order < ApplicationRecord
  def self.place_and_enqueue!(reference:, total_cents:)
    order = create!(reference:, total_cents:)
    ShipmentJob.perform_later(order_id: order.id)
    order
  end

  def self.place_with_outbox!(reference:, total_cents:)
    transaction do
      order = create!(reference:, total_cents:)
      OutboxMessage.create!(name: "request_shipment", arguments: { "order_id" => order.id })
      order
    end
  end
end
class ShipmentJob < ApplicationJob
  def perform(order_id:)
    order = Order.find(order_id)
    ShippingProvider.create_shipment(idempotency_key: "order-#{order.id}", order_reference: order.reference)
  end
end

In place_and_enqueue!, create! commits the order. Then perform_later sends the job to the queue. The test uses a queue adapter that stops the process before the job reaches the queue. The order exists, but no job exists.

after_commit and enqueue_after_transaction_commit have the same gap: they enqueue the job after the database commit.

The Rails Guides state that enqueue_after_transaction_commit defers the enqueue until the Active Record transaction commits successfully. If the transaction rolls back, Rails does not enqueue the job. Rails 8 configures Solid Queue on a separate database by default. With this default, the job row and the order row use two databases. See the Rails Guides, section 6.6.1 (checked October 9, 2026).

The transactional outbox tests demonstrate the crash gap.

The plain outbox pattern

In place_with_outbox!, the model writes the order and an outbox row in one transaction.

class OutboxMessage < ApplicationRecord
  JOBS = { "request_shipment" => ShipmentJob }.freeze

  scope :pending, -> { where(delivered_at: nil).order(:id) }

  def self.relay(limit: 100)
    pending.limit(limit).each do |message|
      JOBS.fetch(message.name).perform_later(**message.arguments.symbolize_keys)
      message.update!(delivered_at: Time.current)
    end
  end
end

The relay method uses these steps:

  1. It sends each undelivered row to the queue.
  2. It marks the row as delivered.

If the process stops after the enqueue and before the mark, the next relay sends the row again. The job must be idempotent. ShipmentJob passes order-<id> to the provider as the idempotency key.

Rails Event Store uses this pattern. Its scheduler writes the job into the same database table within the same transaction. A separate res_outbox process sends those rows to the background jobs tool. See Rails Event Store (checked October 9, 2026).

The plain outbox pattern is a good choice for an application that does not use actors.

The atomic boundary of an actor turn

class Checkout < SolidObjects::Actor
  attribute :status, default: "open"
  attribute :total_cents, default: 0
  attribute :shipment_id, default: nil

  def place(total_cents:)
    return status unless status == "open"

    self.status = "placed"
    self.total_cents = total_cents
    commit_action(:record_order, reference: actor_id, total_cents:)
    emit(:request_shipment, order_reference: actor_id, on_success: :shipment_requested)
    status
  end

  def shipment_requested(effect_id:, arguments:, result:)
    self.status = "shipping"
    self.shipment_id = result.fetch("shipment_id")
  end
end
SolidObjects.register_commit_action(:record_order) do |arguments, _context|
  Order.create!(reference: arguments.fetch("reference"), total_cents: arguments.fetch("total_cents"))
end

SolidObjects.register_effect(:request_shipment) do |arguments, context|
  ShippingProvider.create_shipment(
    idempotency_key: context.id,
    order_reference: arguments.fetch("order_reference")
  )
end

One successful turn commits the actor state, the staged effects, the same-database commit actions, the reminders, and the outbound messages together. Actor Ruby code and external I/O run outside the actor-state transaction.

What Solid Objects does not do

What the tests prove

Run it in production

Limits

More information

Generated from docs/guides/transactional-outbox.md in cardmagic/solid-objects-ruby, v0.17.3, commit 8c9f0f9. For TypeScript and Node.js, read Virtual actors in TypeScript and Node.js.