Simplify Your Code with Wisper

Eric - Jul 15 - - Dev Community

Are you having a hard time with huge classes in your code? Are your classes holding too many responsibilities making your code hard to maintain? You're not alone. Many developers face this problem known as high code coupling.

At Wecasa, we strive to keep things simple and small to minimize cognitive load. Small, focused methods and classes are essential for us.

Wisper is one of the gems we like using to prevent our code from getting bloated and get out of hand.

What is Wisper?

Wisper is a Ruby gem that helps organize your code by breaking down large classes into smaller ones. These smaller classes communicate by sending and receiving events using the publish-subscribe pattern.

In practice, we have:

  • One publisher that broadcasts events
  • One or more subscribers that will "react" to these events
  • The Wisper broadcaster acting as an intermediary between both for event communication

How does Wisper work?

Let's see the following use case.

Wecasa is a platform for booking at-home services connecting clients and professionals. Clients can make bookings. Once a booking is created, clients receive confirmation alerts by email. The creation will trigger a search for an available professional as well as a record tracking in our CRM.

class CreateBookingService
  def call(booking_params:)
    validate_booking_details(booking_params)
    booking = create_booking(booking_params)
    send_email_confirmation
    search_for_pro
    track_in_crm

    booking
  end

  private

  def validate_booking_details(booking_params)
    [...]
  end

  def create_booking(booking_params)
    [...]
  end

  def send_email_confirmation
    [...]
  end

  def search_for_pro
    [...]
  end

  def track_in_crm
    [...]
  end
end
Enter fullscreen mode Exit fullscreen mode

The code remains readable, but it's obvious that the class is handling multiple responsibilities. This not only violates the Single Responsibility Principle (SRP), but it also poses two concerns:

  • adding new subscribers will increase the class size
  • the booking creation is tightly coupled with the subscribers limiting its reusability across different contexts

After a few adjustments, here is how we can improve it with Wisper

Setup
First, add this line to your application's Gemfile

gem 'wisper'
Enter fullscreen mode Exit fullscreen mode

And then execute: bundle install

The Publisher

class CreateBookingService
  def call(booking_params:)
    validate_booking_details(booking_params)

    booking = create_booking(booking_params)

    broadcast(:booking_created, { booking_id: booking.id })

    booking
  end

  private

  def validate_booking_details(booking_params)
    [...]
  end

  def create_booking(booking_params)
    [...]
  end
end
Enter fullscreen mode Exit fullscreen mode

Let's zoom on the following line. The instruction will emit an event ready to be captured by subscribers once the booking is created.

broadcast(:booking_created, { booking_id: booking.id })
Enter fullscreen mode Exit fullscreen mode

It is composed of:

  • an event name :booking_created
  • a payload (any type is accepted)

The Subscribers

app/subscribers/notify_user_subscriber.rb

class NotifyUserSubscriber
  def call(payload)
    send_email_confirmation(payload)
  end
  [...]
end
Enter fullscreen mode Exit fullscreen mode

app/subscribers/search_pro_subscriber.rb

class SearchProSubscriber
  def call(payload)
    search_available_pro(payload)
  end
  [...]
end
Enter fullscreen mode Exit fullscreen mode

app/subscribers/track_booking_subscriber.rb

class TrackBookingSubscriber
  def call(payload)
    track_in_crm(payload)
  end
  [...]
end
Enter fullscreen mode Exit fullscreen mode

*Details were intentionally omitted to simplify understanding

And here is how we wire them all together

schema

The Wisper broadcaster serves as an intermediary, linking the publisher and the subscribers according to the following definitions.

config/initializers/wisper/booking_subscriptions.rb

Rails.application.config.after_initialize do
  Wisper.subscribe(
    [NotifyUserSubscriber.new, SearchProSubscriber.new, TrackBookingSubscriber.new],
    scope: CreateBookingService,
    on: :booking_created,
    with: :call
  )
end
Enter fullscreen mode Exit fullscreen mode

