Martian Chronicles
Evil Martians’ team blog
Back-end

Crafting user notifications in Rails with Active Delivery

Rails framework is like a Swiss-army knife, providing a lot of useful functionality out-of-the-box (and it’s becoming even more Swiss-er).

It’s built on top of the sub-frameworks, such as, to name a few, ActiveRecord, ActiveJob, ActionCable (❤️), ActionMailer… Ok, let’s stop at this point.
What is the purpose of ActionMailer?

ActionMailer is an abstraction to send emails (and receive too, though now we have ActionMailbox).

It abstracts the delivery mechanism and provides a Railsy API to build messages.

So, sending email notifications to users is not a big problem for Rails apps.

The problem is that in a modern world we have many different ways to send notifications, not only emails: push notifications, chatbots, SMS, pigeons.

NOTE: DDH mentioned some “action notifier” framework, “yet to be extracted” from Basecamp, which sounds like a solution; but we’re not there yet.

It’s pretty common to have a code like this:

def notify_user(user)
  MyMailer.with(user: user).some_action.deliver_later if user.receive_emails?
  SmsSender.send_message(user, "Something happened") if user.receive_sms?
  NotifyService.send_notification(user, "action") if whatever_else?
end

And there could be dozens of such places in the codebase. Good luck with maintaining and testing this code!

How can we refactor this code? Maybe, we need an another layer of abstraction?)

Here comes Active Delivery—a new gem I wrote to solve this puzzle.

Active Delivery is a framework providing an entry point for all types of notifications: mailers, push notifications, whatever you want.

It helps you to rewrite the code above in the following way:

def notify_user(user)
  MyDelivery.with(user: user).notify(:some_action)
end

And even more—you can now test it elegantly:

# my_something_spec.rb
expect { subject }.to have_delivered_to(MyDelivery, :some_action)
  .with(user: user)

How does it work?

In the simplest case, a delivery is just a wrapper over a mailer:

# suppose that you have a mailer class
class MyMailer < ApplicationMailer
  def some_action
    # ...
  end
end

# the corresponding delivery could look like this
class MyDelivery < ActiveDelivery::Base
  # here we can also apply "delivery rules"
  before_notify :ensure_receive_emails, on: :mailer

  def ensure_receive_emails
    # returning `false` halts the execution
    params[:user].receive_emails?
  end
end

# when you call
MyDelivery.with(user: user).notify(:some_action)

# it invokes under the hood (only if user receives emails)
MyMailer.with(user: user).some_action.deliver_later

We rely on convention over configuration to infer the corresponding mailer class.

OK. We’ve just wrapped our mailer. What’s the deal? How to handle other delivery methods?

Let’s take a look at the architecture of the framework:

Active Delivery architecture

Active Delivery architecture

Notice that we have an internal layer here—lines. Each line is a connector between the delivery and the actual notification channel (e.g., mailer).

Active Delivery provides an API to add custom delivery lines—that’s how you can implement pretty much any type of notifications!

And to make it even easier, we’ve built another micro-framework—Abstract Notifier.

It’s a very abstract framework: all it does is provides an Action Mailer-like API for describing notifier classes, pure Ruby abstraction, zero knowledge of “how to send notifications.”

Why Action Mailer-like interface? It’s a familiar and continent API, first of all. And I like it’s parameterized classes feature (which we heavily use in Active Delivery).

To “teach” Abstract Notifier how to send notifications, you must implement a driver (any callable object).

For example, we use Twilio Notify for push notifications, and that’s how our driver, ApplicationDelivery and ApplicationNotifier classes look like:

class TwilioDriver
  attr_reader :service

  def initialize(service_id)
    client = build_twilio_api_client
    @service = client.notify.services(service_id)
  end

  def call(params)
    service.notifications.create(params)
  end
end

class ApplicationDelivery < ActiveDelivery::Base
  # NOTE: abstract_notifier automatically registers its default line,
  # you don't have to do that
  #
  # Default notifier infers notifier classes replacing "*Delivery* with
  # "*Notifier"
  register_line :notifier, ActiveDelivery::Lines::Notifier
end

class ApplicationNotifier < AbstractNotifier::Base
  self.driver = TwilioDriver.new(Rails.application.config.twilio_notify_id)
end

Now let’s define our delivery, mailer, and notifier classes:

class PostsDelivery < ApplicationDelivery
  # here we can define callbacks, for example,
  # we want to enforce passing a target user as a param
  before_notify :ensure_user_provided

  def ensure_user_provided
    raise ArgumentError, "User must be passed as a param" unless params[:user].is_a?(User)
  end

  # in our case we have a convenient params-reader method
  def user
    params[:user]
  end
end

class PostsMailer < ApplicationMailer
  def published(post)
    mail(
      to: user.email,
      subject: "Post #{post.title} has been published"
    )
  end
end

class PostsNotifier < ApplicationNotifier
  # Btw, we can specify default notification fields
  default action: "POSTS"

  def published(post)
    notification(
      body: "Post #{post.title} has been published",
      identity: user.twilio_notify_id
      # you can pass here any fields supported by your driver
    )
  end
end

And, finally, that’s how we trigger the notification:

PostsDelivery.with(user: user).notify(:published, post)

What if we need one more notification channel? We can add another notifier line to our ApplicationDelivery:

class ApplicationDelivery < ActiveDelivery::Base
  register_line :notifier, ActiveDelivery::Lines::Notifier

  register_line :pigeon,
                ActiveDelivery::Lines::Notifier,
                # resolver is responsible for inferring
                # the notifier class from
                # the delivery class name
                resolver: ->(name) { name.gsub(/Delivery$/, "Pigeon").safe_constantize }
end

class PigeonNotifier < AbstractNotifier::Base
  self.driver = PigeonDelivery.new
end

class PostsPigeon < PigeonNotifier
  def published(_post)
    notification(
      to: user.pigeon_nest_id,
      message: "coo-chi coo-chi coo"
    )
  end
end

That’s it 🐦!

Check out Active Delivery and Abstract Notifier repos for more technical information.

Humans! We come in peace and bring cookies. We also care about your privacy: if you want to know more or withdraw your consent, please see the Privacy Policy.