Simplify Your Code with Wisper
Published: 2024-07-15 07:05:07

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.

```ruby
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
```

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

```ruby
gem 'wisper'
```

And then execute: `bundle install`

**The Publisher**

```ruby
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
```

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

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

It is composed of:
- an event name :booking_created
- a payload (any type is accepted)

**The Subscribers**

`app/subscribers/notify_user_subscriber.rb`
```ruby
class NotifyUserSubscriber
  def call(payload)
    send_email_confirmation(payload)
  end
  [...]
end
```

`app/subscribers/search_pro_subscriber.rb`
```ruby
class SearchProSubscriber
  def call(payload)
    search_available_pro(payload)
  end
  [...]
end
```

`app/subscribers/track_booking_subscriber.rb`
```ruby
class TrackBookingSubscriber
  def call(payload)
    track_in_crm(payload)
  end
  [...]
end
```
_*Details were intentionally omitted to simplify understanding_

**And here is how we wire them all together**


![schema](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/49rvel37tn7b1f4fm7tj.png)

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

`config/initializers/wisper/booking_subscriptions.rb`
```ruby
Rails.application.config.after_initialize do
  Wisper.subscribe(
    [NotifyUserSubscriber.new, SearchProSubscriber.new, TrackBookingSubscriber.new],
    scope: CreateBookingService,
    on: :booking_created,
    with: :call
  )
end
```

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`

```ruby
require 'wisper/rspec/matchers'

RSpec::configure do |config|
 config.include(Wisper::RSpec::BroadcastMatcher)
end
```

The previous code can be tested with
```ruby
expect {
 publisher.call(123)
}.to broadcast(:booking_created, { booking_id: 1 })
```

## 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.

```ruby
Wisper.subscribe(
  NotifyUserSubscriber.new,
  scope: CreateBookingService,
  on: :booking_created,
  async: true
)
```

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

```ruby
Wisper.subscribe(
  NotifyUserJob,
  scope: [CreateBookingService],
  on: :booking_created,
  with: :perform_later
)
```

## 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,
```ruby
broadcast(:event_name, 123)
broadcast(:event_name, 'Hello World!')
broadcast(:event_name, [1, 2, 3])
```
we advise using a hash for consistency and flexibility. Any new information can be added to the payload without changing the type.
```ruby
broadcast(:event_name, { age: 20 })
```

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`
```ruby
Rails.application.config.to_prepare do
  Wisper.configure do |config|
   config.broadcaster(:default, Broadcasters::LoggerBroadcaster.new(Rails.logger, Broadcasters::SendBroadcaster.new))
  end
end
```

**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._