Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Elixir & Erlang/OTP  Module 5 – Recursion, collections and data modelling

Behaviours and dynamic dispatch

Declare Elixir behaviours with @callback, implement them with @behaviour and @impl, choose the module at run time, and test with a double you write yourself.

  • Intermediate
  • 20 minutes
  • Examples run with elixir 1.20.4, erlang 29.1.1 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Declare a behaviour with @callback and @optional_callbacks, and implement it with @behaviour and @impl
  • Read the compiler's warnings for a missing callback, a wrong arity and an unmarked callback, and fix each
  • Call a module chosen while the program runs, passed as an argument or read from the application environment
  • Write a test double that implements a behaviour, and test code through it without sending anything
  • Choose between a protocol, a behaviour and a function argument for code that must vary

Before you start

What you’ll learn · Before you start

What you will learn

  • Declare a behaviour with @callback and @optional_callbacks, and implement it with @behaviour and @impl
  • Read the compiler's warnings for a missing callback, a wrong arity and an unmarked callback, and fix each
  • Call a module chosen while the program runs, passed as an argument or read from the application environment
  • Write a test double that implements a behaviour, and test code through it without sending anything
  • Choose between a protocol, a behaviour and a function argument for code that must vary

Before you start

On this page

A library sends due-date reminders by SMS today, and wants email and a phone app next year: three ways of doing one job that the code deciding whom to remind should not care about. A behaviour writes that job down as a list of functions, deliver/2 among them, that every notifier module promises to define, and the compiler warns when a module breaks the promise.

Below, you will declare the behaviour and implement it, read the warnings for the usual slips, choose the notifier while the program runs, test reminders without sending any, see that GenServer is a behaviour too, and choose between a behaviour, a protocol and a plain function. Every run below used Elixir 1.20.4 and Erlang/OTP 29.1.1.

A behaviour lists the functions a module promises to define

The typespecs reference puts it in one sentence: “A behaviour module defines a set of functions and macros (referred to as callbacks) that callback modules implementing that behaviour must export”. Each callback: A function that a behaviour declares with @callback and that each module adopting the behaviour defines, unless it is listed in @optional_callbacks; the code that uses the behaviour calls it back. A missing one gets a warning when the module compiles. is declared with @callback and a type specification. Notifier declares two, and makes the second optional with @optional_callbacks, since not every channel has a length limit:

notify/notifier.ex Elixir · notify/notifier.ex
defmodule Notifier do
  @moduledoc "Sends a short message to a member of the library."

  @doc "Delivers body to the member at the address to."
  @callback deliver(to :: String.t(), body :: String.t()) :: :ok | {:error, term()}

  @doc "The longest body this channel takes, in characters."
  @callback max_length() :: pos_integer()

  @optional_callbacks max_length: 0
end

A callback module: A module that adopts a behaviour with @behaviour and defines its callbacks, such as an SMS notifier for a Notifier behaviour, or your own module for GenServer. adopts the behaviour with @behaviour Notifier and defines the callbacks. SmsNotifier keeps the promise; EmailNotifier, written by someone who named the function after what it does, does not:

notify/sms.ex Elixir · notify/sms.ex
defmodule SmsNotifier do
  @behaviour Notifier

  @impl true
  def deliver(to, body) do
    IO.puts("SMS to #{to}: #{body}")
    :ok
  end

  @impl true
  def max_length, do: 160
end
notify/email.ex Elixir · notify/email.ex
# An email notifier whose author named the function after what it does.
defmodule EmailNotifier do
  @behaviour Notifier

  def send_mail(to, body) do
    IO.puts("Mail to #{to}: #{body}")
    :ok
  end
end
A behaviour, a module that keeps its promise and one that does not Python · callbacks_session.py
from session import run

# The behaviour and a module that keeps its promise; then a module that does not.
run("""
cd notify
elixirc -o out notifier.ex sms.ex
elixir -pa out -e 'IO.inspect(Notifier.behaviour_info(:callbacks)); IO.inspect(Notifier.behaviour_info(:optional_callbacks))'
elixir -pa out -e 'SmsNotifier.deliver("Asha", "Gora is due back in 3 days.")'
elixirc -o out_email notifier.ex email.ex
""")

Output

$ cd notify
$ elixirc -o out notifier.ex sms.ex
$ elixir -pa out -e 'IO.inspect(Notifier.behaviour_info(:callbacks)); IO.inspect(Notifier.behaviour_info(:optional_callbacks))'
[max_length: 0, deliver: 2]
[max_length: 0]
$ elixir -pa out -e 'SmsNotifier.deliver("Asha", "Gora is due back in 3 days.")'
SMS to Asha: Gora is due back in 3 days.
$ elixirc -o out_email notifier.ex email.ex
    warning: function deliver/2 required by behaviour Notifier is not implemented (in module EmailNotifier)
    │
  2 │ defmodule EmailNotifier do
    │ ~~~~~~~~~~~~~~~~~~~~~~~~~~
    │
    └─ email.ex:2: EmailNotifier (module)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 callbacks_session.py

@callback and @optional_callbacks built Notifier.behaviour_info/1, which listed both callbacks and the optional one. SmsNotifier.deliver/2 sent its message. Compiling email.ex printed “function deliver/2 required by behaviour Notifier is not implemented (in module EmailNotifier)”, and nothing about max_length/0, because it is optional. Step through the check:

