(13) Rules for Writing Idiomatic Code
A practical guide aligned with Credo and the Elixir Community Style Guide.
Use conventional Elixir naming to express intent clearly.
- Modules:
CamelCaseas nouns describing the domain concept. Keep acronyms uppercase (MyApp.HTTPClient, notMyApp.HttpClient). - Functions:
snake_caseas verbs describing the action (fetch_user,validate_input). - Variables/parameters:
snake_caseas nouns describing the data (user_params,connection). - Predicates: Use trailing
?for boolean-returning functions (valid?,active?). Useis_prefix only for guard-safe functions (defguard is_admin(user)). - Exceptions: End with
Errorsuffix (InvalidTokenError).
Prefer pronounceable, unabbreviated names. Let names reveal purpose within the call graph.
# Preferred
defmodule MyApp.OrderProcessor do
def process_order(order_params), do: ...
def valid?(order), do: ...
defguard is_complete(order) when order.status == :complete
end
# Avoid
defmodule MyApp.OrdProc do
def proc(params), do: ...
def isValid(ord), do: ...
endCredo checks: Readability.FunctionNames, Readability.ModuleNames, Readability.PredicateFunctionNames, Consistency.ExceptionNames
Favor clear, minimal parameter lists with pattern matching.
- Tend toward one or two parameters; avoid "telephone passing" long argument lists.
- Use guards for value constraints:
nil, length/size, type checks, comparisons, membership (in), boolean, arithmetic. - Match on structs in function heads to enforce expected shapes.
- Bind variables by pattern matching in the function head, not in the body.
- Pass functions using the capture operator (
&Module.fun/arity).
# Preferred: pattern match and guard in head
def process(%Order{status: status} = order) when status in [:pending, :confirmed] do
# order is bound and validated
end
def notify(user, message_fn) when is_function(message_fn, 1) do
message_fn.(user)
end
# Avoid: extracting values in body
def process(order) do
status = order.status
if status in [:pending, :confirmed], do: ...
endCredo checks: Refactor.FunctionArity, Readability.Specs
Prefer multi-clause functions over conditionals; use pipes for composition.
- Use multiple function clauses with pattern matching instead of
if,case, orcondwhere practical. - Use the pipe operator (
|>) to chain transformations. Start pipes with a bare variable. - Avoid single-pipe expressions; just call the function directly.
- Use parentheses in piped function calls.
# Preferred: multi-clause with pipes
def handle_response({:ok, body}), do: body |> decode() |> transform()
def handle_response({:error, reason}), do: {:error, reason}
# Preferred: bare variable starts the pipe
user_input
|> String.trim()
|> String.downcase()
|> validate()
# Avoid: single pipe
input |> String.trim()
# Prefer instead
String.trim(input)
# Avoid: conditional when clauses suffice
def handle_response(response) do
case response do
{:ok, body} -> ...
{:error, reason} -> ...
end
endCredo checks: Refactor.CondStatements, Readability.SinglePipe, Refactor.PipeChainStart
Use semantic types; prefer structs over raw maps.
- Define custom structs with
defstructto enforce required keys and default values. - Use
@typeto define custom types for clarity and Dialyzer support. - Conventionally: lists for ordered/mutable collections (commands, writes); tuples for fixed-size groupings (queries, reads, return values).
defmodule MyApp.User do
@type t :: %__MODULE__{
name: String.t(),
email: String.t(),
role: :admin | :member
}
defstruct [:name, :email, role: :member]
end
# Preferred: struct with explicit shape
%User{name: "Alice", email: "[email protected]"}
# Avoid: bare map when structure is known
%{name: "Alice", email: "[email protected]", role: :member}Credo checks: Readability.Specs (for @type documentation)
Use multi-clause recursion for clarity; tail-call optimize for scale.
- Prefer multi-clause recursive functions over
Enum.reducewith complex accumulators when the logic is clearer. - Use tail-recursive functions (accumulator in parameters) for very large collections (millions of elements).
- Use
Enumconveniences (map,filter,reduce) for typical collection operations. - Consider
Streamfor lazy evaluation of large or infinite sequences.
# Multi-clause recursion for clarity
def sum([]), do: 0
def sum([head | tail]), do: head + sum(tail)
# Tail-recursive for large collections
def sum_large(list), do: do_sum(list, 0)
defp do_sum([], acc), do: acc
defp do_sum([head | tail], acc), do: do_sum(tail, acc + head)
# Enum for typical cases
Enum.sum(list)
Enum.map(users, &User.activate/1)Credo checks: Refactor.Nesting (deep nesting suggests refactoring)
Bubble up context with tagged tuples and with; avoid defensive coding.
- Return tagged tuples (
{:ok, value}or{:error, reason}) to propagate results. - Use
withto chain operations that may fail, unwrapping as you go. - Define custom error structs for domain-specific failures.
- Reserve
try/catch/rescuefor genuinely exceptional, unguaranteed operations (external APIs, file I/O with uncertain state). - Let processes crash and rely on supervisors for fault tolerance—this is "let it crash" philosophy.
# Preferred: with for happy-path chaining
def create_order(params) do
with {:ok, validated} <- validate(params),
{:ok, order} <- persist(validated),
{:ok, _} <- notify_warehouse(order) do
{:ok, order}
else
{:error, :validation_failed} -> {:error, "Invalid order data"}
{:error, :db_error} -> {:error, "Could not save order"}
{:error, reason} -> {:error, reason}
end
end
# Custom error for domain clarity
defmodule MyApp.InvalidOrderError do
defexception [:message, :field]
end
# Avoid: defensive try/catch around normal operations
def fetch_user(id) do
try do
Repo.get!(User, id)
rescue
_ -> nil # Hides real errors
end
end
# Prefer: let it crash or use Repo.get/2
def fetch_user(id), do: Repo.get(User, id)Credo checks: Refactor.WithClauses, Warning.RaiseInsideRescue
Order module contents consistently; use extension points appropriately.
Follow this ordering within modules (with blank lines between groups):
@moduledoc@behaviouruseimportrequirealias- Module attributes (
@constant) defstruct- Type definitions (
@type,@typep) - Callbacks (
@callback,@macrocallback,@optional_callbacks) - Public functions
- Private functions
Sort aliases alphabetically. Use __MODULE__ for self-references.
defmodule MyApp.OrderService do
@moduledoc """
Handles order processing and fulfillment.
"""
use GenServer
import MyApp.Guards
alias MyApp.{Order, User, Warehouse}
@timeout 5_000
defstruct [:order, :status]
@type t :: %__MODULE__{order: Order.t(), status: atom()}
# Public API
def start_link(opts), do: GenServer.start_link(__MODULE__, opts)
def process(pid, order), do: GenServer.call(pid, {:process, order})
# GenServer callbacks
@impl true
def init(opts), do: {:ok, %__MODULE__{}}
# Private helpers
defp validate(order), do: ...
endCredo checks: Readability.AliasOrder, Readability.ModuleDoc, Design.AliasUsage
Document behavior with @spec, @type, @moduledoc, and @doc.
- Use
@specon public functions to document inputs and outputs; Dialyzer will verify them. - Define
@typefor custom types, especially algebraic data types (sum types with|, product types with structs). - Every module should have
@moduledoc; use@moduledoc falsefor internal modules. - Use
@docto explain non-obvious functions. Include examples with## Examplesin heredocs.
defmodule MyApp.Calculator do
@moduledoc """
Provides arithmetic operations with overflow protection.
## Examples
iex> Calculator.safe_add(1, 2)
{:ok, 3}
"""
@type result :: {:ok, integer()} | {:error, :overflow}
@doc """
Adds two integers, returning an error if overflow occurs.
"""
@spec safe_add(integer(), integer()) :: result()
def safe_add(a, b) when is_integer(a) and is_integer(b) do
# implementation
end
endCredo checks: Readability.ModuleDoc, Readability.Specs
Use the right OTP abstraction for the job.
| Pattern | Use Case |
|---|---|
Task |
One-off async work, parallel map |
Task.Supervisor |
Supervised async work with fault tolerance |
Agent |
Simple state without complex logic |
GenServer |
Stateful processes with message handling |
| Supervisor | Fault tolerance through process supervision |
Stream |
Lazy evaluation of sequences |
Unsupervised tasks (Task.async/1) link to the caller. If the task crashes, the caller crashes. If the caller crashes, the task is terminated. Use these for work that is integral to the caller's success.
# Unsupervised: caller and tasks are linked
# If any task crashes, the caller crashes too
tasks = Enum.map(urls, &Task.async(fn -> fetch(&1) end))
results = Task.await_many(tasks)Supervised tasks (Task.Supervisor) are managed by a supervisor. They can crash independently without taking down the caller, and the supervisor can restart them or apply backpressure. Use these for:
- Fire-and-forget work: Sending emails, logging to external services, analytics events
- Work that may fail: External API calls where you don't want to crash the caller
- Controlled concurrency: Limiting how many tasks run simultaneously
- Long-running request handlers: Web requests that spawn background work
# Add to your supervision tree (application.ex)
children = [
{Task.Supervisor, name: MyApp.TaskSupervisor}
]
# Fire-and-forget: don't wait for result, won't crash caller if it fails
Task.Supervisor.start_child(MyApp.TaskSupervisor, fn ->
send_welcome_email(user)
end)
# Async with fault isolation: get result but isolate failures
task = Task.Supervisor.async_nolink(MyApp.TaskSupervisor, fn ->
fetch_from_flaky_api(id)
end)
case Task.yield(task, 5_000) || Task.shutdown(task) do
{:ok, result} -> {:ok, result}
{:exit, reason} -> {:error, :task_failed}
nil -> {:error, :timeout}
end
# Controlled concurrency: max 10 simultaneous tasks
Task.Supervisor.async_stream_nolink(
MyApp.TaskSupervisor,
urls,
&fetch/1,
max_concurrency: 10,
ordered: false
)
|> Enum.to_list()Decision guide:
| Scenario | Use |
|---|---|
| Need the result, failure is fatal | Task.async/1 + Task.await/2 |
| Need the result, failure is recoverable | Task.Supervisor.async_nolink/2 + Task.yield/2 |
| Don't need result, best-effort delivery | Task.Supervisor.start_child/2 |
| Many items, controlled parallelism | Task.Supervisor.async_stream_nolink/4 |
| Parallel map, all must succeed | Task.async_stream/3 |
defmodule MyApp.Counter do
use GenServer
def start_link(initial), do: GenServer.start_link(__MODULE__, initial)
def increment(pid), do: GenServer.call(pid, :increment)
@impl true
def init(count), do: {:ok, count}
@impl true
def handle_call(:increment, _from, count), do: {:reply, count + 1, count + 1}
endUse behaviours to define and enforce module interfaces.
A behaviour is a contract: it specifies which functions a module must implement. The compiler verifies compliance at compile time.
When to use behaviours:
- Defining a plugin or adapter interface (e.g., different storage backends)
- Ensuring consistent APIs across modules that serve similar purposes
- Creating extension points in libraries or frameworks
- When you need compile-time guarantees that implementations are complete
When NOT to use behaviours:
- For polymorphism over data types (use protocols instead)
- When you only have one implementation (premature abstraction)
- For simple delegation (just call the module directly)
# Define the contract
defmodule MyApp.Cache do
@moduledoc "Behaviour for cache implementations."
@type key :: String.t()
@type value :: term()
@callback get(key()) :: {:ok, value()} | {:error, :not_found}
@callback put(key(), value(), keyword()) :: :ok | {:error, term()}
@callback delete(key()) :: :ok
@optional_callbacks delete: 1
end
# Implement the contract
defmodule MyApp.Cache.ETS do
@behaviour MyApp.Cache
@impl true
def get(key), do: ...
@impl true
def put(key, value, _opts), do: ...
# delete/1 is optional, so we can omit it
end
# Use the implementation (typically injected via config or parameter)
defmodule MyApp.UserService do
@cache Application.compile_env(:my_app, :cache_module, MyApp.Cache.ETS)
def get_user(id) do
case @cache.get("user:#{id}") do
{:ok, user} -> user
{:error, :not_found} -> fetch_and_cache(id)
end
end
endKey points:
- Use
@callbackto declare required functions with their specs - Use
@optional_callbacksfor functions implementations may omit - Use
@impl truein implementations to signal intent and get compiler warnings if the function doesn't match a callback - Behaviours enable dependency injection and make testing easier
Use protocols to dispatch on data type; use behaviours to dispatch on module.
Protocols provide ad-hoc polymorphism—different implementations based on the data type passed in. They're Elixir's answer to interfaces that operate on data rather than modules.
When to use protocols:
- Operating differently based on data type (the classic polymorphism use case)
- Extending functionality for types you don't control (e.g., third-party structs)
- When the data "knows" how to perform an operation (data-driven dispatch)
When to use behaviours instead:
- The caller chooses which implementation to use (not determined by data)
- You need compile-time implementation guarantees
- Defining adapter/plugin interfaces
# Define a protocol
defprotocol MyApp.Renderable do
@doc "Renders the data structure as an HTML string."
@spec to_html(t()) :: String.t()
def to_html(data)
end
# Implement for your structs
defimpl MyApp.Renderable, for: MyApp.User do
def to_html(%MyApp.User{name: name, email: email}) do
"<div class=\"user\"><h2>#{name}</h2><p>#{email}</p></div>"
end
end
defimpl MyApp.Renderable, for: MyApp.Product do
def to_html(%MyApp.Product{name: name, price: price}) do
"<div class=\"product\"><h3>#{name}</h3><span>$#{price}</span></div>"
end
end
# Implement for built-in types
defimpl MyApp.Renderable, for: BitString do
def to_html(string), do: "<p>#{string}</p>"
end
# Use polymorphically—dispatch is automatic based on data type
def render_all(items) do
items
|> Enum.map(&MyApp.Renderable.to_html/1)
|> Enum.join("\n")
endProtocol vs Behaviour decision guide:
| Question | Protocol | Behaviour |
|---|---|---|
| What determines the implementation? | The data type passed in | The caller's choice |
| Can you extend types you don't own? | Yes | N/A |
| Compile-time verification? | No (runtime dispatch) | Yes |
| Typical use | Data transformation, formatting | Adapters, plugins, strategies |
Derive for automatic implementations:
# If a protocol provides @derive support
defmodule MyApp.Event do
@derive [MyApp.Renderable] # Use default implementation
defstruct [:type, :payload]
endAvoid macros unless the abstraction cannot be achieved with functions.
Macros operate on the AST at compile time. They're powerful but obscure: readers can't understand macro-generated code without mentally expanding it, stack traces point to generated code, and debugging becomes significantly harder.
Legitimate macro use cases:
- DSLs where syntax matters (e.g., ExUnit's
test, Ecto'sschema) - Compile-time code generation from external data
- Wrapping boilerplate that truly cannot be a function (e.g.,
usecallbacks)
Avoid macros when:
- A function would work (most of the time)
- You're just reducing typing (that's not a good tradeoff)
- The abstraction isn't well-understood yet (macros are hard to change)
# ❌ Avoid: macro for something a function can do
defmacro add(a, b) do
quote do: unquote(a) + unquote(b)
end
# ✅ Prefer: just use a function
def add(a, b), do: a + b
# ✅ Legitimate: DSL where syntax/compile-time matters
defmacro field(name, type, opts \\ []) do
quote do
Module.put_attribute(__MODULE__, :fields, {unquote(name), unquote(type), unquote(opts)})
end
endWhen you must use macros, use decompile to verify the generated code:
Install the decompile tool:
mix archive.install github michalmuskala/decompileInspect what your macros actually generate:
# See the expanded Elixir code (most useful for understanding macros)
mix decompile MyApp.SomeModule --to expanded
# See the Erlang code (useful for performance analysis)
mix decompile MyApp.SomeModule --to erlang
# See the BEAM assembly (useful for deep optimization)
mix decompile MyApp.SomeModule --to asm
# See the Core Erlang (intermediate representation)
mix decompile MyApp.SomeModule --to coreExample workflow:
# You wrote this
defmodule MyApp.User do
use MyApp.Schema
schema "users" do
field :name, :string
field :email, :string
end
end# See what it actually becomes
$ mix decompile MyApp.User --to expanded
# Output shows the generated functions, struct definition, etc.Macro hygiene checklist:
- Can this be a function instead? (If yes, use a function)
- Have I tested the generated code with
decompile --to expanded? - Are variable names properly hygienic (use
var!only when intentional)? - Does the macro compose well with other macros?
- Is the error message clear when misused?
- Have I documented what code gets generated?
Credo check: Refactor.LongQuoteBlocks (warns when quote blocks are too large—consider extracting to functions)
Use mix format, Credo, and Dialyzer; iterate toward clarity.
- Run
mix formaton every save—formatting is automated and non-negotiable. - Run
mix credo --strictto catch style and consistency issues. - Run
mix dialyzerto verify typespecs and catch type errors. - Use
IEx.pry(viarequire IEx; IEx.pry()) for interactive debugging.
Workflow: Write pseudocode or a TODO list → write a test → implement → format → run Credo → run Dialyzer.
# Standard quality checks
mix format --check-formatted
mix credo --strict
mix dialyzer
mix testClean code is an ongoing practice: sort, systematize, refine, standardize, and maintain discipline.
| Category | Focus |
|---|---|
| Consistency | Uniform style (tabs vs spaces, naming patterns) |
| Readability | Ease of comprehension (function names, module docs) |
| Refactor | Opportunities to simplify (reduce nesting, extract functions) |
| Design | Architectural guidance (alias usage, TODOs/FIXMEs) |
| Warning | Potential bugs (unused operations, IEx.pry left in code) |