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!