How to implement Pub/Sub pattern in Ruby on Rails?

vladhilko

Vlad Hilko

Posted on April 10, 2023

How to implement Pub/Sub pattern in Ruby on Rails?

Overview:

In this article we'll provide a comprehensive guide to understanding and implementing the Pub/Sub pattern. We will explore the evolution of this pattern from a primitive implementation to three product-ready solutions: ActiveSupport::Notifications, Wisper, and Dry-Events. We will begin by discussing the core concept of the Pub/Sub pattern. Then:

  • We will provide a step-by-step guide on how to implement the Pub/Sub pattern using the basic Ruby language constructs.
  • We will introduce the ActiveSupport::Notifications library.
  • We will delve into the Wisper gem.
  • Finally, we will examine the Dry-Events gem.

By the end of this article, we will have a solid understanding of the Pub/Sub pattern and the different options available for implementing it in their Ruby on Rails applications.

Definition

In simple terms, the Pub/Sub (Publish/Subscribe) pattern is a way of decoupling components in a system by providing an alternative method of communication between them. Rather than sending messages directly to specific components, a publisher broadcasts messages to any subscribers that are interested in receiving them. In other words, the Pub/Sub pattern enables communication between different components of a system without these components having any knowledge of each other.

Why do we need it and what problems can this pattern solve? 🤷🏻‍♂️

Let's take a look at the following piece of code from the rails controller.

def create
  animal = Animal.create(animal_params)
  # send_email
  # send_mobile_notification
  # save_logs

  render json: animal
end
Enter fullscreen mode Exit fullscreen mode

What are we doing here?

  • We want to create an Animal record and show this record in the response
  • PLUS send an email about it
  • PLUS send a mobile notification about it
  • PLUS save this action to the logs

Do we have any problems with this approach? 🤔

Yes, we have. This code can lead to several problems, including:

  • Reduced maintainability: With multiple unrelated tasks in the same controller action, it can be difficult to maintain or modify the code in the future.
  • Reduced testability: Testing the create action becomes more complicated due to the additional responsibilities it has. It also makes it more challenging to test these other responsibilities in isolation.
  • Reduced scalability: If additional functionality needs to be added, it would likely be added to the create action, which can make the controller even more complicated and challenging to maintain.
  • Reduced readability: Mixing unrelated functionality in the same controller action can make it difficult to understand the overall purpose of the action.

How can we solve this problem? 🤔

Some developers solve this problem by using callbacks on the model to handle it. So, they might use something like this::

# app/models/animal.rb

# frozen_string_literal: true

class Animal < ApplicationRecord
  after_create :send_email, :send_mobile_notification, :save_logs

  private

  def send_email
    puts 'Email will be sent'
  end

  def send_mobile_notification
    puts 'Mobile Notification will be sent'
  end

  def save_logs
    puts 'Logs will be saved'
  end
end
Enter fullscreen mode Exit fullscreen mode

But callbacks can potentially lead to significant issues, so it is better to avoid them.

Potentially, we could create a separate service object and place all this logic inside it. For example:

# app/units/create_animal_service.rb

class CreateAnimalService
  def self.call
    # do something
    send_email
    send_mobile_notification
    save_logs
  end

  private

  def send_email
    puts 'Email will be sent'
  end

  def send_mobile_notification
    puts 'Mobile Notification will be sent'
  end

  def save_logs
    puts 'Logs will be saved'
  end
end
Enter fullscreen mode Exit fullscreen mode

While this approach is an improvement over using model callbacks, there is still a significant amount of cohesion between the core business logic and non-critical secondary logic. As a result, developers must understand the secondary logic, which is closely tied to the core logic, leading to increased cognitive load and a shift in focus from the primary part to technical details.

To address these issues, we can use the Pub/Sub pattern to separate the core business logic from non-critical secondary logic. Let's discuss this further:

How pub/sub actually works?

