Implementing a Flexible Notifications System in Elixir Using Protocols

post image
Image credit: suttonlscb.org.uk

Published on: 20 October 2017

This post was originally published on Medium. This the latest version which contains some minor changes.

At Heresy, we help Sales teams stay in sync by delivering real-time data through different types of notifications. Thanks to Elixir, all that functionality is rather straightforward to implement using Protocols. If you haven’t used protocols yourself, this article should serve as a good example of how to do it in practice.

What’s a protocol?

From Elixir’s official Getting Started guide on Protocols:

Protocols are a mechanism to achieve polymorphism in Elixir. Dispatching on a protocol is available to any data type as long as it implements the protocol.

Which essentially means that if you have different types of data, that you want to process in a standardised, uniform way, then you need protocols.

Another Elixir feature, that’s always brought up when someone mentions Protocols, is Behaviours. Behaviours are similar to Interfaces in other languages—they define a contract, which Elixir modules implementing the behaviour have to follow, thus achieving interoperability. In a nutshell—protocols are about data, behaviours are about modules.

Going back to our use case—notifications—you can see how Protocols are a natural fit. Wouldn’t it be nice if we have a single Notification.send function that “just works” with any kind of notification? That’s exactly what we’re going to do!

Structs are your friend

Let’s start with two common types of notifications: websocket and email. In Phoenix, you can broadcast a WebSocket event like so:

MyApp.Endpoint.broadcast(topic, event, payload) 

So let’s define our first data structure that would represent a WebSocket notification:

# notifications/web_socket.ex
defmodule Notifications.WebSocket do
  defstruct [:topic, :event, :payload]
end

Emails are trickier, as they usually have a variable number of parameters that you want to include in your email template. For simplicity, let’s have:

# notifications/email.ex
defmodule Notifications.Email do
  defstruct [:type, :args]
end

Where args would hold a list of parameters we would expect to have for a specific type of email.

Creating and implementing the protocol

Let’s define our Notification protocol using the defprotocol macro:

# notifications.ex
defprotocol Notifications do
  @moduledoc """
  A protocol for dealing with the
  various forms of notifications.
  """ 
  
  @doc "Sends a notification."
  def send(notification)
end

To send a notification, we’ll need to call Notifications.send/1 with the notification as parameter.

To implement the protocol, we use another Elixir macro, defimpl:

# notifications/web_socket.ex
defmodule Notifications.WebSocket do
  defstruct [:topic, :event, :payload]
end

defimpl Notifications, for: Notifications.WebSocket do
  alias MyApp.Endpoint
  
  def send(n) do
    Endpoint.broadcast(n.topic, n.event, n.payload)
  end
end

Now if you pass a %Notifications.WebSocket{} struct to Notifications.send/1, it will know it needs to broadcast a WebSocket event:

%Notifications.WebSocket{topic: "user:1", event: "new_member", payload: %{name: "Emiko"}}
|> Notifications.send()

Pretty cool! We just told User:1 that a new team member called Emiko has joined the team! Now let’s extend our Notifications protocol so it knows how to deal with emails:

# notifications/email.ex
defmodule Notifications.Email do
  defstruct [:type, :args]
end

defimpl Notifications, for: Notifications.Email do
  alias MyApp.Mailer
  
  def send(%{type: "new_member", args: args}) do
    [recipient, new_member] = args
    Mailer.send_new_member_email(recipient, new_member)
  end
  
  def send(_), do: raise "Email type not implemented!"
end

Assuming that you have a module and a function called Mailer and send_new_member_email ready, which takes care of the email sending logic, the rest looks pretty familiar. The only difference is that here I’ve decided to patter-match against the different email types, since it gives us more flexibility, but you can also use Kernel.apply/3 if you’re happy just to pass the arguments to your email sending module.

Now we can just add the email notification to our list of notifications to send, and we’ll be good to go:

[
  %Notifications.WebSocket{...},
  %Notifications.Email{type: "new_member", args: [user, new_member]}
]
|> Enum.each(&Notification.send/1)

Since we’re just dealing with data, depending on user settings or other conditions, you can build up a list of notifications to be sent, before you pass them over to the send function. Once implemented, you can forget how the Notification sending actually works.

Extending Notifications

We recently introduced our own Heresy API and made it available to a limited number of customers. One of the things we were keen on implementing was a Webhooks API for the various types of system events that happen in Heresy.

All we had to do was define a new data struct and implement the Notifications protocol, and that’s it. We have something we could easily plug into our existing Notifications sending logic:

# notifications/webhook.ex
defmodule Notifications.Webhook do
  defstruct [:type, :user, :payload]
end

defimpl Notifications, for: Notifications.Webhook do

  def send(%{type: "new_deal"} = params) do
    # Fetch registered webhook for event, verify, etc...
    HTTPoison.post(webhook.url, params.payload, headers)
  end
  
  def send(_), do: raise "Webhook type not implemented!"
end

Later, we extended this to support Slack notifications as well, so overall this approached worked really well for us.

Conclusion

Protocols are a versatile tool in your Elixir arsenal. There’s a bunch of things I didn’t cover in this short article, like the ability to define fallback implementation for structs, which is also very neat. As usual, the official Elixir guide is a great source of information.

This work is licensed under the Creative Commons BY-SA 4.0 International License