Elixir: Structs, Protocols & Behaviours
Structs: Named Shapes
A struct is a map that carries its module name, so shape errors are caught early and pattern
matching can select on type. Fields default to nil unless given, and
@enforce_keys requires specific fields at construction.
defmodule Invoice do
@enforce_keys [:number] # compile-time requirement
defstruct number: nil, total: 0, paid: false
# Put domain functions in the same module as the struct.
def mark_paid(%Invoice{} = inv) do
# The | update syntax returns a new struct.
%Invoice{inv | paid: true}
end
end
inv = %Invoice{number: "A-1", total: 99}
%Invoice{paid: paid} = Invoice.mark_paid(inv)
paid #=> true
Protocols: Dispatch on Data
A protocol declares a function signature; each data type supplies its own implementation. It is polymorphism on data, extensible by anyone — including types you do not own, which is exactly what OOP interfaces cannot do with library types.
defprotocol Describable do
@doc "Human-readable one-liner for any value."
def describe(value)
end
# One implementation per type — open for extension later.
defimpl Describable, for: Invoice do
def describe(%Invoice{} = i), do: "Invoice #{i.number} (#{i.total} EUR)"
end
defimpl Describable, for: Date do
def describe(d), do: "Date #{Date.to_iso8601(d)}"
end
Describable.describe(inv) #=> "Invoice A-1 (99 EUR)"
Describable.describe(~D[2026-09-17]) #=> "Date 2026-09-17"
Derivation removes boilerplate for common protocols: @derive {Inspect, only: [:number]}
or @derive Jason.Encoder-style JSON encoders. Protocol implementations are
consolidated at compile time into one dispatch table, so calls are fast.
Behaviours: Module Contracts
A behaviour is a contract for modules: it declares callbacks that any conforming module must
define. The compiler enforces the contract and warns on missing or misspelled callbacks, and
@impl documents which callback each function implements. OTP behaviours
(GenServer, Supervisor, Task) are the same mechanism.
defmodule Notifier do
@callback send_message(String.t(), map()) :: :ok | {:error, term()}
@callback name() :: atom()
end
defmodule EmailNotifier do
@behaviour Notifier
@impl true
def name, do: :email
@impl true
def send_message(to, payload) do
# ... delivery code with the API of your mail provider ...
:ok
end
end
defmodule Alert do
# Dependency injection: the notifier is just a module passed in.
def broadcast(module, users, payload) do
Enum.each(users, &module.send_message(&1, payload))
end
end
Alert.broadcast(EmailNotifier, ["a@x.io"], %{text: "hi"})
Protocol vs Behaviour
| Protocol | Behaviour | |
|---|---|---|
| Dispatch key | the data's type (struct/module of the value) | an explicit module passed as argument |
| Typical use | formatting, encoding, collection iteration | pluggable workers, GenServers, drivers |
| Extends | any existing type, from anywhere | any module that declares @behaviour |
| Seen in | Enumerable, Inspect, Jason.Encoder | GenServer, Supervisor, custom callbacks |
Practice
- Add an
overdue?/1function toInvoicethat takes a deadline and usesDate.compare/2. - Implement
Describablefor a new struct of your own; confirm the dispatch picks it. - Turn the email notifier into a second
SmsNotifierand runAlert.broadcast/3with each — note thatAlertdid not change.
Next: Errors & Failure Idioms