Behaviours
Behaviours are the other half of Elixir's polymorphism story. Where protocols say "any value of this type can answer these questions," behaviours say "any module that claims to be one of these must implement these functions." The dispatch target is different — types for protocols, module names for behaviours — and the use case is correspondingly different.
If you have written a use GenServer and filled in init/1 and handle_call/3, you have already used a behaviour. GenServer is a behaviour. So is Supervisor, Application, Plug, Phoenix.LiveView, Ecto.Repo, and roughly every framework abstraction in the ecosystem. They are how libraries say "give me a module that does X, Y, and Z, and I will handle the rest."
What a Behaviour Actually Is
A behaviour is a module that declares a set of @callback specifications. Any module that wants to implement the behaviour says @behaviour TheBehaviour and provides the callbacks.
defmodule PaymentGateway do
@moduledoc "Contract for payment processing backends"
@type amount :: pos_integer()
@type currency :: String.t()
@type card_token :: String.t()
@type charge_id :: String.t()
@type reason :: atom() | String.t()
@callback charge(amount, currency, card_token) ::
{:ok, charge_id} | {:error, reason}
@callback refund(charge_id, amount) ::
{:ok, charge_id} | {:error, reason}
@callback supported_currencies() :: [currency]
end
That is the entire behaviour. No code, just a contract. Now any module can implement it:
defmodule PaymentGateway.Stripe do
@behaviour PaymentGateway
@impl true
def charge(amount, currency, token) do
case Stripe.Charge.create(%{
amount: amount,
currency: currency,
source: token
}) do
{:ok, %{id: id}} -> {:ok, id}
{:error, %{message: msg}} -> {:error, msg}
end
end
@impl true
def refund(charge_id, amount) do
case Stripe.Refund.create(%{charge: charge_id, amount: amount}) do
{:ok, %{id: id}} -> {:ok, id}
{:error, %{message: msg}} -> {:error, msg}
end
end
@impl true
def supported_currencies, do: ~w(usd eur gbp jpy aud)
end
defmodule PaymentGateway.Adyen do
@behaviour PaymentGateway
@impl true
def charge(amount, currency, token) do
Adyen.Payments.authorize(%{
amount: %{value: amount, currency: currency},
paymentMethod: %{type: "scheme", encryptedCardNumber: token}
})
|> handle_adyen_response()
end
@impl true
def refund(charge_id, amount) do
Adyen.Modifications.refund(charge_id, amount)
|> handle_adyen_response()
end
@impl true
def supported_currencies, do: ~w(usd eur gbp chf sek nok dkk)
defp handle_adyen_response({:ok, %{"pspReference" => id}}), do: {:ok, id}
defp handle_adyen_response({:error, err}), do: {:error, err}
end
Now your application code does not care which gateway is in use:
defmodule Checkout do
def gateway, do: Application.get_env(:my_app, :payment_gateway)
def charge_customer(amount, currency, token) do
gateway().charge(amount, currency, token)
end
end
In config/prod.exs you wire Stripe. In config/test.exs you wire a PaymentGateway.Mock. Same code paths, different module names, dispatched by configuration.
@impl true and Why It Matters
The @impl true attribute above each callback implementation tells the compiler "this function is meant to fulfill a behaviour callback." If you typo the function name or forget a callback entirely, you get a compile-time warning.
Without @impl true, the compiler will not warn you that chrage/3 (typo) is not satisfying the behaviour — it will just silently fail at runtime when someone tries to call charge/3. With it, you get a clear message at compile time.
You can also be specific about which behaviour:
@impl PaymentGateway
def charge(amount, currency, token), do: ...
This matters when a single module implements multiple behaviours. Phoenix LiveView modules often implement both Phoenix.LiveView and Phoenix.LiveComponent callbacks in the same file — naming the behaviour makes the intent obvious and the warnings more precise.
Optional Callbacks
Real behaviours rarely require every callback. GenServer is the canonical example: only init/1 is strictly required, and the framework provides sensible defaults for handle_call/3, handle_cast/2, handle_info/2, terminate/2, and code_change/3. You override the ones that matter for your specific GenServer.
You declare optional callbacks with @optional_callbacks:
defmodule PaymentGateway do
@callback charge(integer, String.t(), String.t()) ::
{:ok, String.t()} | {:error, term()}
@callback refund(String.t(), integer) ::
{:ok, String.t()} | {:error, term()}
@callback void(String.t()) :: :ok | {:error, term()}
@optional_callbacks void: 1
end
Now Stripe can implement void/1 (instant cancellation of an unsettled charge) while Adyen, which does not support it, can omit it. Calling PaymentGateway.Adyen.void(charge_id) would raise UndefinedFunctionError, so the caller checks first:
if function_exported?(gateway, :void, 1) do
gateway.void(charge_id)
else
gateway.refund(charge_id, full_amount)
end
The use Macro Pattern
When you write use GenServer at the top of a module, you are not just declaring a behaviour. You are running a macro from the GenServer module that injects default implementations of the optional callbacks, brings in helper functions, and adds the @behaviour GenServer declaration for you.
This is the convention for behaviours that ship a lot of plumbing. The behaviour module exports a __using__/1 macro:
defmodule PaymentGateway do
@callback charge(integer, String.t(), String.t()) ::
{:ok, String.t()} | {:error, term()}
defmacro __using__(_opts) do
quote do
@behaviour PaymentGateway
@impl true
def charge(_amount, _currency, _token) do
{:error, :not_implemented}
end
defoverridable charge: 3
end
end
end
Now use PaymentGateway gives you a default implementation that returns {:error, :not_implemented}, marked defoverridable so you can replace it. Plug, Phoenix.Controller, Phoenix.LiveView, and Ecto.Schema all use this pattern. The behaviour gives you the contract; the use macro gives you the defaults and helpers.
Whether you need a __using__/1 macro depends on the behaviour. A small contract with three required callbacks is fine with just @behaviour. A large contract with twenty optional callbacks usually wants a use macro to make the common case ergonomic.
Where Behaviours Show Up in the Ecosystem
Almost every framework abstraction is a behaviour. Plug is one of the simplest: two callbacks, init/1 and call/2. That is the entire contract for "a thing that processes an HTTP connection." Every middleware in a Phoenix endpoint, every controller plug, every custom auth check — all of them implement the Plug behaviour. The pipeline is just a sequence of call/2 calls down a list of modules.
GenServer has six callbacks. Supervisor has one. Application has two. Ecto.Repo has dozens, most optional. Phoenix.LiveView has mount/3, handle_event/3, handle_info/2, and render/1 as the core. Reading the @callback declarations in these modules is one of the better ways to understand what the abstraction expects from you.
For your own code, the rule of thumb is the same as for any interface: introduce a behaviour when you have at least two implementations or you want to mock one out for testing. The Stripe vs Adyen split is the textbook case. Email backends (SMTP vs SendGrid vs SES) are another. Cache backends (in-memory vs Redis) are a third. Anything with a clear "I need a swappable thing that does X" shape.
Protocols vs Behaviours
This is the comparison that trips up everyone learning Elixir. Both feel like interfaces. Both are about polymorphism. They are not interchangeable.
| Protocols | Behaviours | |
|---|---|---|
| Dispatches on | The type of a value (first argument) | The name of a module |
| Lookup happens | At runtime, per call | At compile time, by config or call site |
| What you implement | defimpl Protocol, for: Type |
Module with @behaviour and callbacks |
| Built for | "Many data types share an operation" | "Many modules implement the same contract" |
| Canonical example | Enumerable, Inspect, String.Chars |
GenServer, Plug, Ecto.Repo |
| Adding implementations | Anyone can add one for any type, anywhere | The module decides which behaviour it implements |
The shorter version: use a protocol when the polymorphism is about data and the data type drives which code runs. Use a behaviour when the polymorphism is about modules and the calling code picks the module by configuration or pattern matching.
A payment gateway is module-shaped. Stripe and Adyen are not "types of value" — they are different services with different code. That is a behaviour.
A to_string/1 function is data-shaped. Atoms, integers, binaries, and your custom structs all answer the same question differently depending on what they are. That is a protocol.
If you find yourself wanting "dispatch on the first argument, where the first argument's type tells me which implementation runs" — protocol. If you want "a contract that other developers fulfill by writing a module" — behaviour. They coexist happily in the same codebase; most real Elixir projects use both heavily.
Common Pitfalls
Skipping @impl true. Without it, typos in callback names compile cleanly and fail at runtime. With it, you get a compile warning the moment the implementation drifts from the contract. There is no reason not to use it.
Using a behaviour when a plain function would do. If you only ever have one implementation, the behaviour adds indirection without buying anything. Wait until you have a second implementation or a real testing need before extracting the contract.
Confusing @callback with @spec. @callback declares what a module that implements this behaviour must provide. @spec declares the type of a specific function in this module. They look similar and complement each other — you typically write @callback in the behaviour module and @spec in the implementing modules.
Forgetting that behaviour dispatch is static. The caller has to know which module to call. There is no runtime lookup like with protocols. If you want to swap implementations based on config, you read the module out of config at the call site. If you want to swap based on data shape, you probably want a protocol instead.
Building a use macro that does too much. The __using__/1 macro is tempting for adding helpers, but every line of injected code is a line the user of the behaviour has to debug. Keep __using__/1 to the minimum — the @behaviour declaration, default implementations of optional callbacks, and defoverridable. Resist the urge to inject business logic.
Implementing optional callbacks "just in case." Optional callbacks exist because not every implementation should provide them. If your void/1 is just refund/2 with a placeholder amount, leave it out and let the caller fall back. Implementing it weakly defeats the point of the optionality.
Key Takeaways
- A behaviour is a module that declares
@callbackspecifications. Other modules say@behaviour ModuleNameand provide implementations. @impl true(or@impl BehaviourName) above each implementation makes typos and missing callbacks compile-time errors instead of runtime ones.- Optional callbacks let some implementations provide a function while others omit it. The caller checks with
function_exported?/3or relies on a default from theusemacro. - The
usemacro pattern injects default implementations and helpers — GenServer, Plug, Phoenix.LiveView, and Ecto.Schema all use it. Keep your own__using__/1macros small. - Protocols dispatch on the type of a value. Behaviours dispatch on the name of a module. Use protocols for "many data types share an operation," behaviours for "many modules fulfill a contract."
- The textbook behaviour use cases: payment gateways, email backends, cache stores, storage adapters — anywhere you want swappable modules selected by configuration or testing.