Note: Since "global listeners" are defined in config/initializers/**, any updates will require a restart of the Rails server.

Explanation
When the CreateBookingService publishes a booking_created event, three subscribers, NotifyUserSubscriber, SearchProSubscriber and TrackBookingSubscriber will respond to this event. They can then perform their related logic or opt out if necessary (e.g., if a user doesn't want to receive notifications).

The on: instruction is optional if your publisher only publishes one event. You can also chain multiple publishers or multiple subscribers within an array.
The with: instruction specifies the method of the subscribers to be invoked.

All of this is made possible thanks to the in-memory registry maintained by Wisper. Each event is tied to its publisher and subscriber.

Testing Support
Testing is effortless with the helpers provided by the wisper gem.

Thanks to this snippet in rspec_helper.rb

require 'wisper/rspec/matchers'

RSpec::configure do |config|
 config.include(Wisper::RSpec::BroadcastMatcher)
end
Enter fullscreen mode Exit fullscreen mode

The previous code can be tested with

expect {
 publisher.call(123)
}.to broadcast(:booking_created, { booking_id: 1 })
Enter fullscreen mode Exit fullscreen mode

Asynchronous Event Handling

If you notice, the previous subscribers are executed synchronously. However, we want them to be executed asynchronously since notification and tracking involve third-party services, and searching for a professional are not performed in real time.

Wisper supports various adapters for asynchronous event handling (wisper-sidekiq, wisper-resque, wisper-activejob to name a few) where an additional option async: true needs to be added.

Wisper.subscribe(
  NotifyUserSubscriber.new,
  scope: CreateBookingService,
  on: :booking_created,
  async: true
)
Enter fullscreen mode Exit fullscreen mode

However, we didn't add an additional adapter, instead we chose to implement it this way.

Wisper.subscribe(
  NotifyUserJob,
  scope: [CreateBookingService],
  on: :booking_created,
  with: :perform_later
)
Enter fullscreen mode Exit fullscreen mode

Advantages

Introducing this new design brought us those benefits

  • High cohesion: Focus each class on one responsibility
  • Loose coupling: The lack of interconnected dependencies makes classes easier to extend and reuse. Classes are connected based on context.
  • Readability and Maintainability: Because of this new decoupled code, code becomes naturally more manageable. Code becomes less complex for the team but also easier to read
  • Scalability: Adding new subscribers does not require modifying existing publishers
  • Testability: Testing becomes easier because classes are inherently smaller and limited in responsibilities

Best Practices

Here are some recommended tips learned from our experience with Wisper.

Clear and Precise Event Naming
Stick with a clear and consistent naming for the event's name to ensure maintainability. Consistent naming makes it easier to understand event purposes (i.e we like combining the resource with a past tense verb "booking_created")

Generic and Consistent Event Payload
While Wisper supports various payload types like integers, strings, and arrays and so on,

broadcast(:event_name, 123)
broadcast(:event_name, 'Hello World!')
broadcast(:event_name, [1, 2, 3])
Enter fullscreen mode Exit fullscreen mode

we advise using a hash for consistency and flexibility. Any new information can be added to the payload without changing the type.

broadcast(:event_name, { age: 20 })
Enter fullscreen mode Exit fullscreen mode

We also recommend designing a payload to be comprehensive and generic enough to accommodate various subscriber needs. Avoid subscriber-specific details, instead include information that all subscribers require.

Moderate use of Wisper
We don't use Wisper everywhere. Unless the class becomes complex, we prefer to use it for scenarios where loose coupling is essential.

Event Monitoring
Monitor and debug with logs to track interactions.

config/initializers/wisper.rb

Rails.application.config.to_prepare do
  Wisper.configure do |config|
   config.broadcaster(:default, Broadcasters::LoggerBroadcaster.new(Rails.logger, Broadcasters::SendBroadcaster.new))
  end
end
Enter fullscreen mode Exit fullscreen mode

Effective organization of event subscriptions
With the increasing volume of events configured in the Wisper Broadcaster, a rigorous way of organization is required to easilly locate them. (for instance, it could be sorted by component config/initializers/notification_subscriptions.rb)

Conclusion

Our initial motivation was to find an alternative to ActiveRecord callbacks. Over time, some models became bloated with callbacks. Not only was it difficult to test them in isolation, but it was also challenging to follow the flow of events effectively.
Additionally, these callbacks often introduced unwanted side effects.

Wisper emerged as a good candidate for connecting components based on context, preventing business logic from being placed in models.

On the other hand, this new approach to organizing code can be more complex compared to synchronous code as it involves understanding the flow of events and their interactions in different classes. Some developers may prefer having everything contained within a single class to easily grasp the entire logic. However these logics are supposed to be outlined within our tests, so that's up to you to use it wisely.

Thank you for reading. I invite you to subscribe so you don't miss the next articles that will be released.

. . .
Terabox Video Player