At the heart of Cassava is a powerful rendering engine that leverages the protocol pattern in Elixir. Cassava uses this pattern to render unique content for each distribution channel that we serve.
Follow along to see how you can employ the protocol pattern to solve challenging problems you are likely to face when working with multiple data types that must honor the same contract.
The Protocol Pattern
A pattern in software programming is defined as a common solution to a recurring problem.
The Protocol Pattern in Elixir specifies an API that must be honored by its implementations.
This pattern comes into play when the nature of the implementation varies with the data type.
The Elixir documentation best describes a use case for computing the size
of a data type. Strings, List, Maps, and Tuples express their size in different ways.
Data Type | Function |
BitString | byte_size |
List | length |
Map | map_size |
Tuple | tuple_size |
Having an API support these four data types and additional ones can quickly become cumbersome. It would be better to expose a generic function that works with any data type, which is what protocols allow.
defprotocol Size do
def size(data)
end
defimpl Size, for: BitString do
def size(binary), do: byte_size(binary)
end
defimpl Size, for: List do
def size(list), do: length(list)
end
Here we have the protocol definition Size
with an appropriately named function that works on data of any type. The implementations for BitStrings and Lists are shown.
With this protocol in place, callers can request the size of a data type using a single unified function independent of the data type itself.
Next, we will examine how to apply this pattern in the real world.
Rendering Templates in Cassava
Cassava uses a rendering engine to generate content specific to a distribution channel.
Render = Template + Data
A render is a combination of a channel-specific template and data. Here we see that each templated channel is its own data type. All templates render their output as JSON.
Consequently, the protocol definition we use in Cassava takes the form:
defprotocol Cassava.Render do
def render(renderer, channel, attrs)
end
Each unique message type in Cassava is defined by a renderer that honors this protocol. For example, the Invitation
renderer is responsible for inviting collaborators to an Experience. A message is sent to the invitee via Slack or email.
defmodule CassavaAdmin.Renderer.Invitation do
alias CassavaAdmin.Renderer.Invitation, as: Invitation
alias CassavaAdmin.Templates.Invitation, as: Template
alias CassavaAdmin.Templates.Invitation.Data, as: Data
defstruct [:shared_experience_id]
@moduledoc ~S"""
Render an Invitation across all supported channels.
Returns a JSON representation as a list of maps.
"""
defimpl Cassava.Render do
@doc false
def render(
%Invitation{
shared_experience_id: _
},
:slack,
%{
accept_token: _,
decline_token: _
}
) do
# fetch the data and render the template
...
Template.render(:slack, data)
end
@doc false
def render(
%Invitation{
shared_experience_id: _
},
:email,
%{
accept_invitation_url: _,
decline_invitation_url: _
}
) do
# fetch the data and render the template
...
Template.render(:email, data)
end
end
end
The renderer knows how to produce the content appropriate for the channel. The renderer fetches the underlying data, passes it to a dedicated template, and returns a JSON representation to the caller. In the case of Slack, the JSON is composed of Block elements.
{
"blocks": [
{
"text": {
"text": "<@michael> has invited you to collaborate on the Experience:\n\n
*Compliance Training 101*",
"type": "mrkdwn"
},
"type": "section"
},
{
"text": {
"text": ">Welcome to our annual compliance training. New employees are
expected to complete this training within the first week ...",
"type": "mrkdwn"
},
"type": "section"
},
{
"block_id": "admin",
"elements": [
{
"style": "primary",
"text": {
"emoji": true,
"text": "Accept",
"type": "plain_text"
},
"type": "button",
"value": "RPRtAnrOqwXv"
},
{
"style": "danger",
"text": {
"emoji": true,
"text": "Decline",
"type": "plain_text"
},
"type": "button",
"value": "KDafEphEe7gi"
}
],
"type": "actions"
}
]
}
Dispatch to the Rescue
Renderers can be invoked as follows:
Invitation.render(%Invitation{}, :slack, %{})
But we can shorten this by using dispatching:
import Cassava, only: [render: 3]
%Invitation{}
|> render(:slack, %{})
defmodule Cassava do
def render(renderer, channel, attrs) do
render(renderer, channel, attrs)
end
end
The render function that is imported results in a dispatch to the protocol implementation, which allows us to shorten the command.
Summary
So there you have it. We have shown the value of using protocols in Elixir to save us from needless bloat when working with multiple data types that must honor a standard API.
Your feedback is welcomed as always.
Michael
You no doubt have an opinion bubbling to the surface.