The Publish-Subscribe pattern is a great way to make code modular and decoupled from the rest of the architecture. It also has a nice side-effect of making testing easier and classes smaller.
In a pub-sub pattern, publishers don’t know anything about its subscribers and subscribers don’t know anything about who is publishing to it. Subscribers are simply listening for specific messages and handling them accordingly and publishers are simply broadcasting messages.
Subscribers are meant to be reused with other publishers. Though often some subscribers will only be used in one place. The value add with this approach is that the subscriber can be removed at anytime and readded. If one were to implement a feature flipper, dynamically adding subscribers can be a big win.
Let’s take a dive into the Signals gem.
At a basic level a publisher can be considered a service object and any calls to and external service can be a subscriber. Subscribers need a way to listen for specific events and fire off the appropriate actions.
class ActivityListener
include Signals::Subscriber
listen_for :failed_login => :log_failed_attempt
def log_failed_attempt(user)
# ... some security audit stuff ...
end
end
The ActivityListener
is now isolated away from what other subscribers are
doing. This makes testing really simple. A mocked user could be passed in and
test spies could be used to ensure that the proper methods were invoked.
Sometimes there are events that will take place that need to have multiple actions taken. For example, when a user creates a subscription the application will probably need to send a confirmation email along with talking to a payment gateway while logging the request to some security tracker. This is a strawman example, but it is something that can happen.
class ActivityListener
include Signals::Subscriber
listen_for [:logged_in, :logged_out] => :log_activity
def log_activity(user)
# ... something ...
end
end
Example
This is something similar to how I use Signals in production. This does assume you are using Delayed Jobs, however if you are using a different background job tool, I’m sure the conversion is easy enough.
# app/services/create_user.rb
class CreateUser
include Signals::Publisher
def initialize(params={})
@user = User.new(params)
end
def execute
if @user.save
broadcast(:create_user_successful, @user)
else
broadcast(:create_user_failed, @user)
end
end
end
# app/jobs/welcome_email_job.rb
class WelcomeEmailJob
def initialize(id)
@id = id
end
def perform
user = User.find(@id)
EmailListener.new.user_created(user)
end
end
# app/listeners/email_listener.rb
class EmailListener
include Signals::Subscriber
listen_for :create_user_successful => :enqueue_user_created
def enqueue_user_created(user)
Delayed::Job.enqueue(WelcomeEmailJob.new(user.id))
end
def user_created(user)
WelcomeMailer.welcome_email(user).deliver
end
end
# app/controllers/users_controller.rb
class UsersController < ApplicationController
# ...
def create
service = CreateUser.new(user_params)
service.subscribe(EmailListener.new)
service.on(:create_user_successful) do |user|
redirect_to root_url
end
service.on(:create_user_failed) do |user|
@user = user
render action: 'new'
end
service.execute
end
# ...
end
Resources
These are some resources I found extremely helpful as I wrote the library to further my understanding of the publish-subscriber pattern.