Outboxer helps you migrate Ruby on Rails applications to best practice event driven architecture fast.
It was created out of a need to addresses the dual write problem that can occur when attempting to insert an Event
row into an SQL database and then queuing an EventCreatedJob
into redis to handle that event e.g.
user_created_event = UserCreatedEvent.create!(...)
# ☠️ process crashes
UserCreatedJob.perform_async(user_created_event.id)
❌ job was not queued to redis
🪲 downstream state is now inconsistent
# app/event.rb
class Event < ApplicationRecord
after_create do |event|
Outboxer::Message.queue(messageable: event)
end
end
Note: This ensures an event is always created in the same transaction as the queued outboxer message referencing it.
# app/models/user_created_event.rb
class UserCreatedEvent < Event; end
# app/jobs/user_created_job.rb
class UserCreatedJob
include Sidekiq::Job
def perform(event_id, event_type)
# your code to handle UserCreatedEvent here
end
end
# app/jobs/event_created_job.rb
class EventCreatedJob
include Sidekiq::Job
def perform(event_id, event_type)
job_class_name = event_type.sub(/Event\z/, "Job")
job_class = job_class_name.safe_constantize
job_class.perform_async(event_id, event_type)
end
end
# bin/publisher
Outboxer::Publisher.publish_message(...) do |message|
EventCreatedJob.perform_async(
message[:messageable_id], message[:messageable_type])
end
UserCreatedEvent.create!
For more details, see the wiki page: How Outboxer works
gem 'outboxer'
bundle install
bin/rails g outboxer:install
# db/migrate/create_events.rb
class CreateEvents < ActiveRecord::Migration[7.0]
def up
create_table :events do |t|
t.bigint :user_id
t.bigint :tenant_id
t.string :eventable_type, limit: 255
t.bigint :eventable_id
t.index [:eventable_type, :eventable_id]
t.string :type, null: false, limit: 255
t.send(json_column_type, :body)
t.datetime :created_at, null: false
end
end
# ...
end
# app/models/event.rb
class Event < ApplicationRecord
self.table_name = "events"
# associations
belongs_to :eventable, polymorphic: true
# validations
validates :type, presence: true, length: { maximum: 255 }
# callbacks
after_create do |event|
Outboxer::Message.queue(messageable: event)
end
end
bin/rake db:migrate
# app/models/accountify/contact_created_event.rb
module Accountify
class ContactCreatedEvent < Event
end
end
# app/services/accountify/contact_service.rb
module Accountify
module ContactService
module_function
def create(user_id:, tenant_id:, email:)
ActiveRecord::Transaction.execute do
contact = Contact.create!(tenant_id: tenant_id, email: email)
event = ContactCreatedEvent.create!(
user_id: user_id, tenant_id: tenant_id,
eventable: contact, body: { "email" => email }
)
[contact, event]
end
end
end
end
contact, event = Accountiy::ContactService.create(...)
Following the convention Context::ResourceVerbEvent -> Context::ResourceVerbJob
# app/jobs/accountify/contact_created_job.rb
module Accountify
class ContactCreatedJob
include Sidekiq::Job
def perform_async(args)
# your handler code here
end
end
end
Note: this can be customised in the generated EventCreatedJob
bin/outboxer_publisher
Note: The outboxer publisher supports many options.
bin/sidekiq
Note: Enabling superfetch is strongly recommend, to preserve consistency across services.
bin/rails c
contact, event = Accountify::ContactService.create(user_id: 1, tenant_id: 1, email: '[email protected]')
TRANSACTION (0.5ms) BEGIN
Accountify::Contact Create (1.9ms) INSERT INTO "accountify_contacts" ...
Accountify::ContactCreatedEvent Create (1.8ms) INSERT INTO "events" ...
Outboxer::Message Create (3.2ms) INSERT INTO "outboxer_messages" ...
TRANSACTION (0.7ms) COMMIT
=> {:id=>1, :events=>[{:id=>1, :type=>"Accountify::ContactCreatedEvent"}]}
OutboxerIntegration::TestService.start
TRANSACTION (0.5ms) BEGIN
OutboxerIntegration::Test Create (1.9ms) INSERT INTO "outboxer_integration_tests" ...
OutboxerIntegration::TestStartedEvent Create (1.8ms) INSERT INTO "events" ...
Outboxer::Message Create (3.2ms) INSERT INTO "outboxer_messages" ...
TRANSACTION (0.7ms) COMMIT
=> {:id=>1, :events=>[{:id=>1, :type=>"OutboxerIntegration::TestStartedEvent"}]}
OutboxerIntegration::Test.find(1).events
=>
[#<OutboxerIntegration::TestStartedEvent:0x0000000105749158
id: 1,
user_id: nil,
tenant_id: nil,
eventable_type: "OutboxerIntegration::Test",
eventable_id: 1,
type: "OutboxerIntegration::TestStartedEvent",
body: {"test"=>{"id"=>1}},
created_at: 2025-01-11 23:37:36.009745 UTC>,
#<OutboxerIntegration::TestCompletedEvent:0x0000000105748be0
id: 2,
user_id: nil,
tenant_id: nil,
eventable_type: "OutboxerIntegration::Test",
eventable_id: 1,
type: "OutboxerIntegration::TestCompletedEvent",
body: {"test"=>{"id"=>1}},
created_at: 2025-01-11 23:48:38.750419 UTC>]
bin/rspec spec/outboxer_integration/test_started_spec.rb
This is what the generated spec is testing, to continue to ensure you have end to end confidence in your stack.
Outboxer provides a sidekiq like UI to help manage your messages


require 'outboxer/web'
Rails.application.routes.draw do
mount Outboxer::Web, at: '/outboxer'
end
require 'outboxer/web'
map '/outboxer' do
run Outboxer::Web
end
If you'd like to help out, take a look at the project's open issues.
Also join our discord and ping bedrock-adam
if you'd like to contribute to upcoming features.
This gem is available as open source under the terms of the GNU Lesser General Public License v3.0.