Implementing a flexible notifications system in Elixir using Protocols
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. Keep reading!
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 thedefprotocol
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 one of our notifications, we’ll need to call Notifications.send
with the notification as parameter, as promised.
To implement the protocol, we use yet another Elixir macro:
# notifications/web_socket.ex
defmodule Notifications.WebSocket do
defstruct [:topic, :event, :payload]
enddefimpl 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
, 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]
enddefimpl 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 calledMailer
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]
enddefimpl Notifications, for: Notifications.Webhook do def send(%{type: "new_deal"} = params) do
# Fetch registered webhook for event and
# lots of other logic omitted for brevity.
HTTPoison.post(webhook.url, params.payload, headers)
end
def send(_), do: raise "Webhook type not implemented!"
end
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, as well as the Elixir School section on Protocols (sadly buried in the scary Advanced section).
If you find this short tutorial useful and would like to see more content on Elixir — please share and hit the 👏 button. Thanks 🍻
—
I’m Svilen — a full-stack web developer and co-founder at Heresy. We’re always looking for engineers who enjoy working with the latest technologies and solving challenging problems. If you’re curious, check out jobs page!