Elixir : Basics of Behaviours

Arunmuthuram M
11 min readJan 2, 2024

--

Behaviours in Elixir are a powerful tool that simply aids in establishing a contract between different modules. They provide modularity, consistency, scalability, interoperability and maintainability in code by enforcing a consistent, common set of function signatures across different modules that provide a single functionality with their own specific implementations. They provide abstraction of these specific implementations by using a common interface, making it easier to hide and reuse the common parts of the functionality. Even though both behaviours and protocols deal with contract, provide extensibility and modularity, the main difference between them is that, protocols enforce contracts on datatypes while behaviours enforce contracts on modules. Using behaviours in elixir involve two parts such as the behaviour module and the callback modules.

Behaviour module

The behaviour module is the module that defines and outlines the contract that must be implemented by the callback modules. The contract is just a set of different function signatures denoted by type specifications. The contract can consist of both functions and macros that the callback modules must implement. They are defined using @callback or @macrocallback followed by the type spec denoting the function/macro name, its arguments and their types and its return value type. Every callback module that implements this behaviour must provide concrete implementations for all the callbacks defined in the behaviour module, following the same signature provided in the callback type specs. Compile time warnings will be generated if a callback module fails to provide a required concrete implementation for a callback.

defmodule EgBehaviour do
@callback contract_func_one(num :: number()) :: boolean()
@callback contract_func_two(nums :: list(number())) :: integer()
@macrocallback contract_macro(arg :: any()) :: Macro.t()
end

Elixir also supports the @optional_callbacks module attribute which can take a keyword list as its value denoting a list of all the callback names and their arities. The callback modules need not provide concrete implementations for the optional callbacks if they are not required.

defmodule EgBehaviour do
@callback contract_func_one(num :: number()) :: boolean()
@callback contract_func_two(nums :: list(number())) :: integer()
@macrocallback contract_macro(arg :: any()) :: Macro.t()
@optional_callbacks [contract_func_two: 1, contract_macro: 1]
end

The behaviour module can also contain additional functions that provide common functionality to all of the callback modules. Unlike protocols where the dispatching logic is implicitly performed by Elixir to choose and execute the correct concrete implementation for an argument, behaviours do not provide any implicit dispatching logic. It is common for behaviour modules to contain functions as common entry points through which the user-provided dispatching logic is performed and the respective concrete implementation is executed. In these situations, especially when dealing with optional callbacks, in order to make sure that a callback module has implemented a callback function or a macro, the function_exported?/3 and the macro_exported?/3 functions can be used before calling. In addition to that, in every behaviour module, the function behaviour_info/1 will be generated with which callback information such as the function/macro names and their arities can be obtained.

EgBehaviour.behaviour_info(:callbacks)
["MACRO-contract_macro": 2, contract_func_two: 1, contract_func_one: 1]

EgBehaviour.behaviour_info(:optional_callbacks)
["MACRO-contract_macro": 2, contract_func_two: 1]

Callback modules

Callback modules are modules that implement a behaviour module. The callback modules provide concrete implementations for the contract specified by a behaviour module. The @behaviour module attribute followed by the name of the behaviour module is used at the top of a callback module in order to denote that this particular callback module implements the said behaviour module. Then the concrete implementations for all the required callbacks are created inside the callback module just like any other function or macro definition.

Elixir also provides the @impl module attribute that can be used above the contract callback implementations. Using the @impl attribute for callbacks provides clarity for readers about which functions are callbacks and which are not. They also produce compile time warnings if there is any mismatch in contract signatures’ arities. The @impl takes the value true or the name of the behaviour module to which the implemented contract belongs to. This is especially useful when the callback module implements more than one behaviour, providing clear information about which callback belongs to which behaviour. For callback implementations with multiple clauses, annotating only the first clause with the @impl attribute is enough. Please note that when the module attribute @impl is used for a callback module, it should be used properly for all callback implementations present in the callback module to avoid compile time warnings.

defmodule EgBehaviour do
@callback contract_func_one(num :: number()) :: boolean()
end
-------------------------------------------------------------------------
defmodule CallbackModule do
@behaviour EgBehaviour
end
-------------------------------------------------------------------------
warning: function contract_func_one/1 required by behaviour EgBehaviour
is not implemented (in module CallbackModule)
iex:11: CallbackModule (module)
defmodule EgBehaviour do
@callback contract_func_one(num :: number()) :: boolean()
end
-------------------------------------------------------------------------
defmodule CallbackModule do
@behaviour EgBehaviour

