Using the Protocol Pattern in Elixir to render Templates

ELIXIR PROTOCOLS

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.

Sample Renders

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 TypeFunction
BitStringbyte_size
Listlength
Mapmap_size
Tupletuple_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.
Let's go one step farther and add your voice to the conversation.
Your email is used to display your Gravatar and is never disclosed. As always, do review our moderation guidelines to keep the converstion friendly and respectful.