Real World Example with Elixir Protocol
Edison Yap
Posted on March 13, 2021
Today I'm going to write about Protocols in Elixir, and how I'm using them in my production app, Slick Inbox, a newsletter inbox app.
Protocol had always been a bit of a mystery to me, I've always been confused between it and behaviour, like when should I use one over another? I tried looking up resources on the web but I didn't manage to find much beyond the basics. I was interested in real world application, and learning how it could be used to apply to my situation. That's why I'm writing my journey here, perhaps someone could find this useful!
I was refactoring Slick Inbox, and one of the things I wanted to do is to identify my domains, group them up and enforce proper boundary/domain design. There's an excellent package, boundary in Elixir that can help with that, but the first step is for me to identify my domains first.
Phoenix ships with the concept of Contexts, which sort of nudges you in the right direction, but in the beginning things are pretty unclear so it is difficult to map out the domain upfront. Instead, I think it makes more sense to run your app in production and let the domain emerges itself.
Anyway, while I was doing that, I identified a few domains, and one of it is what I now call Messaging
. The responsibility of this domain is to communicate with users, and in Slick's current set-up, there are three ways to do that:
- Push Notification (when user receives a newsletter)
- Email (when I send user an email to their personal email address)
- Issue (when I send user an Issue in their Slick inbox)
The first two are self-explanatory. For the third one, Issue is a Slick entity. Slick is a newsletter inbox app, when an author sends users an email, it gets parsed into an Issue, which is then shown on the users' Inbox. One could argue that it's fundamentally the same thing as email, and one won't be too far off, but sending an Issue is a closed-loop action, there's no need to make a call to an Email Service Provider.
Anyway, now that I've established the three data type I want to be able to send
/deliver
to user, I thought this was a perfect use case for protocols. From what I know, protocols are meant for doing an action based on data type, which is exactly what I have right now. Protocols it is then!
I start off by describing what action I want. If you paid attention to my language earlier, you can see that I want to send/deliver
something. That's my action. With this in mind, I created a protocol called Courier
, which does one thing.
defprotocol Courier do
def deliver(data)
end
Now, the next thing is to implement the protocol.
For Push Notifications, I use the Pigeon library to interface with FCM. My initial implementation was pretty crude, I was passing data around, and the function signature looked something like this:
def notify(user, title, message) do
...
end
It looked fine to me at first, I am passing in the user
, then the title
and then the message
, but in reality there's a lot mangling with the parameters to fit into what Pigeon/FCM expect, and I figured with the protocol idea, why don't I create a new struct to hold this data? That's where I came up with Push
.
defmodule Push do
defstruct user: %User{}, body: %{}, data: %{}
end
I would also need to implement the protocol, so it ended up looking something like this:
defmodule Push do
defimpl Courier do
def deliver(%{user: user, body: body, data: data}) do
result =
user
|> fetch_tokens()
|> Enum.map(&do_send_push(&1, body, data))
{:ok, result}
end
end
end
With this, I implemented my Context APIs like this:
defmodule Messaging do
def send_push(user, issue) do
body = ...
data = ...
%Push{user: user, body: body, data: data}
|> Courier.deliver()
end
end
I only send push notifications when an issue arrives in their inbox, so I can just pass in the Issue directly here. Keep in mind though, my Protocol still knows nothing about Issue, it has the perfect shape of data it needs with %Push{}
to do its FCM magic.
I am pretty happy with this so far, and that's basically it really. The other two context APIs look pretty much the same, so there's not much to talk about:
defmodule Messaging do
def send_mail(user, template, assigns \\ []) do
%Mail{user: user, template: template, assigns: assigns}
|> Courier.deliver()
end
def send_issue(user, issue) do
Courier.deliver(issue)
end
end
Frankly, I still don't really have the heuristic to decide when to use protocols vs behaviour, but I hoped by sharing my experience it could maybe bring clarity to you.
Do you have any heuristics that you can share when to use protocols vs behaviours, or do you have resources you'd recommend about domain driven design (I'm especially interested in Phoenix code), I'd love if you could share them with me!
Posted on March 13, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.