Best Practices
Want to write idiomatic Elixir? This guide presents best practices for production Elixir code across functional programming, OTP, Phoenix, testing, and performance.
What Makes Elixir Special
Philosophy: Immutability and Predictability
Core Principle: All data is immutable. Transformations create new data, leaving original unchanged.
Why it matters:
- Predictable code (no hidden state changes)
- Thread-safe by default (no race conditions on data)
- Easier debugging (data flow is explicit)
- Simpler testing (functions pure and deterministic)
Example:
defmodule Counter do
def increment(state) do
state.count = state.count + 1 # ❌ Error: cannot mutate
end
end
defmodule Counter do
def increment(state) do
%{state | count: state.count + 1} # ✅ Returns new state
end
endPhilosophy: Processes as Isolation Boundaries
Core Principle: Use processes for isolation, not just concurrency.
Why it matters:
- Crash isolation (one process crash doesn’t affect others)
- State isolation (no shared memory, only messages)
- Resource isolation (per-process GC, heap)
- Fault tolerance (supervisors restart failed processes)
Example:
{:ok, session1} = UserSession.start_link(user_id: 1)
{:ok, session2} = UserSession.start_link(user_id: 2)Philosophy: “Let It Crash”
Core Principle: Don’t defensive code. Let processes crash and supervisors restart them to known-good state.
Why it matters:
- Simpler code (no complex error recovery)
- Self-healing systems (supervisors restore health)
- Clear separation (business logic vs error handling)
- Reduced bugs (fewer edge cases to handle)
Example:
defmodule Parser do
def parse(data) do
try do
case validate(data) do
:ok ->
try do
transform(data)
rescue
_ -> {:error, "transform failed"}
end
:error -> {:error, "validation failed"}
end
rescue
_ -> {:error, "unknown error"}
end
end
end
defmodule Parser do
def parse(data) do
data
|> validate!()
|> transform!()
end
endPhilosophy: Message Passing Over Shared State
Core Principle: Processes communicate via messages, never shared memory.
Why it matters:
- No locks or mutexes (eliminates deadlocks)
- Location transparency (works across nodes)
- Decoupling (sender doesn’t know receiver internals)
- Testability (message-based protocols easy to test)
Example:
GenServer.call(pid, {:get_value, key})
GenServer.cast(pid, {:update_value, key, new_value})Pattern Matching Best Practices
Match Early, Match Often
Good:
def process_user(%User{role: :admin, id: id}) do
# Admin-specific logic
end
def process_user(%User{role: :user, id: id}) do
# User-specific logic
endWhy: Function signatures document expected input structure.
Use Guards for Type and Value Constraints
Good:
def divide(a, b) when is_number(a) and is_number(b) and b != 0 do
a / b
end
def categorize_age(age) when age < 13, do: :child
def categorize_age(age) when age < 20, do: :teen
def categorize_age(age) when age < 65, do: :adult
def categorize_age(_age), do: :seniorWhy: Guards provide compile-time documentation and runtime safety.
Pin Operator for Matching Existing Values
Good:
expected_status = :ok
case fetch_data() do
{^expected_status, data} -> process(data)
{:error, reason} -> handle_error(reason)
endWhy: Makes intent clear (match specific value, not bind new variable).
Destructure in Function Arguments
Good:
def format_user(%{name: name, email: email}) do
"#{name} <#{email}>"
end
def sum([]), do: 0
def sum([head | tail]), do: head + sum(tail)Avoid:
def format_user(user) do
name = user.name
email = user.email
"#{name} <#{email}>"
endWhy: Destructuring in function head is more declarative and concise.
Immutability Patterns
Prefer Transformations Over Mutations
Good:
defmodule Cart do
def add_item(cart, item) do
%{cart | items: [item | cart.items]}
end
def update_quantity(cart, item_id, quantity) do
items = Enum.map(cart.items, fn item ->
if item.id == item_id do
%{item | quantity: quantity}
else
item
end
end)
%{cart | items: items}
end
endWhy: Clear data flow, easier to test, thread-safe.
Use Update Syntax for Maps and Structs
Good:
%{user | name: "Alice"} # Update one field
%{user | name: "Alice", age: 30} # Update multiple fieldsNote: Only works for existing keys (compile-time check for structs).
Leverage Pipe Operator for Transformations
Good:
data
|> String.trim()
|> String.downcase()
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))Avoid:
reject_empty(map_trim(split_comma(downcase(trim(data)))))Why: Pipelines read top-to-bottom, left-to-right (natural order).
Chain with Result Tuples
Good:
with {:ok, user} <- fetch_user(id),
{:ok, profile} <- fetch_profile(user),
{:ok, posts} <- fetch_posts(user) do
{:ok, {user, profile, posts}}
else
{:error, reason} -> {:error, reason}
endWhy: Clear error handling, short-circuits on first error.
Process Design
One Process Per Resource
Good:
defmodule ChatSocket do
use Phoenix.Channel
def join("room:lobby", _payload, socket) do
{:ok, socket} # This socket = one process
end
end
defmodule UserSession do
use GenServer
def start_link(user_id) do
GenServer.start_link(__MODULE__, user_id)
end
endWhy: Isolation, independent failure, resource cleanup on crash.
Use GenServer for Stateful Resources
Good:
defmodule Cache do
use GenServer
# Client API
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
def get(key), do: GenServer.call(__MODULE__, {:get, key})
def put(key, value), do: GenServer.cast(__MODULE__, {:put, key, value})
# Server callbacks
def init(_opts), do: {:ok, %{}}
def handle_call({:get, key}, _from, state), do: {:reply, Map.get(state, key), state}
def handle_cast({:put, key, value}, state), do: {:noreply, Map.put(state, key, value)}
endWhen to use:
- Manage state over time
- Coordinate access to resource
- Background work with state
Use Task for Fire-and-Forget
Good:
Task.start(fn -> send_email(user) end)
Task.Supervisor.start_child(MyApp.TaskSupervisor, fn ->
send_email(user)
end)When to use:
- One-off computations
- Background work without state
- Don’t need result
Use Agent for Simple State
Good:
{:ok, counter} = Agent.start_link(fn -> 0 end)
Agent.get(counter, & &1) # 0
Agent.update(counter, &(&1 + 1))
Agent.get(counter, & &1) # 1When to use:
- Simple shared state
- No complex logic
- Alternative to ETS for small data
Avoid for: Complex business logic (use GenServer).
Minimize Process State
Good:
defmodule UserSession do
use GenServer
def init(user_id) do
# Only store essential state
{:ok, %{user_id: user_id, last_activity: now()}}
end
def handle_call(:get_user, _from, state) do
# Fetch fresh data on demand
user = Users.get_user!(state.user_id)
{:reply, user, state}
end
endWhy: Less memory, avoid stale data, simpler state management.
OTP Design Patterns
Design Supervision Trees Bottom-Up
Good:
defmodule MyApp.Supervisor do
use Supervisor
def start_link(opts) do
Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
end
def init(_opts) do
children = [
# Workers first
{MyApp.Cache, []},
{MyApp.Registry, []},
# Then dynamic supervisor for workers
{DynamicSupervisor, name: MyApp.SessionSupervisor, strategy: :one_for_one},
# Then higher-level components
MyApp.WebSocket
]
Supervisor.init(children, strategy: :one_for_one)
end
endStrategy:
- Workers at bottom
- Dynamic supervisors for runtime workers
- Static supervisors for infrastructure
- Application callbacks at top
Choose Right Supervision Strategy
:one_for_one (most common):
Supervisor.init(children, strategy: :one_for_one)Use when: Children are independent (cache, workers, sessions).
:rest_for_one:
Supervisor.init(children, strategy: :rest_for_one)Use when: Children depend on each other in order (database → repo → cache).
:one_for_all:
Supervisor.init(children, strategy: :one_for_all)Use when: Children share state that must be consistent (rare).
Name Processes Strategically
Good:
GenServer.start_link(Cache, [], name: Cache)
{:ok, pid} = GenServer.start_link(UserSession, user_id, name: {:via, Registry, {MyApp.Registry, user_id}})
GenServer.start_link(Worker, [])When to name:
- Singletons (one per application)
- Registry-based lookup needed
When not to name:
- Many instances (use Registry or store PIDs)
- Temporary workers
Use Application Callbacks for Startup
Good:
defmodule MyApp.Application do
use Application
def start(_type, _args) do
children = [
MyApp.Repo,
MyApp.Endpoint,
{Phoenix.PubSub, name: MyApp.PubSub}
]
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
endWhy: Clean startup, supervisor tree defined, OTP-compliant.
Phoenix Best Practices
Thin Controllers, Rich Contexts
Good:
defmodule MyAppWeb.UserController do
use MyAppWeb, :controller
def create(conn, %{"user" => user_params}) do
case Accounts.create_user(user_params) do
{:ok, user} ->
conn
|> put_flash(:info, "User created successfully")
|> redirect(to: ~p"/users/#{user}")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :new, changeset: changeset)
end
end
end
defmodule MyApp.Accounts do
def create_user(attrs) do
%User{}
|> User.changeset(attrs)
|> Repo.insert()
|> maybe_send_welcome_email()
end
defp maybe_send_welcome_email({:ok, user}) do
Email.send_welcome(user)
{:ok, user}
end
defp maybe_send_welcome_email(error), do: error
endWhy: Controllers handle HTTP, contexts handle business logic.
Use Changesets for All Data Validation
Good:
defmodule User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :age, :integer
end
def changeset(user, attrs) do
user
|> cast(attrs, [:email, :age])
|> validate_required([:email])
|> validate_format(:email, ~r/@/)
|> validate_number(:age, greater_than: 0)
|> unique_constraint(:email)
end
end
%User{}
|> User.changeset(params)
|> Repo.insert()Why: Centralized validation, reusable, composable.
Leverage LiveView for Interactive UIs
Good:
defmodule MyAppWeb.CounterLive do
use Phoenix.LiveView
def mount(_params, _session, socket) do
{:ok, assign(socket, count: 0)}
end
def handle_event("increment", _value, socket) do
{:noreply, update(socket, :count, &(&1 + 1))}
end
def render(assigns) do
~H"""
<div>
<p>Count: <%= @count %></p>
<button phx-click="increment">+</button>
</div>
"""
end
endWhen to use: Real-time updates, dynamic UIs, form validation.
Why: No JavaScript framework needed, simpler stack.
Use Plugs for Cross-Cutting Concerns
Good:
defmodule MyAppWeb.AuthPlug do
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
case get_session(conn, :user_id) do
nil -> conn |> put_flash(:error, "Not authenticated") |> redirect(to: "/login") |> halt()
user_id -> assign(conn, :current_user_id, user_id)
end
end
end
pipeline :authenticated do
plug MyAppWeb.AuthPlug
endUse for: Authentication, authorization, logging, rate limiting.
Organize Routes with Pipelines
Good:
defmodule MyAppWeb.Router do
use MyAppWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :protect_from_forgery
end
pipeline :api do
plug :accepts, ["json"]
end
pipeline :authenticated do
plug MyAppWeb.AuthPlug
end
scope "/", MyAppWeb do
pipe_through [:browser]
get "/", PageController, :index
end
scope "/admin", MyAppWeb.Admin do
pipe_through [:browser, :authenticated]
resources "/users", UserController
end
scope "/api", MyAppWeb.API do
pipe_through [:api]
resources "/posts", PostController
end
endWhy: Clear separation, composable pipelines, DRY.
Testing Best Practices
Test Behaviors, Not Implementation
Good:
test "user registration sends welcome email" do
{:ok, user} = Accounts.create_user(@valid_attrs)
assert_email_sent to: user.email, subject: "Welcome!"
endAvoid:
test "create_user calls Email.send_welcome" do
# Testing implementation detail
endWhy: Tests survive refactoring, focus on what matters.
Use ExUnit Features Effectively
Good:
defmodule UserTest do
use ExUnit.Case, async: true # Parallel tests
setup do
# Runs before each test
{:ok, user: create_user()}
end
describe "create_user/1" do
test "with valid data creates user", %{user: user} do
assert user.email =~ "@"
end
test "with invalid data returns error" do
assert {:error, changeset} = Accounts.create_user(%{email: "invalid"})
assert "is invalid" in errors_on(changeset).email
end
end
endFeatures: async: true, setup, describe for organization.
Test Edge Cases and Error Paths
Good:
describe "divide/2" do
test "divides positive numbers" do
assert Math.divide(10, 2) == 5.0
end
test "divides negative numbers" do
assert Math.divide(-10, 2) == -5.0
end
test "returns error for division by zero" do
assert {:error, :division_by_zero} = Math.divide(10, 0)
end
test "handles very large numbers" do
assert Math.divide(1.0e308, 2) == 5.0e307
end
endWhy: Edge cases reveal bugs, error paths ensure robustness.
Use Mox for Test Doubles
Good:
defmodule EmailSender do
@callback send(String.t(), String.t()) :: :ok | {:error, term()}
end
defmodule RealEmailSender do
@behaviour EmailSender
def send(to, body), do: # ... actual sending
end
Mox.defmock(MockEmailSender, for: EmailSender)
config :my_app, email_sender: MockEmailSender
test "sends email" do
expect(MockEmailSender, :send, fn to, body ->
assert to == "user@example.com"
:ok
end)
Accounts.create_user(@valid_attrs)
endWhy: Type-safe mocks, explicit contracts, compile-time errors.
Performance Patterns
Use Streams for Large Collections
Good:
File.stream!("large_file.csv")
|> Stream.map(&parse_line/1)
|> Stream.filter(&valid?/1)
|> Stream.take(100)
|> Enum.to_list()Avoid:
File.read!("large_file.csv")
|> String.split("\n")
|> Enum.map(&parse_line/1)
|> Enum.filter(&valid?/1)
|> Enum.take(100)When to use Streams: Large/infinite sequences, I/O operations, pipelines with early termination.
Leverage Tail Recursion
Good (tail-recursive):
def sum(list, acc \\ 0)
def sum([], acc), do: acc
def sum([head | tail], acc), do: sum(tail, head + acc)Avoid (non-tail-recursive):
def sum([]), do: 0
def sum([head | tail]), do: head + sum(tail)Why: Tail recursion optimized to constant stack space.
Use ETS for Fast In-Memory Storage
Good:
defmodule Cache do
def init do
:ets.new(:cache, [:set, :public, :named_table])
end
def get(key) do
case :ets.lookup(:cache, key) do
[{^key, value}] -> {:ok, value}
[] -> :error
end
end
def put(key, value) do
:ets.insert(:cache, {key, value})
end
endWhen to use: High-performance lookups, shared cache, counters.
Trade-off: No automatic cleanup (manage lifecycle carefully).
Batch Database Queries
Good:
user_ids = [1, 2, 3, 4, 5]
users = Repo.all(from u in User, where: u.id in ^user_ids)Avoid:
users = Enum.map(user_ids, &Repo.get(User, &1))Why: Reduces database round-trips, improves performance.
Code Organization
Group Related Functions in Modules
Good:
defmodule Users do
def list_users, do: # ...
def get_user!(id), do: # ...
def create_user(attrs), do: # ...
def update_user(user, attrs), do: # ...
def delete_user(user), do: # ...
endWhy: Clear API boundary, discoverability.
Use Module Attributes for Constants
Good:
defmodule Config do
@max_retries 3
@timeout 5000
def retry(fun, attempt \\ 0) do
if attempt < @max_retries do
# ...
end
end
endWhy: Centralized configuration, compile-time constants.
Leverage alias, import, require Wisely
Good:
defmodule MyApp.Users do
alias MyApp.{Repo, User} # Alias multiple
import Ecto.Query, only: [from: 2] # Import specific
require Logger # For macros
def list_users do
from(u in User, select: u)
|> Repo.all()
end
endPrefer alias: Most common, just shortens names.
Use import sparingly: Only for frequently used functions.
Use require for macros: Logger, assert, pattern matching.
Elixir Idioms
Use Bang Functions Appropriately
Conventions:
function()returns{:ok, result}or{:error, reason}function!()returnsresultor raises exception
Good:
case Repo.insert(changeset) do
{:ok, user} -> # ...
{:error, changeset} -> # ...
end
user = Repo.insert!(changeset) # Raise if errorWhen to use !: When error is truly exceptional, caller can’t recover.
Tagged Tuples for Results
Good:
def fetch_user(id) do
case Repo.get(User, id) do
nil -> {:error, :not_found}
user -> {:ok, user}
end
endConvention: :ok for success, :error for failure.
Use with for Sequential Operations
Good:
with {:ok, user} <- fetch_user(id),
{:ok, posts} <- fetch_posts(user),
{:ok, comments} <- fetch_comments(posts) do
{:ok, {user, posts, comments}}
else
{:error, reason} -> {:error, reason}
endWhy: Reads naturally, short-circuits on first error.
Comprehensions for Transformations
Good:
for x <- 1..10, rem(x, 2) == 0, do: x * 2When to use: Simple transformations with filters, generating collections.
When to avoid: Complex logic (use Enum functions).
Ready to learn what to avoid? See Anti-Patterns for common mistakes and how to fix them.