@impl true
def contract_func_one(num) do
#callback implementation
true
end
end
defmodule EgBehaviour do
@callback contract_func_one(num :: number()) :: boolean()
end
-------------------------------------------------------------------------
defmodule EgBehaviourOne do
@callback contract_func_two(num :: number()) :: boolean()
end
-------------------------------------------------------------------------
defmodule CallbackModule do
@behaviour EgBehaviour
@behaviour EgBehaviourOne

@impl EgBehaviour
def contract_func_one(num) do
#callback implementation
true
end

@impl EgBehaviourOne
def contract_func_two(num) do
#callback implementation
true
end
end

Let us now create some practical examples and see how behaviours can improve code in many ways. Let us try to create a simple notification system that provides functionality for sending message notifications to a user. Let us say that, for now, the requirement is that we have to send in-app notifications to users. Without using behaviours the code may look like this.

defmodule InAppNotifier do
@app_name "App1"
def send_in_app_notification(user_id, msg) do
device_id = get_user_device_id(user_id)
formatted_msg = format_msg(msg)
send_notification(device_id, formatted_msg)
end

def get_user_device_id(user_id) do
# query db, fetch and return device_id
end

def format_msg(msg, app_name \\ @app_name, current_time \\ DateTime.utc_now()) do
"#{app_name} - #{current_time}:: #{msg}"
end

def send_notification(device_id, formatted_msg) do
# in app notification sending logic
:ok
end
end
---------------------------------------------------------------------------
InAppNotifier.send_in_app_notification("user_1", "Notification test 1")

Now let us say that we need to add email notifications for the users. If we are not going to use behaviours then the code for email notifications would probably look like this.

defmodule EmailNotifier do
@app_name "App1"
def send_email_notification(user_id, msg) do
email_id = get_user_email_id(user_id)
formatted_msg = format_msg(msg)
send_notification(email_id, formatted_msg)
end

def get_user_email_id(user_id) do
# query db, fetch and return email id
end

def format_msg(msg, app_name \\ @app_name, current_time \\ DateTime.utc_now()) do
"#{app_name} - #{current_time}:: #{msg}"
end

def send_notification(email_id, formatted_msg) do
# EmailServiceA api calls to send email
:ok
end
end
---------------------------------------------------------------------------
EmailNotifier.send_email_notification("user_1", "Notification test 1")

As you can see the above code, both InAppNotifier and EmailNotifier are used for a single functionality i.e. sending notifications. Both have different implementations for sending notifications. Both have common code such as the format_msg/3 that has been repeated. Lets now try to restructure the code using behaviours.

defmodule Notifier do
@app_name "App1"

@callback get_notifier_id(user_id :: String.t) :: String.t
@callback send_notification(notifier_id :: String.t, msg :: String.t) :: :ok

def format_msg(msg, app_name \\ @app_name, current_time \\ DateTime.utc_now) do
"#{app_name} - #{current_time}:: #{msg}"
end

@spec send_notification(notifiers :: [module()], user_id :: String.t, msg :: String.t) :: :ok
def send_notification(notifiers, user_id, msg) do
formatted_msg = format_msg(msg)
Enum.each(notifiers, fn notifier ->
notifier.get_notifier_id(user_id) |> notifier.send_notification(formatted_msg)
end)
:ok
end
end
------------------------------------------------------------------------------------------------

defmodule InAppNotifier do
@behaviour Notifier

@impl true
def get_notifier_id(user_id) do
# query db, fetch and return device_id
end

@impl true
def send_notification(notifier_id, msg) do
# in app notification sending logic
:ok
end
end

-------------------------------------------------------------------------------------------------

defmodule EmailNotifier do
@behaviour Notifier

@impl true
def get_notifier_id(user_id) do
# query db, fetch and return email id
end

@impl true
def send_notification(notifier_id, msg) do
EmailServiceA.sendEmail(fromAddr, notifier_id, msg, ...)
:ok
end
end

--------------------------------------------------------------------------------------------------

Notifier.send_notification([InAppNotifier, EmailNotifier], "user_1", "Notification test 1")

By using a Notifier behaviour, we have abstracted away all the common functionality such as the format_msg/3 in one place and have defined callbacks for the functionality that are specific to each callback module. In our case, getting the id for the notifier such as device_id and email_id and the notification sending logic is specific to each callback module and hence we have defined callbacks for get_notifier_id/1 and send_notification/2.