There are three key elements that must be implemented to make Pub/Sub work for you:

  • Send/Publish/Broadcast an event.
  • Define the logic that can potentially react to this event.
  • Bind the event with the logic ("subscribe to the event").

And we would like to have something like this as our new interface:

def create
  animal = Animal.create(animal_params).tap { send_event('animal_created') }

  render json: animal
end
Enter fullscreen mode Exit fullscreen mode

This interface would allow us to concentrate on the core business logic and delegate all 'secondary' logic to somewhere else.

With the key elements above in mind, let's try to build a primitive Pub/Sub logic using plain Ruby to understand the concept behind it.


Primitive Ruby implementation

In this implementation, we'll use the same three main elements: 'Publishing an event', 'Defining logic' and 'Binding the event to the logic'.

Publishing an Event

For example, let's consider an event named animal_created, which is represented as a string. Whenever this event is sent, we simply add this string to an array

events = []
events << 'animal_created'
Enter fullscreen mode Exit fullscreen mode

Defining logic to react to the event (subcriptions)

To react to the event, we need to define some actions that can potentially happen when the event occurs. These actions are called subscriptions or listeners. We can use a simple hash with two keys: event_name and action. event_name is a string that identifies the event, and action is a proc that is executed when the event is received.

subscriptions = []
subscriptions << { event_name: 'animal_created', action: -> { puts 'hello animal!' } }
subscriptions << { event_name: 'animal_created', action: -> { puts 'hello animal 2!' } }
Enter fullscreen mode Exit fullscreen mode

Binding events and subscriptions

We have defined two main elements, but there is a problem - events and subscriptions are not connected and do not know about each other. To address this, we need to introduce some logic that will iterate over new events and match them with the appropriate action defined in the subscriptions.

events.each do |event_name|
  subscriptions.select { |subscription| subscription[:event_name] == event_name }.each { |subscription| subscription[:action].call }
end
events.clear
Enter fullscreen mode Exit fullscreen mode

So, that is the basic idea. Let's try to improve our implementation by encapsulating some logic in a class.


Primitive Ruby implementation using class

We will create a class called Notification, which will have 3 methods: publish, subscribe and initialize. In this implementation, we will immediately react to the published event as soon as it is sent.

class Notification
  def initialize
    # We are initializing a Hash with empty arrays as default values for any new key
    @notifications = Hash.new { |hash, key| hash[key] = [] }
  end

  def subscribe(event_name, action:)
    notifications[event_name] << action
  end

  def publish(event_name)
    notifications[event_name].each { |action| action.call }
  end

  private

  attr_reader :notifications
end
Enter fullscreen mode Exit fullscreen mode

Here is a usage example:

# Initialize the class
notification = Notification.new

# Subscribe to the event "animal_created" with two actions.
notification.subscribe('animal_created', action: -> { puts 'hello animal!' })
notification.subscribe('animal_created', action: -> { puts 'hello animal 2!' })

# Publish the 'animal_created' event, which will trigger both actions.
notification.publish('animal_created')
Enter fullscreen mode Exit fullscreen mode

Out three key elements have also been used in this implementation.


This is a very simple example of how pub/sub can work. However, there are many ready-made solutions that we can use in our application. Let's consider three of them:

  • ActiveSupport::Notification
  • Wisper
  • Dry-events

They're pretty much the same and have very similar interfaces. Let's look at each of these options one by one to better understand the concept.


ActiveSupport::Notification

ActiveSupport::Notification has the same three key elements that we discussed earlier

  • Send/Publish/Broadcast an event.
  • Define the logic that can potentially react to this event.
  • Bind the event with the logic ("subscribe to the event").

Let's consider three ways of implementing the pub/sub pattern using ActiveSupport::Notification, starting from the simplest and progressing to the most complex, in order to better understand the concept:

  • ActiveSupport::Notification with blocks
  • ActiveSupport::Notification with classes
  • ActiveSupport::Notification in a Rails application

Let's start with the most basic one

ActiveSupport::Notification with blocks