Interactive · message by message How the compiler finds a callback that is missing Step through the compile of email.ex. The middle column is the compiler; the right one is the behaviour it checks against.
How the compiler finds a callback that is missing, step 5 of 5. Messages so far: EmailNotifier to The compiler: @behaviour Notifier; The compiler to Notifier: behaviour_info(:callbacks); Notifier to The compiler: deliver/2, max_length/0.EmailNotifierdefines send_mail/2The compilerwarning: deliver/2Notifierthe behaviour@behaviour Notifierbehaviour_info(:callbacks)deliver/2, max_length/0

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The module names its behaviour. EmailNotifier starts with @behaviour Notifier: a promise that it defines every callback Notifier declares.
  2. It asks for the callbacks. When EmailNotifier is compiled, the compiler asks Notifier for its callbacks, the list that @callback built into behaviour_info/1.
  3. Two callbacks, one optional. Notifier answers deliver/2 and max_length/0, and marks max_length/0 as optional, so only deliver/2 must be defined.
  4. deliver/2 is not there. EmailNotifier defines send_mail/2, a name no callback has, and no deliver/2. The compiler warns: "function deliver/2 required by behaviour Notifier is not implemented (in module EmailNotifier)".
  5. A warning, not an error. The module still compiles, so a call to EmailNotifier.deliver/2 would fail only when it runs. Treat the warning as an error, as the --warnings-as-errors option of mix compile does.

Key idea

A behaviour is a contract between modules: @callback lists what every callback module must define, and the compiler compares each module that says @behaviour with that list.

Checkpoint

Check: Callbacks and @behaviour

  1. Question 1 of 2 A module says @behaviour Notifier and defines only send_mail/2. What happens when it compiles?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It compiles, with the warning that deliver/2, required by Notifier, is not implemented

    @behaviour Notifier makes the compiler compare the module's functions with the callbacks Notifier declares, and every required one that is missing gets a warning naming it.

  2. Question 2 of 2 EmailNotifier defines neither deliver/2 nor max_length/0. Why was only deliver/2 named in the warning?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: max_length/0 is listed in @optional_callbacks

    @optional_callbacks max_length: 0 tells the compiler, and anyone reading behaviour_info/1, that a callback module may leave max_length/0 out; callers then check whether a module defines it before calling it.

@impl makes the compiler check each callback by name and arity

The missing-callback check cannot tell a callback written wrongly from a helper that happens to look like one. @impl true before a function says “this is a callback”, and the compiler checks the claim. The Module documentation lists the values: “You may pass either false, true, or a specific behaviour to @impl.” Three versions of EmailNotifier with one slip each, compiled one by one:

notify/: three versions of EmailNotifier

notify/email_arity.ex

# The callback's name is right, but it takes one argument instead of two.
defmodule EmailNotifier do
  @behaviour Notifier

  @impl true
  def deliver(message) do
    IO.puts("Mail: #{inspect(message)}")
    :ok
  end
end

notify/email_unmarked.ex

# One callback is marked with @impl, the other is not.
defmodule EmailNotifier do
  @behaviour Notifier

  @impl true
  def max_length, do: 5_000

  def deliver(to, body) do
    IO.puts("Mail to #{to}: #{body}")
    :ok
  end
end

notify/email_helper.ex

# @impl on a helper that no behaviour declares.
defmodule EmailNotifier do
  @behaviour Notifier

  @impl true
  def deliver(to, body) do
    IO.puts("Mail to #{to}: #{tidy(body)}")
    :ok
  end

  @impl true
  def tidy(body), do: String.trim(body)
end
Three slips, three warnings Python · impl_session.py
from session import run

# Three slips in EmailNotifier, each compiled with the behaviour on its own.
run("""
cd notify
elixirc -o out_arity notifier.ex email_arity.ex
elixirc -o out_unmarked notifier.ex email_unmarked.ex
elixirc -o out_helper notifier.ex email_helper.ex
""")

Output

$ cd notify
$ elixirc -o out_arity notifier.ex email_arity.ex
    warning: function deliver/2 required by behaviour Notifier is not implemented (in module EmailNotifier)
    │
  2 │ defmodule EmailNotifier do
    │ ~~~~~~~~~~~~~~~~~~~~~~~~~~
    │
    └─ email_arity.ex:2: EmailNotifier (module)

    warning: got "@impl true" for function deliver/1 but no behaviour specifies such callback. The known callbacks are:

      * Notifier.deliver/2 (function)
      * Notifier.max_length/0 (function)

    │
  6 │   def deliver(message) do
    │   ~~~~~~~~~~~~~~~~~~~~~~~
    │
    └─ email_arity.ex:6: EmailNotifier (module)
$ elixirc -o out_unmarked notifier.ex email_unmarked.ex
    warning: module attribute @impl was not set for function deliver/2 callback (specified in Notifier). This either means you forgot to add the "@impl true" annotation before the definition or that you are accidentally overriding this callback
    │
  8 │   def deliver(to, body) do
    │   ~~~~~~~~~~~~~~~~~~~~~~~~
    │
    └─ email_unmarked.ex:8: EmailNotifier (module)
$ elixirc -o out_helper notifier.ex email_helper.ex
    warning: got "@impl true" for function tidy/1 but no behaviour specifies such callback. The known callbacks are:

      * Notifier.deliver/2 (function)
      * Notifier.max_length/0 (function)

    │
 12 │   def tidy(body), do: String.trim(body)
    │   ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    │
    └─ email_helper.ex:12: EmailNotifier (module)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 impl_session.py

The slip What the compiler said
deliver(message) with @impl true, one argument too few deliver/2 is missing, and @impl true was given for deliver/1, which “no behaviour specifies”
@impl true on max_length/0 only “@impl was not set for function deliver/2 callback”
@impl true on a helper, tidy/1 “no behaviour specifies such callback”, with the list of known callbacks

The second warning comes from a rule the Module documentation states: “If you mark one function with @impl, you must mark all other callbacks for that behaviour as @impl.” So once a module uses @impl, every callback carries the mark and a function without it is not a callback, and a reader of the module sees the contract at a glance. @impl true also “automatically marks the function as @doc false”, because the behaviour documents its callbacks.