Furthermore, since all the callback modules are forced to follow the same consistent contract, we have created a common entry point function Notifier.send_notification/2 in the behaviour module, through which the dispatching logic is handled. Even though the callback modules have different specific callback implementations, they can still be called from the common entry point as long as they share a consistent common contract. In the future if more notifiers are required to be added, e.g. a SmsNotifier, they can be added as callback modules for the Notifier behaviour and will be readily compatible with the Notifier.send_notification/2 function as long they implement the enforced contract callbacks inside them. Thus by using behaviours, you could add any number of notifier modules without modifying existing code, making your code modular, consistent, scalable and maintainable.
Behaviours also allow you to call the specific implementations directly such as EmailNotifier.send_notification(email, msg) instead of dispatching through the behaviour module, giving you more control.

Behaviours also let you swap implementations easily without requiring a lot of modifications in the existing code. Let us say that for the EmailNotifier you are using a specific external service EmailServiceA to send out emails. In the future if you have to switch to another service provider such as EmailServiceB to send out emails, in the current implementation we have to modify the EmailNotifier.send_notification/2 to replace the specific api calls to EmailServiceA with calls to EmailServiceB. We can avoid this code modification by using EmailNotifier as another behaviour as follows.

defmodule EmailNotifier do
@behaviour Notifier
@email_service EmailServiceA

@callback send_email(email_id :: String.t, msg :: String.t) :: :ok

@impl true
def get_notifier_id(user_id) do
# query db, fetch and return email id
end

@impl true
def send_notification(notifier_id, msg) do
@email_service.send_email(notifier_id, msg)
:ok
end
end

---------------------------------------------------------------------------

defmodule EmailServiceA do
@behaviour EmailNotifier

@impl true
def send_email(email_id, msg) do
# api calls to EmailServiceA to send email
end
end

As you can see the code above, the EmailNotifier that implements the Notifier behaviour is also made another behaviour that requires the callback contract send_email/2 to be implemented. The EmailServiceA callback module implements the EmailNotifier behaviour and provides concrete implementation for the send_email/2 callback with specific api calls to EmailServiceA. Inside the EmailNotifier behaviour module, we are using a module attribute @email_service and assigning the module EmailServiceA as its value. During runtime the EmailServiceA.send_email/2 will be called via the @email_service module attribute to send emails. With this newly structured code where we inject the email service dependency into the EmailNotifier, it is very easy now to swap the email service to EmailServiceB. All you have to do is create another module EmailServiceB that implements the EmailNotifier behaviour and replace the value of the @email_service module attribute with the new email service module.

defmodule EmailNotifier do
@behaviour Notifier
@email_service EmailServiceB

@callback send_email(email_id :: String.t, msg :: String.t) :: :ok

@impl true
def get_notifier_id(user_id) do
# query db, fetch and return email id
end

@impl true
def send_notification(notifier_id, msg) do
@email_service.send_email(notifier_id, msg)
:ok
end
end

---------------------------------------------------------------------------

defmodule EmailServiceA do
@behaviour EmailNotifier

@impl true
def send_email(email_id, msg) do
# api calls to EmailServiceA to send email
end
end

---------------------------------------------------------------------------

defmodule EmailServiceB do
@behaviour EmailNotifier

@impl true
def send_email(email_id, msg) do
# api calls to EmailServiceB to send email
end
end

Thus by using behaviours and a common consistent contract, we can inject dependencies, create low coupling between modules and swap dependencies seamlessly to create interoperability. This is a common practical use case of behaviours where different specific implementations such as database, cache and storage services etc. are swapped easily during runtime. Instead of using a module attribute to inject the dependencies, environmental variables and environment based configuration files can be employed to choose and inject the right specific implementations to use. This technique is also widely used in testing, where mock implementation modules will be created for behaviours and used instead of real external dependencies and services during testing.

Inbuilt behaviours

Elixir utilises behaviours internally for various use cases. Some of the in built elixir modules that expose behaviours are GenServer behaviour that can be used to build simple server-client functionality, Supervisor behaviour that can be used to monitor and manage child processes, Application behaviour that can be used to package code into applications that can be started, stopped and loaded, Calendar behaviour that be used to create custom calendar systems, Access behaviour that can be used for extending key based data access for data structures, Exception behaviour that can be used to create and manage custom exceptions, Module behaviour that can be used to generate runtime information about functions, macros and other info about the module, Config.Provider behaviour, DynamicSupervisor behaviour etc.

The above listed behaviours can be implemented for any user-defined callback module by going through and implementing the contract callbacks provided in the respective behaviour module’s documentation.

use and __using__