# Define the logic
ActiveSupport::Notifications.subscribe('animal_created') do |*args|
  puts "New animal created 1"
end

ActiveSupport::Notifications.subscribe('animal_created') do |*args|
  puts "New animal created 2"
end

# Send the event
ActiveSupport::Notifications.instrument('animal_created')

# => New animal created 1
# => New animal created 2
Enter fullscreen mode Exit fullscreen mode

So, here everything looks obvious. Basically, we have the same interface as we described in the primitive Ruby implementation. Let's go further.

ActiveSupport::Notification with classes

Instead of a block, we can also send a class that has a call method. This can be useful when your logic becomes too complex, and using classes can greatly simplify testing.

# Define the logic
class Subscription1
  def call(name, started, finished, unique_id, payload)
    puts "New animal created 1"
  end
end

class Subscription2
  def call(name, started, finished, unique_id, payload)
    puts "New animal created 2"
  end
end

# Bind the event with the logic
ActiveSupport::Notifications.subscribe('animal_created', Subscription1.new)
ActiveSupport::Notifications.subscribe('animal_created', Subscription2.new)

# Send the event
ActiveSupport::Notifications.instrument('animal_created')

# => New animal created 1
# => New animal created 2
Enter fullscreen mode Exit fullscreen mode

Here's how ActiveSupport::Notification basically works. Let's try to add it to a real Rails application.

ActiveSupport::Notification in a Rails application

For example, we have a service object that performs some actions and sends the 'animal_created' event.

Publishing an Event

# app/units/service.rb

class Service
  def self.call
    # perform some actions
    ActiveSupport::Notifications.instrument('animal_created', payload: {})
  end
end
Enter fullscreen mode Exit fullscreen mode

Defining logic to react to the event

Additionally, we need to create subscriptions to react to these events. We want to send an email, send a mobile notification, and log this action somewhere. To accomplish this, we'll create three subscription classes, each of which will handle one of these tasks.

  • EmailSubscription
# app/subscriptions/email_subscription.rb

# frozen_string_literal: true

class EmailSubscription < Subscription

  def animal_created(payload)
    puts "Email will be sent #{payload}"
  end

end
Enter fullscreen mode Exit fullscreen mode
  • MobileNotificationSubscription
# app/subscriptions/mobile_notification_subscription.rb

# frozen_string_literal: true

class MobileNotificationSubscription < Subscription

  def animal_created(payload)
    puts "Mobile Notification will be sent #{payload}"
  end

end
Enter fullscreen mode Exit fullscreen mode
  • LoggerSubscription
# app/subscriptions/logger_subscription.rb

# frozen_string_literal: true

class LoggerSubscription < Subscription

  def animal_created(payload)
    puts "Logs will be saved #{payload}"
  end

end
Enter fullscreen mode Exit fullscreen mode

Every subscription class should include the desired reaction to the event and a method call to actually run this reaction. That's why we've created a parent class Subscription.

  • Subscription
# lib/subscription.rb

# frozen_string_literal: true

class Subscription

  def call(event_name, _, _, _, payload)
    send(event_name, payload)
  end

end

Enter fullscreen mode Exit fullscreen mode

Binding events and logic

We've created an event that can be sent and classes with the appropriate reaction to the event. However, these classes aren't currently connected to the event, meaning they won't react to it. To establish the connection, we'll use the following logic after Rails initialization.

# config/initializers/subscriptions.rb

# frozen_string_literal: true

Rails.application.config.after_initialize do
  subscriptions = {
    EmailSubscription: ['animal_created'],
    LoggerSubscription: ['animal_created'],
    MobileNotificationSubscription: ['animal_created']
  }

  # We iterate over each subscription class and bind its logic with the event name, similar to what we did in the example with blocks.
  subscriptions.each do |subscription_class, events|
    events.each do |event|
      ActiveSupport::Notifications.subscribe(event, subscription_class.to_s.constantize.new)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

So now we can call Service.call and this code will send the 'animal_created' event, triggering all subscriptions:

Email will be sent {:payload=>{}}
Logs will be saved {:payload=>{}}
Mobile Notification will be sent {:payload=>{}}
Enter fullscreen mode Exit fullscreen mode

Adding a new event

To add a new event, you need to update the subscription classes with the desired logic and then bind them to the new event. This ensures that the subscription logic will be executed whenever the event is triggered.

For example, if you would like to add the car_created event to the EmailSubscription, then you should do the following:

# app/subscriptions/email_subscription.rb

# frozen_string_literal: true

class EmailSubscription < Subscription

  def animal_created(payload)
    puts "Email will be sent #{payload}"
  end

  def car_created(payload)
    puts "Car email will be sent #{payload}"
  end

end
Enter fullscreen mode Exit fullscreen mode

It's important not to forget to subscribe to this event in the initializer after adding the logic to the EmailSubscription class for the car_created event.

# config/initializers/subscriptions.rb

# frozen_string_literal: true

Rails.application.config.after_initialize do
  subscriptions = {
    EmailSubscription: ['animal_created', 'car_created'],
    LoggerSubscription: ['animal_created'],
    MobileNotificationSubscription: ['animal_created']
  }
  # ...
end
Enter fullscreen mode Exit fullscreen mode

When we call car_created, the subscribed logic in EmailSubscription will be triggered as expected.

ActiveSupport::Notifications.instrument('car_created', payload: {})

# => Car email will be sent {:payload=>{}}
Enter fullscreen mode Exit fullscreen mode

Wisper gem

The second option is to use Wisper gem.

First of all, we need to add the wisper gem to the Gemfile and then run bundle install:

gem 'wisper'
Enter fullscreen mode Exit fullscreen mode

This gem has the same three key elements:

  • Send/Publish/Broadcast an event.
  • Define the logic that can potentially react to this event.
  • Bind the event with the logic ("subscribe to the event").

Send an event.

To send an event we need to do the following:

# app/units/create_animal_service.rb

# frozen_string_literal: true

class CreateAnimalService
  include Wisper::Publisher

  def call
    # do something
    broadcast(:animal_created, payload: {})
  end
end

CreateAnimalService.new.call
Enter fullscreen mode Exit fullscreen mode

Define the logic

To add logic for our events, we need to do the following:

  • EmailSubscription
# app/subscriptions/email_subscription.rb

# frozen_string_literal: true

class EmailSubscription

  def animal_created(payload)
    puts "Email will be sent #{payload}"
  end

end
Enter fullscreen mode Exit fullscreen mode
  • LoggerSubscription
# app/subscriptions/logger_subscription.rb

# frozen_string_literal: true

class LoggerSubscription

  def animal_created(payload)
    puts "Logs will be saved #{payload}"
  end

end
Enter fullscreen mode Exit fullscreen mode
  • MobileNotificationSubscription
# app/subscriptions/mobile_notification_subscription.rb

# frozen_string_literal: true

class MobileNotificationSubscription

  def animal_created(payload)
    puts "Mobile Notification will be sent #{payload}"
  end

end
Enter fullscreen mode Exit fullscreen mode

Bind the event with the logic

To bind the event and subscription, we need to do the following

# config/initializers/subscriptions.rb

# frozen_string_literal: true

Rails.application.config.after_initialize do
  Wisper.subscribe(EmailSubscription.new)
  Wisper.subscribe(LoggerSubscription.new)
  Wisper.subscribe(MobileNotificationSubscription.new)
end

Enter fullscreen mode Exit fullscreen mode

As you can see, the interface is quite similar to what we had in ActiveSupport::Notifications.


Dry-event

Our third option is to create a Pub/Sub system using the dry-rb gems. To be more precise, we are going to use the dry-events gem.

To get started, we need to add the dry-events gem to our Gemfile and run bundle install:

gem 'dry-events'
Enter fullscreen mode Exit fullscreen mode

This gem has the same 3 key elements plus an additional one - we need to register an event. Let's take a look at the example below:

Register an event

# lib/events.rb

# frozen_string_literal: true

require 'dry/events/publisher'

class Events
  include Singleton
  include Dry::Events::Publisher[:my_publisher]

  register_event('animal.created')
end
Enter fullscreen mode Exit fullscreen mode

We included Singleton here because dry-event requires us to work with an instance, but we don't want to create a new instance every time.

Send an event

# app/units/create_animal_service.rb

# frozen_string_literal: true

class CreateAnimalService
  def self.call
    # do something
    Events.instance.publish('animal.created', payload: {})
  end
end
Enter fullscreen mode Exit fullscreen mode

Defining logic to react to the event (Subscriptions)

  • EmailSubscription
# app/subscriptions/email_subscription.rb

# frozen_string_literal: true

class EmailSubscription

  def on_animal_created(event)
    puts "Email will be sent #{event[:payload]}"
  end

end

Enter fullscreen mode Exit fullscreen mode
  • LoggerSubscription
# app/subscriptions/logger_subscription.rb

# frozen_string_literal: true

class LoggerSubscription

  def on_animal_created(event)
    puts "Logs will be saved #{event[:payload]}"
  end

end

Enter fullscreen mode Exit fullscreen mode
  • MobileNotificationSubscription
# app/subscriptions/mobile_notification_subscription.rb

# frozen_string_literal: true

class MobileNotificationSubscription

  def on_animal_created(event)
    puts "Mobile Notification will be sent #{event[:payload]}"
  end

end

Enter fullscreen mode Exit fullscreen mode

Bind the event with the logic

# config/initializers/subscriptions.rb

# frozen_string_literal: true

Rails.application.config.after_initialize do
  events = Events.instance

  events.subscribe(EmailSubscription.new)
  events.subscribe(LoggerSubscription.new)
  events.subscribe(MobileNotificationSubscription.new)
end

Enter fullscreen mode Exit fullscreen mode

Conclusion

The final solution may look like this or be placed under the Service object which sends an event inside:

def create
  animal = Animal.create(animal_params).tap { ActiveSupport::Notifications.instrument('animal_created') }

  render json: animal
end
Enter fullscreen mode Exit fullscreen mode

In conclusion, Pub/Sub pattern is a useful design pattern in Ruby on Rails applications for managing communication between different parts of the system. The advantages of using Pub/Sub are numerous, including the most important ones:

Advantages

  • Decoupling

    One of the main advantages of the pub/sub pattern is that it allows publishers and subscribers to be decoupled from each other. Publishers do not need to know about the subscribers, and subscribers do not need to know about the publishers. This makes it easy to add or remove components from the system without affecting the other components.

  • Scalability

    The pub/sub pattern is highly scalable, since it allows multiple subscribers to receive the same message at the same time. This makes it easy to distribute messages to a large number of subscribers, without overloading the publishers or the subscribers.

  • Flexibility

    The pub/sub pattern provides a flexible way to implement communication between different components of an application, as well as between different applications. Since publishers and subscribers do not need to know about each other, it is easy to add new features or change existing ones without having to modify existing code.

  • Asynchronous

    The pub/sub pattern is asynchronous, which means that publishers and subscribers do not need to be active at the same time. Publishers can publish messages at any time, and subscribers can consume messages whenever they are ready. This makes it easy to implement real-time notifications and messaging systems.

Disadvatages

  • Complexity

    Implementing the pub/sub pattern can be complex, especially when dealing with a large number of publishers and subscribers. This pattern can add an additional layer of complexity to the system, which can make it harder to debug and maintain.

  • Reduction of application flow visibility

    Another potential disadvantage of using the Pub/Sub pattern is that it can result in a reduction of application flow visibility. This means that the overall flow of the system may be harder to understand, as the primary action and its secondary effects may be implemented in different parts of the codebase, making it difficult for future readers to comprehend the system's operation.

💖 💪 🙅 🚩
vladhilko
Vlad Hilko

Posted on April 10, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related