This time we'll experiment with a quick way to architecture a Rails
application to use Pub/Sub instead of model callbacks.
What's wrong with callbacks
Rails active record models easily become bloated, that's where most of
the business logic tends to live after all. One of the most common sources of
technical debt in Rails apps is callbacks. Models become god-objects
with dependencies to other models, mailers and even 3rd party services.
When it comes to refactoring this coupling, I usually recommend
extracting all callbacks to stateless functions which can be composed to
form pipelines. One can use dry-transaction for that.
My love for such composable architectures led me to create Opus for Elixir.
I'm also quite proud that callbacks got deprecated in Ecto 🎉.
About Pub/Sub
The solution which is the focus of this post is Pub/Sub. The models will publish
events concerning database updates. A database record gets created /
updated / destroyed and then a subscriber does something, or ignores the event.
Enter ActiveSupport::Notifications
We'll lay the foundations for this ten minute implementation on top of
ActiveSupport::Notifications. Originally
introduced as an instrumentation API for Rails, but there's nothing
preventing us from using it for custom events.
Some facts about ActiveSupport::Notifications.
- It's basically a thread-safe queue
- Events are synchronous
- Events are process-local
- It's simple to use 😎
The Code
In this experiment, we'll cover the following scenario:
When a User "zorbash" is created
And "zorbash" had been invited by "gandalf"
Then the field signups_count for "gandalf" should increase by 1
First we'll create a model concern which we can include to our User
model to publish events each time a record is created.
# frozen_string_literal: true
module Publishable
extend ActiveSupport::Concern
included do
after_create_commit :publish_create
after_update_commit :publish_update
after_destroy_commit :publish_destroy
end
class_methods do
def subscribe(event = :any)
event_name = event == :any ? /#{table_name}/ : "#{table_name}.#{event}"
ActiveSupport::Notifications.subscribe(event_name) do |_event_name, **payload|
yield payload
end
self
end
end
private
def publish_create
publish(:create)
end
def publish_update
publish(:update)
end
def publish_destroy
publish(:destroy)
end
def publish(event)
event_name = "#{self.class.table_name}.#{event}"
ActiveSupport::Notifications.publish(event_name, event: event, model: self)
end
end
Then we must include it in our model.
# frozen_string_literal: true
class User < ApplicationRecord
include Publishable # 👈 Added here
devise :invitable
# other omitted code
end
Let's implement a subscriber.
module UserSubscriber
extend self
def subscribe
User.subscribe(:create) do |event|
event[:model].increment!(:signups_count)
end
end
end
Finally, we have to initialize the subscription.
# File: config/initializers/subscriptions.rb
Rails.application.config.after_initialize do
UserSubscriber.subscribe
end
Caveats
The more listeners you add, the slower it becomes for an event to be
handled in sequence across all listeners. This is similar to how an
object would call all callback handler methods one after the other.
See: active_support/notifications/fanout.rb
def publish(name, *args)
listeners_for(name).each { |s| s.publish(name, *args) }
end
They're also not suitable for callbacks used to mutate a record like
before_validation
or after_initialize
.
Furthermore there are no guarantees that an event will be processed
successfully. Where things can go wrong, will go wrong. Prefer a
solution with robust recovery semantics.
Next Steps
For enhanced flexibility, we can push events to Redis or RabbitMQ or Kafka. How
to pick one according to your needs is beyond the scope of this post.
However you can consider yourself lucky, since there are tons of resources out
there and mature libraries to build your event-driven system on top of.
Alternatives
Notable Pub/Sub gems:
For other handy libraries and posts, subscribe to my Tefter Ruby & Rails list.
Top comments (2)
Thanks for sharing. I have a concern now, suppose I have a test cases and I don't want to run these callbacks so is it possible to stop them while testing?
Actually I used to use callbacks a lot but not anymore bacause it's difficult to debug and maintain in case you have a lot of them. So my suggestion is creating new interactor class for creating user
CreateUserInteractor
and will add all logic there and in case I have complex logic then will add a separate class for each process which will be called in CreateUserInteractor.What do you think?
Structuring your event subscriptions in interactor classes sounds good. To skip event subscriber calls in your tests you can use regular stubs.
For example
allow(UserSubscriber).to receive(:subscribe)
would do the trick.