Name the behaviour when a module has two

A module can adopt several behaviours. @impl Notifier instead of @impl true checks the function against that one behaviour, and tells the reader where it comes from.

Checkpoint

Check: Checking callbacks with @impl

  1. Question 1 of 2 EmailNotifier defines deliver(message) with @impl true, and nothing else. Which warnings does compiling it print?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • That @impl true was given for deliver/1, which no behaviour specifies
    • That deliver/2, required by Notifier, is not implemented

    Without @impl, the compiler would only report the missing deliver/2. With @impl true it also points at the line that was meant to be the callback, which tells you what to fix.

  2. Question 2 of 2 @impl true is written before max_length/0 but not before deliver/2. What does the compiler say?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: That @impl was not set for the deliver/2 callback

    The Module documentation says that if you mark one function with @impl, you must mark all other callbacks for that behaviour, so a callback without it gets a warning.

A module is a value, so the caller can choose it

A module name such as SmsNotifier is an atom, so it can be passed around, stored and put in a variable, and notifier.deliver(name, body) calls deliver/2 on whichever module the variable holds. The typespecs reference says “Behaviours are useful because you can pass modules around as arguments”. That is dynamic dispatch: Calling a function on a module chosen while the program runs, as in notifier.deliver(to, body), where notifier holds a module name passed in as an argument or read from configuration., and Reminders.send_due/2 takes the module as an argument whose default reads the application environment:

notify/reminders.ex Elixir · notify/reminders.ex
defmodule Reminders do
  @body "Your book is due back in 3 days."

  # The notifier is an argument. When the caller gives none, the :library
  # application's environment decides, read each time the function runs.
  def send_due(names, notifier \\ notifier()) do
    for name <- names, do: {name, notifier.deliver(name, @body)}
  end

  defp notifier, do: Application.get_env(:library, :notifier, SmsNotifier)
end
One function, three ways of choosing the notifier, and a misspelt name Python · dispatch_session.py
from session import run

# One function, three ways of choosing the notifier; then a module name spelt wrong.
run("""
cd notify
elixirc -o out notifier.ex sms.ex log.ex reminders.ex
elixir -pa out -e 'IO.inspect(Reminders.send_due(["Asha", "Dev"]))'
elixir -pa out -e 'IO.inspect(Reminders.send_due(["Asha"], LogNotifier))'
elixir -pa out -e 'Application.put_env(:library, :notifier, LogNotifier); IO.inspect(Reminders.send_due(["Dev"]))'
elixir -e 'IO.inspect(is_atom(SmsNotifier)); IO.inspect(SmsNotifier == :"Elixir.SmsNotifier")'
elixirc -o out_typo typo.ex
!elixir -pa out -e 'Application.put_env(:library, :notifier, SmsNotifer); Reminders.send_due(["Asha"])'
""")

Output

$ cd notify
$ elixirc -o out notifier.ex sms.ex log.ex reminders.ex
$ elixir -pa out -e 'IO.inspect(Reminders.send_due(["Asha", "Dev"]))'
SMS to Asha: Your book is due back in 3 days.
SMS to Dev: Your book is due back in 3 days.
[{"Asha", :ok}, {"Dev", :ok}]
$ elixir -pa out -e 'IO.inspect(Reminders.send_due(["Asha"], LogNotifier))'
[log] Asha: Your book is due back in 3 days.
[{"Asha", :ok}]
$ elixir -pa out -e 'Application.put_env(:library, :notifier, LogNotifier); IO.inspect(Reminders.send_due(["Dev"]))'
[log] Dev: Your book is due back in 3 days.
[{"Dev", :ok}]
$ elixir -e 'IO.inspect(is_atom(SmsNotifier)); IO.inspect(SmsNotifier == :"Elixir.SmsNotifier")'
true
true
$ elixirc -o out_typo typo.ex
    warning: SmsNotifer.deliver/2 is undefined (module SmsNotifer is not available or is yet to be defined). Make sure the module name is correct and has been specified in full (or that an alias has been defined)
    │
  3 │   def direct(name), do: SmsNotifer.deliver(name, "hi")
    │                                    ~
    │
    └─ typo.ex:3:36: Typo.direct/1
    └─ typo.ex:7:14: Typo.through_a_variable/1
$ elixir -pa out -e 'Application.put_env(:library, :notifier, SmsNotifer); Reminders.send_due(["Asha"])'
** (UndefinedFunctionError) function SmsNotifer.deliver/2 is undefined (module SmsNotifer is not available). Make sure the module name is correct and has been specified in full (or that an alias has been defined)
    SmsNotifer.deliver("Asha", "Your book is due back in 3 days.")
    reminders.ex:7: anonymous fn/2 in Reminders.send_due/2
    (stdlib 8.1) erl_eval.erl:1048: :erl_eval.do_apply/7
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dispatch_session.py

Without an argument and with nothing configured, the default answered SmsNotifier. Given LogNotifier, the same line of code called LogNotifier.deliver/2. After Application.put_env/3 set the notifier, a call without an argument used it, because notifier/0 reads the environment each time it runs; the lesson on module attributes showed why a value read into an attribute would have stayed as it was when the module compiled. is_atom(SmsNotifier) was true, and the name equals the atom :"Elixir.SmsNotifier". Step through the calls:

Interactive · message by message Three ways Reminders.send_due/2 gets its notifier Step through the calls of the session. The right column is whichever module the call ends up in.
Three ways Reminders.send_due/2 gets its notifier, step 6 of 6. Messages so far: Your code to Reminders.send_due/2: send_due(["Asha", "Dev"]); Reminders.send_due/2 to The notifier module: deliver("Asha", …); Your code to Reminders.send_due/2: send_due(…, LogNotifier); Reminders.send_due/2 to The notifier module: deliver("Asha", …); Your code to Reminders.send_due/2: send_due(["Dev"]); Reminders.send_due/2 to The notifier module: SmsNotifer.deliver.Your codecalls send_dueReminders.send_due/2notifier \\ notifier()The notifier moduleSmsNotifer: no such modulesend_due(["Asha", "Dev"])deliver("Asha", …)send_due(…, LogNotifier)deliver("Asha", …)send_due(["Dev"])SmsNotifer.deliverUndefinedFunctionError

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. No notifier given. The first call passes only the names. The default argument calls notifier/0, which reads :notifier of the :library application while the call runs, and nothing is set.
  2. The default, SmsNotifier. Application.get_env/3 answers its third argument, SmsNotifier, and send_due/2 calls SmsNotifier.deliver/2 once for each name.
  3. The caller chooses. The second call passes LogNotifier as the argument, so the default is never read: the caller decides.
  4. Another module, the same call. notifier.deliver(name, body) is the same line of code; it now runs LogNotifier.deliver/2, because notifier holds a different module name.
  5. The configuration chooses. After Application.put_env(:library, :notifier, LogNotifier), a call without a notifier reads LogNotifier from the environment, because the value is read on every call, not when the module compiled.
  6. A misspelt name in config. With SmsNotifer, missing an i, set as the notifier, the call compiles and runs until notifier.deliver/2, then raises UndefinedFunctionError: the compiler cannot check a module name that arrives while the program runs.

The last two commands show what dynamic dispatch costs. A misspelt module written in the code, SmsNotifer, was found when typo.ex compiled, even through a variable, because the compiler could see the name. The same misspelt name set in the configuration was found only when a reminder was sent, as UndefinedFunctionError. Check configured module names when the application starts, so a bad one fails at once instead of at the first reminder.

Reading the configuration into a module attribute

@notifier Application.get_env(:library, :notifier, SmsNotifier) reads the environment once, while the module compiles, so a value set later, in a test or in the release’s runtime configuration, never reaches it. Read it in a function, as notifier/0 does.

Checkpoint

Check: Choosing the module while the code runs

  1. Question 1 of 2 Reminders.send_due(["Asha"]) is called with no notifier and nothing in the :library environment. Which module delivers?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: SmsNotifier, the default of Application.get_env/3

    The default argument runs on every call without a notifier, and reads the environment then, so its answer follows whatever the environment holds at that moment.

  2. Question 2 of 2 The configuration names SmsNotifer, missing an i, as the notifier. When is the misspelt name first reported?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: When a reminder is sent: notifier.deliver/2 raises UndefinedFunctionError

    Dynamic dispatch moves the choice of module, and so the check of its name, from compile time to run time. Check configured modules when the application starts, so a bad name fails at once.

A test double is one more implementation of the behaviour

Because send_due/2 calls whatever notifier it is given, a test can give it one that sends nothing. A test double: A module that stands in for a real one in tests: it implements the same behaviour but records or fakes what it does, such as a notifier that sends each message to the test process instead of delivering it. here is a module that adopts Notifier and, instead of delivering, tells the test what it was asked to deliver. TestNotifier sends a message to self(): it runs in the process that called it, the test’s own, so the message lands in the test’s mailbox:

reminders_test.exs Elixir · reminders_test.exs
Code.require_file("notify/notifier.ex", __DIR__)
Code.require_file("notify/reminders.ex", __DIR__)
ExUnit.start(seed: 0)

# A notifier for tests: it delivers nothing, and tells the test process what it
# was asked to deliver. It runs in the process that calls it, here the test's.
defmodule TestNotifier do
  @behaviour Notifier

  @impl true
  def deliver(to, body) do
    send(self(), {:delivered, to, body})
    :ok
  end
end

# A notifier whose channel is down.
defmodule DownNotifier do
  @behaviour Notifier

  @impl true
  def deliver(_to, _body), do: {:error, :unreachable}
end

defmodule RemindersTest do
  use ExUnit.Case, async: true

  test "one reminder for each name, with the due-date message" do
    Reminders.send_due(["Asha", "Dev"], TestNotifier)
    assert_received {:delivered, "Asha", "Your book is due back in 3 days."}
    assert_received {:delivered, "Dev", _body}
    refute_received {:delivered, _, _}
  end

  test "each name comes back with what its notifier answered" do
    assert Reminders.send_due(["Asha"], DownNotifier) == [{"Asha", {:error, :unreachable}}]
  end
end
Two tests of Reminders, with doubles instead of SMS Python · doubles_session.py
from session import run

run("""
elixir reminders_test.exs
""")

Output

$ elixir reminders_test.exs
Running ExUnit with seed: 0, max_cases: 36

..
Finished in 0.5 seconds (0.4s on load, 0.02s async, 0.00s sync)

Result: 2 passed

This output changes from run to run: ExUnit prints how long the tests took, and max_cases follows the computer's processors

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 doubles_session.py

Both tests passed, and no reminder went anywhere. The first checked that each name got exactly one reminder, with assert_received for each and refute_received for any third; the second used a double whose channel is down and checked that the error came back with the name. Step through the first test:

Interactive · message by message A test double that reports to the test Step through the first test. TestNotifier runs inside the test process, so the message it sends lands in the test's own mailbox.
A test double that reports to the test, step 5 of 5. Messages so far: The test to Reminders.send_due/2: send_due(…, TestNotifier); Reminders.send_due/2 to TestNotifier: deliver("Asha", …); TestNotifier to The test: {:delivered, "Asha", …}; Reminders.send_due/2 to TestNotifier: deliver("Dev", …).The testmailbox: empty againReminders.send_due/2two namesTestNotifierdelivers nothingsend_due(…, TestNotifier)deliver("Asha", …){:delivered, "Asha", …}deliver("Dev", …)

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The test passes the double. The test calls Reminders.send_due(["Asha", "Dev"], TestNotifier): the notifier is an argument, so nothing global changes.
  2. Asha. send_due/2 calls TestNotifier.deliver("Asha", body), exactly as it would call the SMS notifier.
  3. A message instead of an SMS. TestNotifier sends {:delivered, "Asha", body} to self(). It runs in the process that called it, the test process, so the message goes to the test's mailbox, and it answers :ok.
  4. Dev. The same for the second name: one more call, one more message.
  5. The test checks its mailbox. assert_received takes the message for Asha with the due-date text, then the one for Dev, and refute_received finds no third: exactly one reminder for each name.

The module goes in as an argument, so each test chooses its own and async: true is safe. Setting the notifier with Application.put_env/3 instead would change the environment for every test running at the same moment, and the ExUnit documentation says async “should be enabled only if tests do not change any global state”. Mox, a third-party library on Hex, builds such doubles from a behaviour for you; its documentation says “You can only create mocks based on behaviours”.

Checkpoint

Check: Test doubles

  1. Question 1 of 2 TestNotifier.deliver/2 calls send(self(), {:delivered, to, body}). Why does assert_received in the test see that message?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The double runs in the process that called it, so self() is the test process

    A function runs in the process that calls it. The test calls send_due/2, which calls the double, so self() inside the double is the test process.

  2. Question 2 of 2 Two test modules with async: true each set their own double with Application.put_env(:library, :notifier, …) in setup. What can go wrong?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The environment is shared, so one module's setup can change the double the other module's tests use

    ExUnit says async should be enabled only if tests do not change any global state. Passing the double as an argument keeps each test's choice its own.

GenServer is a behaviour, and use GenServer adopts it

The behaviour: The generic part of a kind of process, such as gen_server or supervisor, written once in OTP. Your callback module supplies only the specific part. More in “The BEAM, Elixir and Erlang/OTP explained” of OTP use exactly this mechanism. GenServer is generic code for a server process, and your module supplies the callbacks; GenServer.behaviour_info/1 lists them:

server/callbacks.exs Elixir · server/callbacks.exs
# GenServer is a behaviour: its callbacks, the optional ones, and what is left.
callbacks = GenServer.behaviour_info(:callbacks)
optional = GenServer.behaviour_info(:optional_callbacks)
IO.inspect(Enum.sort(callbacks), label: "callbacks")
IO.inspect(Enum.sort(callbacks -- optional), label: "required")
server/counter.ex Elixir · server/counter.ex
# A GenServer callback module without init/1.
defmodule Counter do
  use GenServer

  @impl true
  def handle_call(:get, _from, count), do: {:reply, count, count}
end
GenServer's callbacks, and a server without init/1 Python · genserver_session.py
from session import run

# The callbacks of GenServer, then a callback module that leaves out the required one.
run("""
cd server
elixir callbacks.exs
elixirc -o out counter.ex
elixir -pa out -e 'IO.inspect(Counter.__info__(:attributes)[:behaviour], label: "behaviour")'
""")

Output

$ cd server
$ elixir callbacks.exs
callbacks: [
  code_change: 3,
  format_status: 1,
  format_status: 2,
  handle_call: 3,
  handle_cast: 2,
  handle_continue: 2,
  handle_info: 2,
  init: 1,
  terminate: 2
]
required: [init: 1]
$ elixirc -o out counter.ex
    warning: function init/1 required by behaviour GenServer is not implemented (in module Counter).

    We will inject a default implementation for now:

        def init(init_arg) do
          {:ok, init_arg}
        end

    You can copy the implementation above or define your own that converts the arguments given to GenServer.start_link/3 to the server state.

    │
  2 │ defmodule Counter do
    │ ~~~~~~~~~~~~~~~~~~~~
    │
    └─ counter.ex:2: Counter (module)
$ elixir -pa out -e 'IO.inspect(Counter.__info__(:attributes)[:behaviour], label: "behaviour")'
behaviour: [GenServer]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 genserver_session.py

behaviour_info/1 listed nine callbacks, and all but init/1 are optional. The GenServer documentation counts eight, because format_status/2 is deprecated in favour of format_status/1:

Callback What it handles
init/1, the only required one the start: it answers the server’s first state
handle_call/3 a request that waits for a reply
handle_cast/2 a request with no reply
handle_info/2 any other message
handle_continue/2 work to do right after another callback
terminate/2 the server stopping
code_change/3 new code loaded while the server runs
format_status/1, and the deprecated format_status/2 what status and error reports show of the state

use GenServer wrote @behaviour GenServer into Counter, as its attributes showed, so the compiler checked Counter as it checked EmailNotifier; the GenServer documentation adds that it also defines child_spec/1, so the module “can be used as a child in a supervision tree”. Counter has no init/1, and the warning came with a default that Elixir injected for now, {:ok, init_arg}, and the advice to write one.

Because almost every GenServer callback is optional, @impl true matters more here than for Notifier. A server whose handle_call/3 is misspelt handle_cal/3 gets no missing-callback warning, since handle_call/3 may be left out, and the GenServer documentation says such a server “will fail if a call is performed against it”. With @impl true before it, the compiler reports the name at once, as it reported tidy/1. The OTP module of this track builds servers on these callbacks.

Checkpoint

Check: OTP behaviours

  1. One question Which callback must a GenServer callback module define?

    Choose one answer.

    Show the answer: One question

    Answer: init/1

    GenServer.behaviour_info/1 listed nine callbacks with eight of them optional. Counter, without init/1, got the missing-callback warning, and a default was injected for now.