The use macro is used to inject code from one module into another module. It is widely used in behaviours such as GenServer to inject default implementations for callbacks into the callback module. In order to use the use macro to inject code, a macro called __using__ containing the code to inject must be defined in the source module, which will be called from the destination module during compilation. In order to implement GenServer behaviour, you can just add the line use GenServer in your callback modules. This will in turn expand to require the GenServer to use its macros and call GenServer.__using__() to inject code provided in the __using__ implementation of GenServer module.

defmodule Test do
use GenServer
end
#During compilation the above code will be expanded as below
defmodule Test do
require GenServer
GenServer.__using__()
end

It is also possible to pass in options as a keyword list to the __using__ implementation to generate options-based code in the destination module.

defmodule Test do
use GenServer, option: :test
end
#During compilation the above code will be expanded as below
defmodule Test do
require GenServer
GenServer.__using__([option: :test])
end

Let us try creating a behaviour with __using__ implementation and implement it in a callback module using the use macro.

defmodule EgBehaviour do
defmacro __using__(_opts) do
quote do
@behaviour EgBehaviour

@impl true
def contract_func_one(x, y) do
x + y
end
end
end

@callback contract_func_one(x :: number(), y :: number()) :: number()
end

---------------------------------------------------------------------------
defmodule CallBackModule do
use EgBehaviour
end
--------------------------------------------------------------------------
CallBackModule.contract_func_one(1, 2)
3
--------------------------------------------------------------------------
# After compilation and the EgBehaviour.__using__() call, the module will be
# expanded as follows

defmodule CallBackModule do
@behaviour EgBehaviour

@impl true
def contract_func_one(x, y) do
x + y
end
end

In the above code, we have injected @behaviour EgBehaviour and the implementation for contract_func_one/2 present within the quote block of the __using__ implementation into the call back module. But in most scenarios, we may need to override the default implementation provided by the behaviour module. In this case, if we do create a specific explicit implementation for the callback function, then the resulting code will contain both the injected implementation and the specific implementation for the callback function. Only the injected implementation will be executed since it will be injected before the explicit implementation.

defmodule CallBackModule do
use EgBehaviour

@impl true
def contract_func_one(x, y), do: x * y
end
--------------------------------------------------------------------------
CallBackModule.contract_func_one(1, 2)
3
--------------------------------------------------------------------------
# After compilation and the EgBehaviour.__using__() call, the module will be
# expanded as follows

defmodule CallBackModule do
@behaviour EgBehaviour

@impl true
def contract_func_one(x, y) do
x + y
end

@impl true
def contract_func_one(x, y), do: x * y
end

In order to completely override default implementations, elixir provides the defoverridable macro that enables completely overriding the default implementations provided within the __using__ implementation. The defoverridable macro takes in either a keyword list of the function names and their arities or the behaviour module as its value. If a behaviour module is provided as its value, all of the callback functions of the behaviour are automatically passed as values for the defoverridable macro. If you only want the default implementations of certain callbacks to be overridden, then their function names and arities can be manually passed into the defoverridable macro.

defmodule EgBehaviour do
defmacro __using__(_opts) do
quote do
@behaviour EgBehaviour

@impl true
def contract_func_one(x, y) do
x + y
end

defoverridable contract_func_one: 2 # defoverridable EgBehaviour
# is also valid in this case
end
end

@callback contract_func_one(x :: number(), y :: number()) :: number()
end

---------------------------------------------------------------------------
defmodule CallBackModule do
use EgBehaviour
# without overriding the default implementation
end
--------------------------------------------------------------------------
CallBackModule.contract_func_one(1, 2)
3
___________________________________________________________________________
---------------------------------------------------------------------------
defmodule CallBackModule do
use EgBehaviour

@impl true
def contract_func_one(x, y), do: x * y
end
--------------------------------------------------------------------------
CallBackModule.contract_func_one(1, 2)
2

While overriding a default implementation, the default implementation is still accessible inside the explicit implementation using super.

defmodule EgBehaviour do
defmacro __using__(_opts) do
quote do
@behaviour EgBehaviour

@impl true
def contract_func_one(x, y) do
x + y
end

defoverridable EgBehaviour
end
end

@callback contract_func_one(x :: number(), y :: number()) :: number()
end
---------------------------------------------------------------------------
defmodule CallBackModule do
use EgBehaviour

@impl true
def contract_func_one(x, y), do: 2 * super(x, y)
end
---------------------------------------------------------------------------
CallBackModule.contract_func_one(1, 2)
6

--

--

No responses yet