-
Notifications
You must be signed in to change notification settings - Fork 0
Home
-
Define Ubiquitous Language
Model your core business actions clearly, per bounded context. -
Decoupling with Events
How to migrate to an event-driven architecture. -
The Transactional Outbox Pattern
How to avoid message loss and maintain consistency. -
Use a Callback to DRY Up Message Queuing
Use a baseEvent
class with anafter_create
hook to reduce repetition and ensure consistency.
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
.
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 |
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.
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
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
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
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.
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.
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.
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.
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
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.
- ✅ Ensures every event is queued safely
- ✅ Reduces boilerplate
- ✅ Keeps services focused on business logic
- ✅ Still uses the transactional outbox pattern under the hood