Choose by what decides the code: the value, the caller or the configuration

A protocol, a behaviour and a plain function all let one call run different code; they differ in who decides. The design anti-patterns guide adds a rule for libraries: the application environment “is a global state”, so options belong in arguments, and configuration fits only to swap a component that “must behave the exact same”, which “library authors can even enforce” with a behaviour. Answer the questions about the code you need to vary:

Interactive · answer and compare A protocol, a behaviour or a function? Answer the questions about the code that must vary. The table marks the ways that fit, and why.
What decides which code runs?
How many functions vary together?
Must tests swap the code while running at the same time?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Five ways to let one call run different code, against four things that decide between them
Way The value's type decides Others add new kinds Several functions vary Tests swap it in parallel
A protocol Looks like: Weight.grams(item) Good dispatches on the first argument, as Weight.grams/1 did Good anyone writes a defimpl, in their own code Good a protocol may declare several functions Mixed a defimpl in a test file had no effect once consolidated
A behaviour, module passed in Looks like: send_due(names, LogNotifier) Poor the caller decides, whatever the value is Good a new module with @behaviour Notifier Good deliver/2 and max_length/0 are checked together Good each test passes its own double, as RemindersTest did
A behaviour, module from config Looks like: Application.get_env(:library, :notifier) Poor the deployment decides, for every call Good a new module, named in the configuration Good the same callbacks, checked at compile time Poor put_env changes global state that all tests share
A function passed in Looks like: Enum.map(list, &format/1) Poor the caller decides Good any function of the right arity Poor one function per argument Good each test passes its own function
Clauses for each type Looks like: def grams(%Parcel{} = p), do: … Good a clause for each type, as describe/1 had Poor only the module's owner can add a clause Mixed every function repeats the list of types Mixed tests can only use the types the code knows
How the answers decide
  • “What decides which code runs?” The type of the value: rules out A behaviour, module passed in, A behaviour, module from config and A function passed in and adds a caveat to Clauses for each type. When the value's type decides, a protocol dispatches on it; clauses work too while one module owns every type.
  • “What decides which code runs?” The caller or the deployment: rules out A protocol and Clauses for each type. A protocol and clauses look at the value; when the caller or the deployment decides, pass a module or a function.
  • “How many functions vary together?” Just one: adds a caveat to A behaviour, module passed in and A behaviour, module from config. For one function, passing the function itself is lighter than a module with a behaviour.
  • “How many functions vary together?” Several: rules out A function passed in. Several functions that vary together belong in one module, and a behaviour checks that each module has them all.
  • “Must tests swap the code while running at the same time?” Yes, async tests: rules out A behaviour, module from config. The application environment is global, and ExUnit asks for async only when tests change no global state.

In short: a protocol when the type of the value decides, as Weight.grams/1 and Enumerable do; a behaviour when the caller or the deployment decides among modules that each provide several functions, passed as an argument wherever tests must run at the same time; and a plain function argument when only one function varies, as with Enum.map/2.

Checkpoint

Check: Protocol, behaviour or function

  1. Question 1 of 2 The weight of an item depends on whether it is a parcel, an envelope or a list. Which fits?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A protocol

    When the value's type decides, let the value choose: a protocol, as Weight.grams/1 is.

  2. Question 2 of 2 A report formats each row, and callers want to choose the format. Only that one function varies. Which fits best?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A function passed as an argument, such as &format/1

    A behaviour pays off when several functions vary together; for one, a function argument says the same with less.

Practise: a cache with two stores

A cache keeps computed values in a store, and the store may be memory or a folder on disk. The Storage behaviour is written, with put/3 and get/2; MemoryStorage keeps a map, DiskStorage writes one file for each key, and Cache.fetch/4 takes the adapter as an argument. Copy the two files into an empty folder:

practice/storage.ex: the starter Elixir · practice/storage.ex
# A cache that can keep its values in memory or on disk. The Storage behaviour is
# written; finish the two adapters and Cache.fetch/4.
defmodule Storage do
  @doc "Stores value under key in store, and returns the store to use next."
  @callback put(store :: term(), key :: String.t(), value :: binary()) :: term()

  @doc "The value stored under key, or :error when there is none."
  @callback get(store :: term(), key :: String.t()) :: {:ok, binary()} | :error
end

defmodule MemoryStorage do
  # The store is a map from keys to values.
  @behaviour Storage

  @impl true
  def put(store, _key, _value), do: store

  @impl true
  def get(_store, _key), do: :error
end

defmodule DiskStorage do
  # The store is the path of a folder; each value is a file named after its key.
  @behaviour Storage

  @impl true
  def put(dir, _key, _value), do: dir

  @impl true
  def get(_dir, _key), do: :error
end

defmodule Cache do
  # The value under key in store, through adapter: {value, store}. When there is
  # none, compute.() makes it, the adapter stores it, and the new store comes back.
  def fetch(_adapter, store, _key, compute), do: {compute.(), store}
end
practice/storage_test.exs: the tests Elixir · practice/storage_test.exs
Code.require_file("storage.ex", __DIR__)
ExUnit.start(seed: 0)

