Badges & Elixir: Using Behaviours

post image
Some of the badges available on Heresy

Published on: 16 November 2017

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

Who doesn’t love earning badges, even virtual ones? Sites like StackOverflow, Foursquare and Treehouse are all great examples of how badges can make your app much more engaging, personal, and fun.

Badges were one of the things we were keen on implementing at Heresy from the very beginning. We used Elixir’s Behaviours to build an internal Badge API and the first set of badges. Thanks to all the positive feedback—we have kept increasing the list of badges your can earn since then.

If you’re curious about Behaviours or perhaps interested in implementing something similar in your app—keep reading ✌️

Why use Behavours?

In a previously published article—Implementing a flexible notifications system in Elixir using Protocols—there was a brief mention of 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.

To put this into context: our Badges implementation should allow for a set of conditions to be met, before each badge is granted to the user. Since every badge has its own requirements, we need a standard way of checking if the user is eligible for a specific badge (or number of badges, even!) or not.

That’s where Behaviours come in. Each “badge” could have its own unique logic, organised within a module, but following a common specification. Let’s break it all down.

What’s in a badge

Let’s start with the main Badge module:

# badge.ex
defmodule Badge do
  
  defstruct type: nil, level: 0, eligible?: false

end

To make things easier, we define a %Badge{} struct with containing the following attributes: type (name of the badge), level (a badge could be granted 1 or more times), and a boolean flag :eligible?. The boolean flag would make granting badges easy:

# badge.ex
defmodule Badge do
  
  defstruct type: nil, level: 0, eligible?: false
  
  @doc "Awards user with given badge if eligible."
  @spec grant(%Badge{}, struct) :: struct | false
  def grant(%Badge{eligible?: false}, _), do: false

  def grant(badge, user) do
    params = %{"badges" => Map.new([{badge.type, badge.level}])}
    Account.update_user(user, params) # save to database
  end
end

The grant/1 function takes a badge struct, for example %Badge{type: "firestarter", level: 1, eligible?: true}. It also takes a user struct, which has a :badges field containing the awarded badges and their level. This means that we can construct parameters like %{"badges" => %{"firestarter" => 1}} and pass it to our Account.update_user/2. This would update the user and persist the Firestarter badge in the database.

Creating and adopting Behaviours

Creating a Behaviour is as easy as specifying the callback functions that all modules adopting it should implement. This way we ensure a common API among all badge implementations:

# badge.ex
defmodule Badge do
  
  defstruct type: nil, level: 0, eligible?: false
  
  @doc "Returns a badge with its eligibility status."
  @callback eligible?(%User{}) :: %Badge{}
  
  @doc "Checks the current badge level for the user"
  @callback find_level(%User{}) :: integer

  # rest of the code same as before ✂️

Using @callback you can specify not just the function name and arity, but also types—here %User{} is an Ecto user struct, but you can swap it with any type, including any. Now let’s create our first badge:

# badge/firestarter.ex
defmodule Badge.Firestarter do
  @moduledoc "Awarded when you invite a team member."
  @behaviour Badge
  
  def eligible?(user) do
    level = find_level(user)
    %Badge{type: "firestarter", level: level, eligible?: false}
  end
  
  def find_level(user) do
    0
  end
end

Mystery solved—we give the Firestarter badge to users who have invited someone to join their team. Unfortunately, the code above always returns the Badge as eligible?: false. Let’s fix this:

# badge/firestarter.ex
defmodule Badge.Firestarter do
  @moduledoc "Awarded when you invite a team member."
  @behaviour Badge
  
  def eligible?(user) do
    current = user.badges.firestarter
    level = find_level(user)
    eligible = if current >= level, do: false, else: true

    %Badge{type: "firestarter", level: level, eligible?: eligible}
  end
  
  def find_level(user) do
    invites_accepted = Account.invites_accepted_from(user)
    Enum.count(invites_accepted)
  end
end

You can assume that user.badges is a Map that has a key for every badge and a value with its current “level”. Calling user.badges.firestarter tells us how many times we have awarded that badge (initially 0).

The find_level/1 function will then go and check the database to see how many invites have been sent by this user and have been accepted. If five people have accepted your invite, then that’s 5x Firestarter badges!

Finally, we check the current vs expected level and determine whether the user deserves a shiny new badge (or two). And don’t forget to specify the @behaviour when adopting it!

We complete the logic by returning a %Badge{} struct with the badge name, expected level and eligibility status.

Putting it all together

Back to the badge.ex module: let’s implement a handy helper that would check the eligibility for groups of badges for us:

# badge.ex
defmodule Badge do
  
  # ... code same as before ✂️ ...
  
  @doc "Check if user is eligible for given badges."
  @spec check_badges(list, %User{}) :: list(%Badge{})
  def check_badges(badges, user) do
    Enum.map(badges, fn badge -> badge.eligible?(user) end)
  end
end

Which we can now use it like so:

iex> Badge.check_badges([Badge.Firestarter], user)

[%Badge{type: "firestarter", level: 1, eligible?: true}]

Nice—looks like this user is eligible for 1x Firestarter badge! Let’s grant it using the function we implemented before:

[Badge.Firestarter]
|> Badge.check_badges(user)
|> Enum.each(&(Badge.grant(&1, user))

And that’s it! We just implemented our first Badge and we can grant and upgrade it at any time if our users are eligible! 🎉

Adding more badges? Simply create a new badge module, adopt the behaviour and plug it in wherever you’re checking for badges 😎

Conclusion

Behaviours are a great pattern that’s widely applicable to a variety of use cases. Furthermore, it greatly simplifies and isolates your code, making it much more approachable by developers new to the code base.

Thanks for reading!

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