Advanced Domain Modeling Techniques for Ruby on Rails – Part 4
A Global Message Bus with ActiveSupport::EventReporter
A new way to decouple code since Rails 8.1.
There are these moments where you feel stuck between the devil and the deep blue sea. Two possible solutions, neither quite up to the job.
Let’s start with a simple example that illustrates what we’re competing against. You are implementing an e-commerce solution that sells digital products. After a purchase is placed, a few things need to happen: send a confirmation email, track the event in your analytics, and ping your team on Slack. Your first pass looks something like this:
def create
@order = Order.create!(order_params)
OrderMailer.confirmation(@order).deliver_later
Ahoy::Event.create!(name: "order.created", properties: { order_id: @order.id })
SlackNotifier.new.ping("New order ##{@order.id}")
end
The controller is reaching into entities that aren’t its concern. That’s called Feature Envy. It is violating Tell, Don’t Ask — instead of sending a message and letting receivers decide, it’s interrogating the order and orchestrating the response itself. So you start cleaning up and move it all into the model with after_create callbacks:
after_create :send_confirmation, :track_event, :notify_slack
Note, it is also possible to declare callbacks directly in SlackNotifier via Order.after_create { SlackNotifier.new.ping("New order ##{self.id}") }. This reduces coupling but scatters logic around the codebase which - as we will see - comes with its own set of drawbacks.
Now Order has to change whenever you switch analytics providers, reword a notification, or add a Slack channel. That’s called Divergent Change: the class now has many “axes” of potential change, leading to feature bloat, merge conflicts, and mounting documentation requirements. So you extract it into service objects, and a single business rule change — say, adding a loyalty points bonus on purchase — means editing several files:
# app/services/order_confirmation_service.rb
def call(order)
OrderMailer.confirmation(order).deliver_later
+ order.customer.award_loyalty_points(:purchase_confirmed)
end
# app/services/order_tracking_service.rb
def call(order)
Ahoy::Event.create!(
name: "order.created",
- properties: { order_id: order.id }
+ properties: { order_id: order.id, loyalty_points: order.loyalty_points }
)
end
# ...and SlackNotificationService, InvoiceService, etc.
That’s called Shotgun Surgery. Every refactor just trades one smell for another. At a fundamental level, we are wrestling with the two goals of high cohesion and low coupling, and can’t quite reconcile them.
Suddenly Alan Kay’s object oriented programming mantra springs to mind:
“OOP to me means only messaging, local retention and protection and hiding of state-process, and extreme late-binding of all things.” (https://www.purl.org/stefanram/pub/dockayoopen, emphasis mine)
You think of Kafka, RabbitMQ and their Ruby implementation, but dismiss them as too complex and introducing too much operational overhead for this medium size monolith you’re building.
The good news is that starting from Rails 8.1, you don’t have to reach for external tooling anymore.
The interface is as simple as it gets:
Rails.event.notify("order.created", order_id: @order.id)
One line. The controller announces what happened, and any number of subscribers independently decide how to react.
An Observer With Rails Batteries
Starting from a standard orders#create action, we add the event notification right after a successful save:
if @order.save
Rails.event.notify("order.created", order_id: @order.id)
redirect_to @order
else
render :new
end
This publishes an event to all registered subscribers. The implementation is straightforward, and there’s a way to filter events when subscribing, which we’ll look at in a second.
First, let’s write a subscriber:
# app/subscribers/order_notification_subscriber.rb
class OrderNotificationSubscriber
def emit(event)
case event[:name]
when "order.created"
order = Order.find(event.dig(:payload, :order_id))
OrderMailer.confirmation(order).deliver_later
end
end
end
The pattern is simple: The emit method receives an event, inspects the name and decides if it is able to handle it. So in our case, if the event is of type "order.created", it goes along and fires off a confirmation email.
By the way, if the naming of emit, which actually receives events, confuses you, you are not alone 🤯. The method name describes what the subscriber does — emit a side effect into the world — not what happens to it. This becomes clearer with StructuredEventSubscriber in the bonus section below.
The final piece is to wire it up, which can be done in a simple initializer:
# config/initializers/event_subscribers.rb
Rails.event.subscribe(OrderNotificationSubscriber.new) { |event| event[:name].start_with?("order.") }
The block passed to it is optional, and is the place to implement any “before” filtering logic, to allow only certain events to be passed to it. This is exactly what Rails.event.notify then picks up to sieve through incoming events.
“Tell, Don’t Ask” At Scale
With the basic wiring in place, the real payoff of “Tell, Don’t Ask” becomes visible: neither the emitter nor the subscriber needs to know about the other. The controller just announces what happened, and any number of subscribers can react independently. This means we can grow our system in two directions — adding new subscribers to existing events, and adding new emitters that existing subscribers pick up for free.
As an example, let’s introduce our second requirement - awarding loyalty points when an order is created. To achieve this, we just write another subscriber:
# app/subscribers/loyalty_points_subscriber.rb
class LoyaltyPointsSubscriber
def emit(event)
case event[:name]
when "order.created"
order = Order.find(event.dig(:payload, :order_id))
order.customer.award_loyalty_points(:purchase)
when "review.submitted"
review = Review.find(event.dig(:payload, :review_id))
review.customer.award_loyalty_points(:review)
end
end
end
We already introduced a second when clause: when a user submits a review, they should also be awarded loyalty points. Now, just add it to the initializer:
# config/initializers/event_subscribers.rb
Rails.event.subscribe(OrderNotificationSubscriber.new) { |event| event[:name].start_with?("order.") }
Rails.event.subscribe(LoyaltyPointsSubscriber.new)
The final piece of the puzzle is how easily we can now add more inputs to the event bus, and modify the logic triggering side effects in a central place. Let’s imagine we have an admin interface in our application that allows bulk importing orders via a CSV file. This bulk import, however, should not send any notifications. First, we implement a job that handles this:
# app/jobs/bulk_order_import_job.rb
class BulkOrderImportJob < ApplicationJob
def perform(csv)
Rails.event.tagged("bulk_import") do
CSV.foreach(csv, headers: true) do |row|
order = Order.create!(
product_id: row["product_id"],
customer_id: row["customer_id"]
)
Rails.event.notify("order.created", order_id: order.id)
end
end
end
end
Second, we modify the subscriber just a little bit:
# app/subscribers/order_notification_subscriber.rb
class OrderNotificationSubscriber
def emit(event)
+ return if event[:tags][:bulk_import]
case event[:name]
when "order.created"
order = Order.find(event.dig(:payload, :order_id))
OrderMailer.confirmation(order).deliver_later
end
end
end
Now, whenever an event is tagged with bulk_import, it will be ignored. Incidentally, we have now also touched the concept of tagged events: Enriching the context of an event without tainting the payload.
There’s also the related concept of context stores, which I will leave out for this article
Trade-offs and Limitations
No pattern is free, and it’s worth being honest about what you’re trading away.
First, indirection. The callback version was painful, but at least you could grep for after_create and see what happens when an order is saved. With events, the emitter and subscriber are decoupled by design — which means a new developer on the team might not immediately know the side effects of creating a new order. Good naming conventions, a well-organized app/subscribers directory, and a brief section in your project’s README go a long way here, but it’s a real cost that is largely mitigated by documentation.
Second, this is synchronous and in-process. When Rails.event.notify fires, it calls each subscriber’s emit method in sequence, in the same request cycle. If a subscriber does something slow and doesn’t delegate to deliver_later or perform_later, it blocks the response. This isn’t Kafka — there’s no queue, no retry, no surviving a process crash mid-pipeline. For most monoliths, that’s fine. Just be deliberate about pushing heavy work into background jobs from within your subscribers. For retries specifically, wrapping the actual work in an ActiveJob that uses retry_on is a natural fit — your subscriber stays thin, and the job handles failure gracefully.
Third, testing changes shape. Instead of testing that your controller sends an email, you’re now testing two things separately: that the controller emits the right event, and that the subscriber reacts correctly when it receives one.
Finally, a word on when not to reach for this. If you have a single side effect that only ever triggers from one place, an event bus is a clear overdo. A simple method call is fine. This pattern earns its keep when you have multiple emitters, multiple subscribers, or both — when the matrix of “who triggers what” starts to get unwieldy. Start simple, reach for events when the pain arrives.
Conclusion
Let’s circle back to Alan Kay. What he described wasn’t about classes or inheritance — it was about autonomous objects communicating through messages, with the receiver deciding how to respond. Rails.event is exactly that: a lightweight, in-process messaging system where emitters announce what happened and subscribers independently decide what to do about it.
The pattern doesn’t eliminate complexity — it relocates it. Instead of complexity hiding in a God Class with twelve callbacks, or scattering across a dozen service objects that all need to change in lockstep, it lives in small, focused subscribers that each own one reaction to one kind of event. That’s a trade-off most teams will happily take.
Rails 8.1 didn’t invent the Observer pattern. But by shipping ActiveSupport::EventReporter as a first-class citizen, it removed the last excuse for not using it.
Bonus: StructuredEventSubscriber
So far, all our examples have used plain subscriber objects that listen to events emitted by our own application code via Rails.event.notify. But Rails itself emits hundreds of events through a different, older system: ActiveSupport::Notifications. Every SQL query (sql.active_record), every template render (render_template.action_view), every controller action (process_action.action_controller) — all of these are instrumented internally using ActiveSupport::Notifications.instrument.
What if you want to tap into those internal events and feed them into your structured event pipeline? That’s what ActiveSupport::StructuredEventSubscriber is for. It acts as a bridge: it listens on the ActiveSupport::Notifications side and re-emits into Rails.event.
Let’s say our e-commerce platform uses a payment gateway that already instruments its processing through ActiveSupport::Notifications:
# somewhere in your payment gateway integration
ActiveSupport::Notifications.instrument("payment_processed.checkout", order_id: order.id)
We can bridge this into our event pipeline with a StructuredEventSubscriber:
# app/subscribers/order_structured_event_subscriber.rb
class OrderStructuredEventSubscriber < ActiveSupport::StructuredEventSubscriber
attach_to :checkout
def payment_processed(event)
emit_event("order.fulfilled", order_id: event.payload[:order_id])
end
end
This subscriber attaches to the :checkout namespace, which means it receives any notification matching *.checkout. The payment_processed method is called whenever payment_processed.checkout fires. It then calls emit_event — which is just a convenience wrapper around Rails.event.notify — pushing it into the same structured event pipeline that all our existing subscribers already consume. From here, it would be trivial to add an AhoyTrackingSubscriber that picks up order.fulfilled and logs it — no new wiring needed.
This is also where the emit naming finally comes full circle: the subscriber isn’t a dead end. It receives a notification from one system and emits a structured event into another. It’s a relay, not a sink.
Custom form helpers and builders can help reduce fragmentation if every developer in the team solves the same problem differently.