defmodule StorageTest do
  use ExUnit.Case, async: true
  # Each test gets an empty folder of its own, under tmp/ beside this file.
  @moduletag :tmp_dir

  # A compute function that tells the test process each time it runs.
  defp compute do
    fn ->
      send(self(), :computed)
      "Premchand"
    end
  end

  test "memory: a value comes back under its key" do
    store = MemoryStorage.put(%{}, "gora", "Rabindranath Tagore")
    assert MemoryStorage.get(store, "gora") == {:ok, "Rabindranath Tagore"}
    assert MemoryStorage.get(store, "godaan") == :error
  end

  test "disk: a value comes back under its key", %{tmp_dir: dir} do
    assert DiskStorage.put(dir, "gora", "Rabindranath Tagore") == dir
    assert DiskStorage.get(dir, "gora") == {:ok, "Rabindranath Tagore"}
    assert DiskStorage.get(dir, "godaan") == :error
  end

  test "fetch through memory computes the value once" do
    {"Premchand", store} = Cache.fetch(MemoryStorage, %{}, "godaan", compute())
    {"Premchand", _store} = Cache.fetch(MemoryStorage, store, "godaan", compute())
    assert_received :computed
    refute_received :computed
  end

  test "fetch through disk computes the value once", %{tmp_dir: dir} do
    {"Premchand", ^dir} = Cache.fetch(DiskStorage, dir, "godaan", compute())
    {"Premchand", ^dir} = Cache.fetch(DiskStorage, dir, "godaan", compute())
    assert_received :computed
    refute_received :computed
  end
end

Run elixir storage_test.exs. The starter’s placeholders store nothing and compute every time, so all four tests fail:

The tests against the starter Python · practice_session.py
from session import run

# The tests against the starter: the adapters have no functions yet.
run("""
cd practice
!elixir storage_test.exs
""")

Output

$ cd practice
$ elixir storage_test.exs
Running ExUnit with seed: 0, max_cases: 36



  1) test memory: a value comes back under its key (StorageTest)
     storage_test.exs:17
     Assertion with == failed
     code:  assert MemoryStorage.get(store, "gora") == {:ok, "Rabindranath Tagore"}
     left:  :error
     right: {:ok, "Rabindranath Tagore"}
     stacktrace:
       storage_test.exs:19: (test)



  2) test disk: a value comes back under its key (StorageTest)
     storage_test.exs:23
     Assertion with == failed
     code:  assert DiskStorage.get(dir, "gora") == {:ok, "Rabindranath Tagore"}
     left:  :error
     right: {:ok, "Rabindranath Tagore"}
     stacktrace:
       storage_test.exs:25: (test)



  3) test fetch through memory computes the value once (StorageTest)
     storage_test.exs:29
     Unexpectedly received message :computed (which matched :computed)
     code: refute_received :computed
     stacktrace:
       storage_test.exs:33: (test)



  4) test fetch through disk computes the value once (StorageTest)
     storage_test.exs:36
     Unexpectedly received message :computed (which matched :computed)
     code: refute_received :computed
     stacktrace:
       storage_test.exs:40: (test)


Finished in 0.6 seconds (0.2s on load, 0.4s async, 0.00s sync)

Result: 0/4 passed
Failed: 4 tests
[exit status 2]

This output changes from run to run: ExUnit prints how long the tests took, and max_cases follows the computer's processors

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py

MemoryStorage needs two calls of the Map module, one to put and one to fetch. DiskStorage writes the value to Path.join(dir, key) and reads it back, turning a missing file into :error. Cache.fetch/4 asks adapter.get/2 first; on :error it calls compute.(), stores the value with adapter.put/3 and returns it with the new store. The same fetch/4 then works with both adapters, and with any third one that keeps the promise. A finished version passes all four:

The tests against finished adapters Python · finished_session.py
from session import run

# The same tests against finished adapters and Cache.fetch/4.
run("""
cd finished
cp ../practice/storage_test.exs .
elixir storage_test.exs
""")

Output

$ cd finished
$ cp ../practice/storage_test.exs .
$ elixir storage_test.exs
Running ExUnit with seed: 0, max_cases: 36

....
Finished in 0.6 seconds (0.3s on load, 0.3s async, 0.00s sync)

Result: 4 passed

This output changes from run to run: ExUnit prints how long the tests took, and max_cases follows the computer's processors

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 finished_session.py

Then work through the self-check: it starts from the idea your answers show you know least, asks one question at a time, and explains each choice as soon as you make it:

Practice that adapts to you

Practise this lesson

