Elixir: Structs, Protocols & Behaviours

Elixir has no classes, yet it solves the same problems three ways: structs give data a named shape, protocols dispatch on data type, and behaviours define contracts between modules. Knowing which tool fits which problem is a milestone in writing idiomatic Elixir.

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.

Protocol dispatch table: one describe/1 call routes to implementations for Invoice, User and Date
Fig. 1 — The protocol is a dispatch table keyed by data type; implementations live beside the types they extend.
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

ProtocolBehaviour
Dispatch keythe data's type (struct/module of the value)an explicit module passed as argument
Typical useformatting, encoding, collection iterationpluggable workers, GenServers, drivers
Extendsany existing type, from anywhereany module that declares @behaviour
Seen inEnumerable, Inspect, Jason.EncoderGenServer, Supervisor, custom callbacks

Practice

  1. Add an overdue?/1 function to Invoice that takes a deadline and uses Date.compare/2.
  2. Implement Describable for a new struct of your own; confirm the dispatch picks it.
  3. Turn the email notifier into a second SmsNotifier and run Alert.broadcast/3 with each — note that Alert did not change.

Next: Errors & Failure Idioms