I’m gonna skip introduction about what is publish-subcribe patter, what wisper is or how to use it and get straight to the real-life example.
Let’s say you have a controller action that for some business-related reasons have to handle 7 different use-cases - for example we have to support different kind of logic for our legacy users that have some sort old(er) subscription model, on top of which we have some new subscription models and of course we need to handle some generic failures.
The complexity has to go somewhere - you might decide to split it on entrypoint (maybe split your controller action altogether, but that also have some downsides) - but let’s say you ended up with one bulky action where you have some sort of service class (workflow, action or whatever the modern term for those are nowadays) that does the business logic.
The tricky part begins when you have to add some business checks on top of your service class. So your main service class is doing some business logic but you probably don’t want to break single responsibility principle and put a bunch of if-checks on top of that. In such cases I like to introduce bouncer class that requires filters, here is an example:
# This is simplified example, in real-life project I ended up with some
# very simple inheritance as I needed two different kind of bouncers with different
# set of predefined filters (use dependency injection at will)
class Bouncer::Base
include Wisper::Publisher
DEFAULT_FILTERS = [Bouncer::Filters::Subscription]
# don't worry what user and job is, this is just an example
def initialize(user, job, &block)
@user = user
@job = job
@block = block
end
def call(filters = DEFAULT_FILTERS)
filters.each do |klass|
filter = klass.new(user, job)
# here our nice early return, looks clean & elegant
return broadcast(*filter.event) if filter.apply?
end
block.call
end
private
attr_reader :user, :job, :block
end
# And this is base class for the filter itself - as stated above
# I ended up with very simple inheritance
class Bouncer::Filters::Base
def initialize(user, job)
@user = user
@job = job
end
def apply?
raise NotImplementedError, <<~INFO
Please implement method that will return boolean
regarding if that particular filter should be applied
once it's applies bouncer will use `event` method to broadcast
information up to controller
INFO
end
def event
raise NotImplementedError, <<~INFO
Please implement method that will be used for
broadcasting information up to controller
INFO
end
private
attr_reader :user, :job
# Here is sample implementation of the filter class
# By itself it doesn't do much, but publishing event
# On failure gives us nice flexibility in terms how
# we want o react for given even - read further
class Bouncer::Filters::Subscription < Bouncer::Filters::Base
def apply?
!subscription || subscription.expired?
end
def event
[:invalid_subscription]
end
private
# ... get subscription and possibly more logic
end
By following this pattern we have a possibility to extend it further and further without polluting our existing classes - we can easily add new filters and react to business events. We can even hook in different subscribers for both (service class and bouncer) classes. Let’s take a look.
def create
# let's not dig into business details here, assume service
# is doing our 'main' business thing and subscribers are
# handling potential side effects (on success or failure)
service = ContactRequest::Submit.new(current_user, job)
service.subscribe(Subscribers::NewContactRequestNotification)
# we can attach subscribers to our main service class and
# attach subscribers to our subscribers even further
credit_reducer = Subscribers::CreditReduction.new(current_user, job)
credit_reducer.subscribe(Subscribers::CreditNotificationChecker)
service.subscribe(credit_reducer)
# we wrap our service inside our 'bouncer' class, so basically mimic
# before_action behavior without polluting controller
# In real-life scenario I ended up with extra factory class here
bouncer = Bouncer::Base.new(current_user, job) do
service.call(contact_request_params)
end
# we want to react to bouncer event for some reason?
# Hook in analytics or marketing events? No problem there
bouncer.subscribe(Subscribers::ParticipationDeclinedEvent)
service.on(:success) do
redirect_back fallback_location: notice: 'Handle success'
end
service.on(:failure) do |contact_request, job|
redirect_to job_path(job), flash: { error: 'Respond with some errors maybe?' }
end
# Handle our 'bounced' actions
bouncer.on(:invalid_subscription) { redirect_to subscription_path, notice: 'Add some meaningful message?' }
bouncer.on(:restricted_participation) { redirect_to job_limited_participation_path(job) }
bouncer.call
end
This way we end up with loosely coupled code, the main downside, on the other hand, is that you need pretty good code coverage when following such pattern, otherwise you might end up with pretty green unit tests, yet broken business logic. See github discussion here.
Either way, I recommend giving it a try, maybe such approach will suit your needs!