14 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 14 What does @callback deliver(to :: String.t(), body :: String.t()) :: :ok | {:error, term()} declare?

    Choose one answer.

    Show the answer: Question 1 of 14

    Answer: A function that every module adopting the behaviour must define, with its types

    @callback writes one line of the contract. The behaviour module collects them into behaviour_info/1, and the compiler checks every module that adopts it.

  2. Question 2 of 14 What did Notifier.behaviour_info(:callbacks) return?

    Choose one answer.

    Show the answer: Question 2 of 14

    Answer: [max_length: 0, deliver: 2]

    @callback and @optional_callbacks build behaviour_info/1 on the behaviour module: :callbacks lists every callback, and :optional_callbacks the ones that may be left out.

  3. Question 3 of 14 What does @impl true before a function tell the compiler?

    Choose one answer.

    Show the answer: Question 3 of 14

    Answer: That the function is a callback of one of the module's behaviours, which the compiler then checks

    @impl turns an intention into something the compiler can check: a callback with the wrong name or arity, or a helper marked as a callback, is reported where it is written.

  4. Question 4 of 14 What does @impl true do to the documentation of the function?

    Choose one answer.

    Show the answer: Question 4 of 14

    Answer: It marks it @doc false, unless @doc is set explicitly

    Callbacks are documented once, on the behaviour, so the callback modules do not repeat them; set @doc on a callback only when its implementation needs words of its own.

  5. Question 5 of 14 A module adopts two behaviours. Why write @impl Notifier rather than @impl true before its deliver/2?

    Choose one answer.

    Show the answer: Question 5 of 14

    Answer: The compiler then checks deliver/2 against Notifier alone, and readers see where the callback comes from

    With several behaviours, naming the one a callback belongs to keeps the check exact and the module readable.

  6. Question 6 of 14 What kind of value is SmsNotifier?

    Choose one answer.

    Show the answer: Question 6 of 14

    Answer: An atom, :"Elixir.SmsNotifier"

    Because a module name is an atom, it can be an argument, a default or a value in the configuration, and notifier.deliver(to, body) calls whichever module it names.

  7. Question 7 of 14 @notifier Application.get_env(:library, :notifier, SmsNotifier) is in a module, and a test then calls Application.put_env(:library, :notifier, LogNotifier). Which module does @notifier give?

    Choose one answer.

    Show the answer: Question 7 of 14

    Answer: SmsNotifier, read once while the module compiled

    Read configuration in a function that runs on each call, as notifier/0 does, so that tests and runtime configuration can change it.

  8. Question 8 of 14 SmsNotifer.deliver(name, "hi"), missing an i, is written in a module. What happens when it compiles?

    Choose one answer.

    Show the answer: Question 8 of 14

    Answer: A warning that SmsNotifer.deliver/2 is undefined, because the module is not available

    Written in the code, a module name can be checked when the code compiles; arriving while the program runs, it cannot. That is the cost of choosing the module at run time.

  9. Question 9 of 14 What lets Reminders.send_due/2 use TestNotifier where it uses SmsNotifier in production?

    Choose one answer.

    Show the answer: Question 9 of 14

    Answer: Both implement the Notifier behaviour, so both have deliver/2

    A test double is one more callback module. Code that depends only on the behaviour can be given any of them.

  10. Question 10 of 14 What does Reminders.send_due(["Asha"], DownNotifier) return, when DownNotifier.deliver/2 answers {:error, :unreachable}?

    Choose one answer.

    Show the answer: Question 10 of 14

    Answer: [{"Asha", {:error, :unreachable}}]

    A double can answer anything a real notifier might, which makes failures easy to test without breaking a real channel.

  11. Question 11 of 14 What did use GenServer write into Counter, as its attributes showed?

    Choose one answer.

    Show the answer: Question 11 of 14

    Answer: @behaviour GenServer

    use GenServer adopts the GenServer behaviour, so the compiler checks your module's callbacks as it checked the notifiers'.

  12. Question 12 of 14 Counter uses GenServer and defines handle_call/3 but no init/1. What did compiling it print?

    Choose one answer.

    Show the answer: Question 12 of 14

    Answer: A warning that init/1 is required, with the default that Elixir injected for now

    GenServer is a behaviour like Notifier: a missing required callback is a warning, here with a stop-gap default until you write init/1.

  13. Question 13 of 14 A library parses CSV with one parser by default and lets the applications that use it swap in another parser that must behave the same. What does the anti-patterns guide accept here?

    Choose one answer.

    Show the answer: Question 13 of 14

    Answer: Choosing the parser through the application environment, with a behaviour that fixes what every parser must do

    For options the guide asks for arguments; for swapping a component that must behave the same, configuration is fine, and a behaviour makes the expected behaviour checkable.

  14. Question 14 of 14 A notifier must be swapped for a double in tests that run with async: true, and also chosen by the deployment in production. How do you design it?

    Choose one answer.

    Show the answer: Question 14 of 14

    Answer: An argument whose default reads the application environment, as send_due/2 has

    Taking the module as an argument, with a default read from the configuration at run time, serves both: the deployment decides by default, and a test decides for itself.

Interview questions

Warm-up: what is the difference between a protocol and a behaviour in Elixir?

Both let one call run different code, but they choose it differently. A protocol dispatches on data: Weight.grams(item) runs the implementation for the type of item, so a new type joins by implementing the protocol, anywhere. A behaviour is a contract for modules: @callback lists the functions a module promises to define, the compiler warns when one is missing, and the caller picks the module, through an argument or the configuration, and calls notifier.deliver(to, body). So a protocol fits when the kind of value decides, as for Enumerable or String.Chars; a behaviour fits when the caller or the deployment decides, as for a GenServer’s callback module or an adapter that tests replace with a double.

Two questions from code that swaps its parts:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    Two test modules each put their own notifier double into the application environment with Application.put_env/3 in setup, and both run with async: true. Each passes alone; together they fail now and then, with messages arriving at the wrong test. Explain, and redesign the code so the tests can stay concurrent.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    Your Notifier behaviour has five implementations in three applications. You need a new callback, cancel/1, that only two of them can support. How do you add it without breaking anyone's build, and how does the calling code handle notifiers that lack it?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A behaviour lists its callbacks with @callback, and every module that says @behaviour is checked against the list when it compiles, with optional callbacks left out of the check.
  • @impl true makes the compiler check each callback’s name and arity, and once one function uses it, every callback of the module must.
  • A module name is an atom, so notifier.deliver(to, body) runs whichever module the caller passed or the configuration names, read while the code runs; a misspelt name from the configuration fails only then.
  • A test double is one more callback module, passed as an argument, which keeps global state out of the tests and makes them safe to run with async: true.
  • OTP’s behaviours work the same way: use GenServer adopts GenServer, whose only required callback is init/1.
  • Choose a protocol when the value’s type decides, a behaviour when the caller or the deployment picks among modules with several functions, and a function argument when only one function varies.

Terms in this lesson

callback
A function that a behaviour declares with @callback and that each module adopting the behaviour defines, unless it is listed in @optional_callbacks; the code that uses the behaviour calls it back. A missing one gets a warning when the module compiles.
callback module
A module that adopts a behaviour with @behaviour and defines its callbacks, such as an SMS notifier for a Notifier behaviour, or your own module for GenServer.
dynamic dispatch
Calling a function on a module chosen while the program runs, as in notifier.deliver(to, body), where notifier holds a module name passed in as an argument or read from configuration.
test double
A module that stands in for a real one in tests: it implements the same behaviour but records or fakes what it does, such as a notifier that sends each message to the test process instead of delivering it.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.