Implementing Event-Driven Architecture in Rails with Active Support Instrumentation
Table of contents
TL:DR; You can skip to setup if you just to see the implementation
Background
When I was building Pulse by Welodge I wanted to notify a user when they submit a startup for approval, when it’s accepted/rejected. I also wanted to notify the admins that someone has submitted a startup. The first implementation I simply dispatched a noticed Notifier in the controller when startup was submitted, but this did not have some “rails magic” into it.
After a quick search I found a few articles online about event driven architectures in rails but they all seem to be overly complicated for what I wanted and they seem to rely on 3rd party packages, which is fine but eventmachine wanted to run a separate “event server” or something like which was overkill,
After a deep dive, I landed on Active Support Instrumentation which rails’s own implementation of the observer pattern. Cool, I can now publish an event and have many subscribers which do different things. What the docs don’t say is how to get it to work in userland and that’s what this article is about
Setup
To get this working properly we need to setup a few things, first we need to auto load “subscribers” which I shall call listeners from here on. We want to store listeners in app/listeners/xx_listener.rb
where xx
is a resource/model in our application. To achieve this let’s hook into the to_prepare
config hook to load the event listeners during application boot
module Startuplist
class Application < Rails::Application
# rest of your app config
listeners = "#{Rails.root}/app/listeners"
Rails.autoloaders.main.ignore(listeners)
config.to_prepare do
Dir.glob("#{listeners}/**/*_listener.rb").sort.each do |listener|
load listener
end
end
end
end
Now all we have to do is define a listener file in app/listeners/
in this case app/listeners/startup_listener.rb
ActiveSupport::Notifications.subscribe "app.startup.submitted" do |event|
startup = event.payload[:startup]
StartupSubmittedNotifier.with(record: startup, message: "Your startup was submitted").deliver(startup.user)
Rabarber::Role.assignees(:admin).each do |admin|
SubmissionReceivedNotifier.with(record: startup, message: "A new startup was submitted").deliver(admin)
end
end
ActiveSupport::Notifications.subscribe "app.startup.accepted" do |event|
startup = event.payload[:startup]
StartupAcceptedNotifier.with(record: startup, message: "Your startup was accepted").deliver(startup.user)
end
Here we are simply subscribing to all events related to the startup
model, the user_listener.rb
could look something like this
ActiveSupport::Notifications.subscribe "app.user.created" do |event|
SubscribeNewsletterJob.perform_later event.payload[:user].email
end
Which subscribes a newly registered user to a newsletter like ConverKit, MailChimp or Mailerlite
Okay this is cool, how do we then dispatch these events, right, on Pulse the Startup has a status
enum so I did something like this to dispatch events each time a status changes
statuses.keys.each do |status|
after_save :"broadcast_#{status}_changed"
define_method :"broadcast_#{status}_changed" do
if saved_change_to_status? && self.status == status
ActiveSupport::Notifications.instrument "app.startup.#{status}", startup: self
end
end
end
This made sure that each time the status changed via a controller or anywhere else in the code, I publish a app.startup.status
event which listeners could subscribe to and do what ever they want with the data,
You can broadcast these events anywhere in your app for example we can also do something like for the user model
after_create :broadcast_create
def broadcast_create
ActiveSupport::Notifications.instrument "app.user.created", user: self
end
On this event we can then send welcome email, subscribe to newsletter, provision a tenant, etc.
I hope you enjoyed this pattern, let me know what you think about this.
[Update] Refactoring
After sharing this article on reddit, I got some feedback. Instead of manually loading the listeners we can use Zeitwerk eager loading instead. The config/application.rb
will be changed to
module Startuplist
class Application < Rails::Application
# rest of your app config
listeners = "#{Rails.root}/app/listeners"
config.to_prepare do
Rails.autoloaders.main.eager_load_dir(listeners)
end
end
end
Then our listeners we need to create a constant to allow eager loading, the startup_listener
would look something like this
module StartupListener
ActiveSupport::Notifications.subscribe "app.startup.submitted" do |event|
startup = event.payload[:startup]
StartupSubmittedNotifier.with(record: startup, message: "Your startup was submitted").deliver(startup.user)
Rabarber::Role.assignees(:admin).each do |admin|
SubmissionReceivedNotifier.with(record: startup, message: "A new startup was submitted").deliver(admin)
end
end
ActiveSupport::Notifications.subscribe "app.startup.accepted" do |event|
startup = event.payload[:startup]
StartupAcceptedNotifier.with(record: startup, message: "Your startup was accepted").deliver(startup.user)
end
end
Dispatching the events in an after_create
hook is a bad idea because if the listeners throw an error the transaction would rollback which is not what we wanted. Instead we need to hook to an after_create_commit
and after_save_commit
. The startup model would be changed to something like this
statuses.keys.each do |status|
after_save_commit :"broadcast_#{status}_changed"
define_method :"broadcast_#{status}_changed" do
if saved_change_to_status? && self.status == status
ActiveSupport::Notifications.instrument "app.startup.#{status}", startup: self
end
end
end
If you have more refactoring suggestions I would love to know in the comments