Skip to content
Adam Mikulasev edited this page Jun 14, 2025 · 10 revisions

Outboxer Wiki – Table of Contents

Foundation

  1. Define Ubiquitous Language
    Model your core business actions clearly, per bounded context.

  2. Decoupling with Events
    How to migrate to an event-driven architecture.

  3. The Transactional Outbox Pattern
    How to avoid message loss and maintain consistency.

  4. Use a Callback to DRY Up Message Queuing
    Use a base Event class with an after_create hook to reduce repetition and ensure consistency.


Define Ubiquitous Language in Bounded Contexts

Start by identifying domain concepts in your bounded context.

For example, in the Accountify context, voiding an invoice is a meaningful domain action. We want to:

  • Capture this behavior explicitly
  • Represent it in code using a clear name
  • Allow other systems to react to it without tight coupling

This leads us to define a domain event: Accountify::InvoiceVoidedEvent.

Sample Domain Events in Accountify

Action Event Class Event Name
An invoice is finalised InvoiceFinalisedEvent Accountify::InvoiceFinalisedEvent
An invoice is voided InvoiceVoidedEvent Accountify::InvoiceVoidedEvent
A customer updates payment PaymentMethodUpdatedEvent Accountify::PaymentMethodUpdatedEvent
A statement is downloaded StatementDownloadedEvent Accountify::StatementDownloadedEvent

Decoupling with Events

The Problem

Ever seen a model that does everything? From saving records to sending emails and syncing with external services?

That’s a recipe for trouble. When logic, integrations, and infrastructure mix in the same place, bugs become harder to track and tests become fragile. One change can break ten things.


Step 1: Create a Root Event Class

Start with a base event class to standardize how you track meaningful business actions:

class Event < ApplicationRecord
  self.abstract_class = true
end

Now define a domain event that inherits from it:

module Accountify
  class InvoiceVoidedEvent < Event
    belongs_to :eventable, polymorphic: true
  end
end

Step 2: Define an Event Handler Job

class EventCreatedJob
  include Sidekiq::Job

  def perform(event_id)
    event = Event.find(event_id)

    case event.type
    when "Accountify::InvoiceVoidedEvent"
      Notifier.send_invoice_voided_email(event.body["invoice_id"])
      Analytics.track_invoice_voided(event.tenant_id, event.body)

    # Add other event types here
    else
      Rails.logger.warn("Unhandled event type: #{event.type}")
    end
  end
end

Step 3: Use a Stateless Application Service

To decouple side effects, begin by creating an event and queuing a background job in the application service.

module Accountify
  module InvoiceService
    extend self

    def void(user_id:, tenant_id:, id:)
      ActiveRecord::Base.transaction do
        invoice = Invoice.find(id)
        invoice.update!(status: 'voided')

        event = InvoiceVoidedEvent.create!(
          user_id: user_id,
          tenant_id: tenant_id,
          eventable: invoice,
          body: { invoice_id: invoice.id }
        )

        EventCreatedJob.perform_async(event.id)
      end
    end
  end
end

The Transactional Outbox Pattern

The Problem: Lost or Inconsistent Events

If you update a record and publish a message in two separate steps, you risk:

  • Saving the record but losing the message
  • Sending the message but rolling back the record

This leads to inconsistent state between systems.


The Solution: Queue Messages in the Same Transaction

Outboxer solves this using the transactional outbox pattern. Just queue the message in the same transaction:

ActiveRecord::Base.transaction do
  invoice.update!(status: 'voided')

  event = InvoiceVoidedEvent.create!(
    user_id: user_id,
    tenant_id: tenant_id,
    eventable: invoice,
    body: { invoice_id: invoice.id }
  )

  Outboxer::Message.queue(messageable: event)
end

Outboxer will only publish the message if the transaction commits successfully.


Background Publisher

Run a loop or background worker to publish messages:

Outboxer::Publisher.publish_messages do |_, messages|
  messages.each do |message|
    EventCreatedJob.perform_async(message.messageable_id)
  end
end

You can use Sidekiq, Delayed Job, or any background job system.


Use a Callback to DRY Up Message Queuing

The Problem: Repeating Yourself

Every time you create an event, you also need to remember to queue it:

event = InvoiceVoidedEvent.create!(...)
Outboxer::Message.queue(messageable: event)

This is easy to forget and clutters your code.


The Solution: Use a callback in the base event class

Define an after_save callback that automatically queues outboxer messages:

class Event < ApplicationRecord
  after_create do
    Outboxer::Message.queue(messageable: self)
  end
end

Now your event classes inherit from this:

module Accountify
  class InvoiceVoidedEvent < Event
    belongs_to :eventable, polymorphic: true
  end
end

Application Services Stay Clean

Your service now just creates the event:

module Accountify
  module InvoiceService
    extend self

    def void(user_id:, tenant_id:, id:)
      ActiveRecord::Base.transaction do
        invoice = Invoice.find(id)
        invoice.update!(status: 'voided')

        InvoiceVoidedEvent.create!(
          user_id: user_id,
          tenant_id: tenant_id,
          eventable: invoice,
          body: { invoice_id: invoice.id }
        )
      end
    end
  end
end

No need to call Outboxer::Message.queue—it happens automatically.


Why This Works

  • ✅ Ensures every event is queued safely
  • ✅ Reduces boilerplate
  • ✅ Keeps services focused on business logic
  • ✅ Still uses the transactional outbox pattern under the hood
Clone this wiki locally