Nov 02, 2016
Table of contents:
An important part of writing high quality code is defining a public api to be used by consumers and collaborators This is particularly important when it comes to defining an explicit contract that should have many different implementations.
When you are using a module that defines an explicit contract, you can safely use the public API of the module in a generic way. This is a very important characteristic of code that you probably use every day without even thinking about it.
Explicit contracts are known as “behaviours” in Elixir. You could say that a module that implements an explicit contract has a certain “behaviour”.
In today’s tutorial we will be exploring behaviours to fully understand this very important concept.
“Explicit contract” is a bit of an ambiguous term unless you have a concrete understanding of what I’m talking about. So before we get into the nitty gritty details of behaviours in Elixir, lets first try to understand what are explicit contracts and why are they so important.
An explicit contract defines the public API of a module. This means you know what public functions are available, what arguments they accept and what you expect to be returned if you invoke them.
Once you understand the contract of a module you can confidently use the module in your day-to-day code. You are probably already very used to using certain modules and their functions without even thinking about it.
If you are coming from an object-oriented background, you might know this concept more familiarly as an interface.
So now you know what an explicit contract is, why is it so important?
The main benefit of defining a explicit contract is that you can use any implementation of that contract without knowing the concrete implementation. By relying on the contract of the public API, you can invoke the functions of the given module, without knowing what module you are actually working with.
This is very beneficial for a number of reasons.
Firstly, it makes it very easy to switch to a different implementation as your application grows. By relying on the contract, and not the implementation, you make it easy to switch out the implementation without totally dumping your code and starting again. For example, you might want to switch out your email provider because now that you are sending thousands of emails every day your currently provider is getting really expensive.
Secondly, it allows you to switch out the implementation at run time. By using an environment variable, you could switch out the real implementation for an in-memory implementation. This makes it easy to test the boundaries of your application without making calls to a third-party API, for example.
Thirdly, you can create new implementations for the contract without requiring permission of the person who originally wrote the contract. This is often important when you are using an Open Source package. It is sometimes difficult or out of the scope of the Open Source package to include your specific implementation, and so by using the explicit contract, you don’t need the permission of anyone else to just switch to using your implementation.
And finally, it makes it easy to create a pipeline of interchangeable modules that can be composed together. For example, accepting and responding to an HTTP request might require authentication, caching, or setting a specific header. By using modules that all conform to the same explicit contract, it makes it easy to add, remove, or replace any of these individual components without changing the structure of the pipeline.
Hopefully you now have a clear understanding of the what explicit contracts are and why they are so important. Let us now take a look at using explicit contracts in Elixir in the form of behaviours.
A behaviour is a module that defines the public API. The behaviour module defines “callbacks” that should be implemented by the module that is using that behaviour.
A behaviour callback looks like this:
@doc """
Save an item and return it's id
"""
@callback save(item :: Item.t()) :: {:ok, integer}
Here I’ve defined a save/1
function that accepts an Item
and returns a tuple of {:ok, integer}
.
With this callback defined, any module that uses the behaviour will need a public save/1
function that abides by this contract.
Now let us look at a behaviour module in full. For example, imagine we wanted a way to save items. We might require the following behaviour:
defmodule Widget.Store do
@moduledoc """
A behaviour module for defining a store
"""
alias Widget.Item
@doc """
Save an item and return it's id
"""
@callback save(item :: Item.t()) :: {:ok, integer}
@doc """
Find an item by it's id
"""
@callback find(integer) :: {:ok, Item.t()} | {:error, :not_found}
@doc """
Count the number of items
"""
@callback count :: integer
end
This is an example of an Elixir behaviour. In this example I’ve defined this behaviour as having three public functions and I’ve specified the arguments and the return types for each.
As you can see, this is a fairly generic example of storing items. I’ve defined the save/1
function that accepts an item and returns it’s id, a find/1
function that finds an existing item by it’s id, and a count/0
function that returns the total count of all of the items.
Now that we have the behaviour in place, we can create the first implementation to use it. In this example I’m going to create an ETS based behaviour (What is ETS in Elixir?).
First up we need to create a new module and use the @behaviour
annotation to let Elixir know that we are using the behaviour:
defmodule Widget.ETSStore do
@behaviour Widget.Store
end
I’m going to implement this using ETS and a GenServer
, so the next thing I will do will be to set up the process:
defmodule Widget.ETSStore do
@behaviour Widget.Store
alias Widget.Item
def start_link do
GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
end
def init(:ok) do
:ets.new(__MODULE__, [:ordered_set, :private, :named_table])
{:ok, :ok}
end
end
If you are familiar with GenServer in Elixir this should look pretty familiar. In the code above I’m creating a new ETS table that will be managed by the process.
Next up I will define the public API of this module, and therefore satisfy the explicit contract of the behaviour. If you are following along with this tutorial, before you add the next public functions, try running the following command in terminal:
mix(test)
You should see the compiler complaining that we are using the behaviour without implementing the contract. Let’s keep the compiler happy and do that now:
@spec save(Item.t()) :: {:ok, integer}
def save(item) do
GenServer.call(__MODULE__, {:save, item})
end
@spec find(integer) :: {:ok, Item.t()} | {:error, :not_found}
def find(id) do
GenServer.call(__MODULE__, {:find, id})
end
@spec count :: integer
def count do
GenServer.call(__MODULE__, {:count})
end
In the code section above I’ve implemented the public API functions using standard GenServer
calls.
Now that we have implemented the public functions, let’s now finish off this GenServer
by implementing the callback functions to make it all work:
def handle_call({:save, item}, _from, :ok) do
id = next_id
:ets.insert(__MODULE__, {id, item})
{:reply, {:ok, id}, :ok}
end
def handle_call({:find, id}, _from, :ok) do
reply =
case :ets.lookup(__MODULE__, id) do
[{^id, item}] -> {:ok, item}
[] -> {:error, :not_found}
end
{:reply, reply, :ok}
end
def handle_call({:count}, _from, :ok) do
{:reply, :ets.info(__MODULE__, :size), :ok}
end
defp next_id do
case :ets.last(__MODULE__) do
:"$end_of_table" -> 1
last -> last + 1
end
end
In this final code section I’ve added handle_call/3
callbacks for each of the three public functions of the module. I’ve also included a private function for generating the next id from the table. Each individual implementation can have any additional functions that are required for implementing the functionality of the behaviour. You are not limited to only using the public API functions.
Here is that module in full:
defmodule Widget.ETSStore do
@behaviour Widget.Store
alias Widget.Item
def start_link do
GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
end
def init(:ok) do
:ets.new(__MODULE__, [:ordered_set, :private, :named_table])
{:ok, :ok}
end
@spec save(Item.t()) :: {:ok, integer}
def save(item) do
GenServer.call(__MODULE__, {:save, item})
end
@spec find(integer) :: {:ok, Item.t()} | {:error, :not_found}
def find(id) do
GenServer.call(__MODULE__, {:find, id})
end
@spec count :: integer
def count do
GenServer.call(__MODULE__, {:count})
end
def handle_call({:save, item}, _from, :ok) do
id = next_id
:ets.insert(__MODULE__, {id, item})
{:reply, {:ok, id}, :ok}
end
def handle_call({:find, id}, _from, :ok) do
reply =
case :ets.lookup(__MODULE__, id) do
[{^id, item}] -> {:ok, item}
[] -> {:error, :not_found}
end
{:reply, reply, :ok}
end
def handle_call({:count}, _from, :ok) do
{:reply, :ets.info(__MODULE__, :size), :ok}
end
defp next_id do
case :ets.last(__MODULE__) do
:"$end_of_table" -> 1
last -> last + 1
end
end
end
And here is how you would use it:
{:ok, pid} = Widget.ETSStore.start_link()
item = %Widget.Item{value: "hello"}
Widget.ETSStore.save(item)
Widget.ETSStore.find(1)
Widget.ETSStore.find(2)
Widget.ETSStore.count()
In this example I’m using a simple ETS based store, but as you can imagine, it wouldn’t be difficult to swap the implementation backed by a different storage engine. Due to the fact that we are relying on the contract, and not the implementation, nothing in our code needs to change if we swap out the implementation for production or during testing.
In today’s tutorial we have looked at using behaviours in Elixir. If you are coming from another programming language, this concept is probably somewhat familiar to you. The good news is, this is an important characteristic of code in many different programming languages.
Defining an explicit contract is a good practice to get yourself into. By relying on contracts, and not implementations, it makes writing flexible a lot easier. This could be switching to a different provider, testing the boundaries of your application, or perhaps extending an Open Source package with an implementation that is specific to your project.