Best Practices
Idioms, conventions, and the patterns that separate Elixir code that ages well from code that ossifies. This chapter is the punchlist worth scanning before opening a pull request.
Naming Conventions
Modules
# Use PascalCase
defmodule MyApp.Accounts.User do
end
# Namespace with top-level app name
MyApp.Blog.Post
MyApp.Web.Router
# Acronyms are treated as words
defmodule MyApp.JSONParser do # Good
defmodule MyApp.JsonParser do # Also acceptable
# Avoid
defmodule myapp_user do # Bad
Functions and Variables
# Use snake_case
def calculate_total(items) do
total_price = sum_prices(items)
total_price
end
# Predicates end with ?
def active?(user), do: user.active
def empty?(list), do: list == []
# Dangerous functions end with !
def save!(data) # Raises on error
def save(data) # Returns {:ok, result} | {:error, reason}
Private Functions
defmodule Calculator do
# Public API
def calculate(a, b) do
result = do_calculate(a, b)
format_result(result)
end
# Private helpers
defp do_calculate(a, b), do: a + b
defp format_result(result), do: "Result: #{result}"
end
Code Organization
Context Pattern
Group related functionality:
# DON'T: God module
defmodule MyApp do
def create_user, do: ...
def get_user, do: ...
def create_post, do: ...
def send_email, do: ...
end
# DO: Separate contexts
defmodule MyApp.Accounts do
def create_user, do: ...
def get_user, do: ...
end
defmodule MyApp.Blog do
def create_post, do: ...
def get_post, do: ...
end
defmodule MyApp.Notifications do
def send_email, do: ...
end
Module Structure
defmodule MyApp.Blog.Post do
use Ecto.Schema
import Ecto.Changeset
# Schema definition
schema "posts" do
field :title, :string
timestamps()
end
# Public API
def changeset(post, attrs) do
# ...
end
# Private functions
defp validate_title(changeset) do
# ...
end
end
Pattern Matching
Use Pattern Matching
# DON'T
def greet(user) do
if user.name != nil do
"Hello, #{user.name}"
else
"Hello, Guest"
end
end
# DO
def greet(%{name: name}) when is_binary(name), do: "Hello, #{name}"
def greet(_), do: "Hello, Guest"
Match Early
# DON'T
def process(data) do
if data.type == :valid do
# Long processing logic
else
{:error, :invalid}
end
end
# DO
def process(%{type: :valid} = data) do
# Long processing logic
end
def process(_data) do
{:error, :invalid}
end
Destructure in Function Heads
# DON'T
def format_user(user) do
name = user.name
age = user.age
"#{name} (#{age})"
end
# DO
def format_user(%{name: name, age: age}) do
"#{name} (#{age})"
end
Error Handling
Tagged Tuples
# Public API returns tagged tuples
def create_user(attrs) do
case validate(attrs) do
:ok -> insert_user(attrs)
:error -> {:error, :invalid_data}
end
end
# Let internal functions crash
defp insert_user(attrs) do
# Will raise if fails - that's ok
user = Repo.insert!(%User{name: attrs.name})
{:ok, user}
end
Use with for Pipelines
# DON'T
def process(data) do
case step1(data) do
{:ok, result1} ->
case step2(result1) do
{:ok, result2} ->
case step3(result2) do
{:ok, result3} -> {:ok, result3}
error -> error
end
error -> error
end
error -> error
end
end
# DO
def process(data) do
with {:ok, result1} <- step1(data),
{:ok, result2} <- step2(result1),
{:ok, result3} <- step3(result2) do
{:ok, result3}
end
end
Let It Crash
# DON'T: Defensive programming
def divide(a, b) do
try do
a / b
rescue
_ -> {:error, :failed}
end
end
# DO: Let it crash, supervisor will restart
def divide(a, b) when b != 0, do: a / b
def divide(_, 0), do: {:error, :division_by_zero}
Pipe Operator
Use Pipes for Transformation
# DON'T
def process(data) do
result1 = String.trim(data)
result2 = String.downcase(result1)
result3 = String.split(result2)
Enum.join(result3, "-")
end
# DO
def process(data) do
data
|> String.trim()
|> String.downcase()
|> String.split()
|> Enum.join("-")
end
One Operation Per Line
# DON'T
data |> transform() |> validate() |> save()
# DO
data
|> transform()
|> validate()
|> save()
Don't Overuse
# DON'T
name = user |> Map.get(:name)
# DO
name = user.name
# or
name = Map.get(user, :name)
Functions
Small Functions
# DON'T: Large function doing multiple things
def create_user(attrs) do
# Validate
# Transform
# Save
# Send email
# Log
# Return
end
# DO: Break into smaller functions
def create_user(attrs) do
with {:ok, validated} <- validate(attrs),
{:ok, user} <- insert_user(validated),
:ok <- send_welcome_email(user) do
log_user_creation(user)
{:ok, user}
end
end
Single Responsibility
# Each function does one thing
defp validate(attrs), do: ...
defp insert_user(validated), do: ...
defp send_welcome_email(user), do: ...
defp log_user_creation(user), do: ...
Avoid Side Effects in Pipelines
# DON'T
data
|> transform()
|> tap(&IO.inspect/1) # Side effect
|> validate()
# DO: Keep pipelines pure, side effects at the end
data =
data
|> transform()
|> validate()
IO.inspect(data)
process(data)
Data Structures
Use Structs for Domain Models
# DON'T: Plain maps
def create_user(name, email) do
%{name: name, email: email, created_at: DateTime.utc_now()}
end
# DO: Structs with enforced keys
defmodule User do
@enforce_keys [:name, :email]
defstruct [:name, :email, :created_at]
def new(name, email) do
%User{
name: name,
email: email,
created_at: DateTime.utc_now()
}
end
end
Use Keywords for Options
# DO
def fetch(url, opts \\ []) do
timeout = Keyword.get(opts, :timeout, 5000)
headers = Keyword.get(opts, :headers, [])
# ...
end
fetch("http://example.com", timeout: 10_000, headers: [{"Accept", "application/json"}])
Use Maps for Data
# For key-value data
user_data = %{
"name" => "Alice",
"email" => "alice@example.com"
}
# For structured data with atom keys
config = %{
timeout: 5000,
retry: true
}
Concurrency
Don't Share State
# DON'T
defmodule Counter do
@count 0 # This won't work as expected
def increment do
@count = @count + 1 # Module attributes are compile-time
end
end
# DO: Use processes
defmodule Counter do
use GenServer
def start_link(initial) do
GenServer.start_link(__MODULE__, initial, name: __MODULE__)
end
def increment do
GenServer.cast(__MODULE__, :increment)
end
def init(initial), do: {:ok, initial}
def handle_cast(:increment, state) do
{:noreply, state + 1}
end
end
Use Task for One-off Work
# DO
Task.start(fn -> send_email(user) end)
# With supervision
Task.Supervisor.start_child(MyApp.TaskSupervisor, fn ->
send_email(user)
end)
Use GenServer for State
# When you need to maintain state
defmodule Cache do
use GenServer
def get(key), do: GenServer.call(__MODULE__, {:get, key})
def put(key, value), do: GenServer.cast(__MODULE__, {:put, key, value})
def init(_), do: {:ok, %{}}
def handle_call({:get, key}, _from, state) do
{:reply, Map.get(state, key), state}
end
def handle_cast({:put, key, value}, state) do
{:noreply, Map.put(state, key, value)}
end
end
Testing
Test Behavior, Not Implementation
# DON'T: Testing implementation details
test "calls internal function" do
assert MyModule.internal_function() == :ok
end
# DO: Test public API
test "processes data correctly" do
input = %{data: "test"}
assert {:ok, result} = MyModule.process(input)
assert result.processed == true
end
Use Descriptive Test Names
# DON'T
test "test1" do
end
# DO
test "creates user with valid attributes" do
end
test "returns error when email is invalid" do
end
One Assertion Per Test
# DON'T
test "user creation" do
assert {:ok, user} = create_user(attrs)
assert user.name == "Alice"
assert user.email == "alice@example.com"
assert user.age == 30
end
# DO
describe "create_user/1" do
test "returns ok tuple" do
assert {:ok, _user} = create_user(attrs)
end
test "sets name correctly" do
{:ok, user} = create_user(attrs)
assert user.name == "Alice"
end
test "sets email correctly" do
{:ok, user} = create_user(attrs)
assert user.email == "alice@example.com"
end
end
Documentation
Module Documentation
defmodule MyApp.Accounts do
@moduledoc """
The Accounts context.
Manages users, authentication, and authorization.
"""
# ...
end
Function Documentation
@doc """
Creates a new user.
Returns `{:ok, user}` if successful, `{:error, changeset}` otherwise.
## Examples
iex> create_user(%{name: "Alice", email: "alice@example.com"})
{:ok, %User{}}
iex> create_user(%{name: nil})
{:error, %Ecto.Changeset{}}
"""
def create_user(attrs) do
# ...
end
Type Specs
@type user :: %User{
name: String.t(),
email: String.t(),
age: integer()
}
@spec create_user(map()) :: {:ok, user()} | {:error, Ecto.Changeset.t()}
def create_user(attrs) do
# ...
end
Performance
Use Streams for Large Data
# DON'T: Loads entire file into memory
File.read!("huge_file.txt")
|> String.split("\n")
|> Enum.map(&process_line/1)
# DO: Streams process lazily
File.stream!("huge_file.txt")
|> Stream.map(&process_line/1)
|> Enum.to_list()
Batch Database Operations
# DON'T
Enum.each(users, fn user ->
Repo.insert(%Post{user_id: user.id, title: "Welcome"})
end)
# DO
posts = Enum.map(users, fn user ->
%Post{user_id: user.id, title: "Welcome"}
end)
Repo.insert_all(Post, posts)
Preload Associations
# DON'T: N+1 queries
users = Repo.all(User)
Enum.map(users, fn user ->
user = Repo.preload(user, :posts)
# Use user.posts
end)
# DO: Single query
users = User |> Repo.all() |> Repo.preload(:posts)
Common Anti-patterns
Avoid If/Else Chains
# DON'T
def handle(type) do
if type == :a do
handle_a()
else
if type == :b do
handle_b()
else
if type == :c do
handle_c()
else
:error
end
end
end
end
# DO
def handle(:a), do: handle_a()
def handle(:b), do: handle_b()
def handle(:c), do: handle_c()
def handle(_), do: :error
Avoid Deeply Nested Code
# DON'T
def process(data) do
if valid?(data) do
result = transform(data)
if result.status == :ok do
if can_save?(result) do
save(result)
end
end
end
end
# DO
def process(data) do
with :ok <- validate(data),
{:ok, result} <- transform(data),
:ok <- check_can_save(result) do
save(result)
end
end
Don't Abuse Macros
# DON'T: Macro when function would work
defmacro add(a, b) do
quote do
unquote(a) + unquote(b)
end
end
# DO: Use function
def add(a, b), do: a + b
# Use macros for DSLs and compile-time code generation
defmacro test(description, do: block) do
# ...
end
Code Formatting
Use mix format
# Format all files
mix format
# Check if formatted
mix format --check-formatted
Configure formatter
# .formatter.exs
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
line_length: 100,
import_deps: [:ecto, :phoenix]
]
Security Best Practices
Never Trust User Input
# DO: Validate and sanitize
def create_post(attrs) do
%Post{}
|> Post.changeset(attrs)
|> Repo.insert()
end
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :body])
|> validate_required([:title, :body])
|> validate_length(:title, max: 255)
end
Use Parameterized Queries
# DON'T: SQL injection risk
Repo.query("SELECT * FROM users WHERE email = '#{email}'")
# DO
Repo.query("SELECT * FROM users WHERE email = $1", [email])
# Better: Use Ecto
User |> where(email: ^email) |> Repo.all()
Store Secrets Securely
# DON'T: Hardcode secrets
@api_key "secret_key_12345"
# DO: Use environment variables
@api_key System.get_env("API_KEY")
# Or runtime config
config :myapp, api_key: System.get_env("API_KEY")
Practical Examples
Well-Structured Context
defmodule MyApp.Blog do
@moduledoc """
The Blog context.
"""
import Ecto.Query
alias MyApp.Repo
alias MyApp.Blog.Post
@doc """
Returns the list of posts.
"""
def list_posts do
Post
|> order_by(desc: :inserted_at)
|> Repo.all()
end
@doc """
Gets a single post.
Raises `Ecto.NoResultsError` if the Post does not exist.
"""
def get_post!(id), do: Repo.get!(Post, id)
@doc """
Creates a post.
"""
def create_post(attrs \\ %{}) do
%Post{}
|> Post.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a post.
"""
def update_post(%Post{} = post, attrs) do
post
|> Post.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a post.
"""
def delete_post(%Post{} = post) do
Repo.delete(post)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking post changes.
"""
def change_post(%Post{} = post, attrs \\ %{}) do
Post.changeset(post, attrs)
end
end
Idiomatic GenServer
defmodule MyApp.Cache do
use GenServer
require Logger
# Client API
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def get(key) do
GenServer.call(__MODULE__, {:get, key})
end
def put(key, value) do
GenServer.cast(__MODULE__, {:put, key, value})
end
def clear do
GenServer.cast(__MODULE__, :clear)
end
# Server Callbacks
@impl true
def init(_opts) do
Logger.info("Starting cache")
{:ok, %{}}
end
@impl true
def handle_call({:get, key}, _from, state) do
{:reply, Map.get(state, key), state}
end
@impl true
def handle_cast({:put, key, value}, state) do
{:noreply, Map.put(state, key, value)}
end
def handle_cast(:clear, _state) do
{:noreply, %{}}
end
@impl true
def terminate(reason, _state) do
Logger.info("Cache stopping: #{inspect(reason)}")
:ok
end
end
Exercises
Refactor a function that uses if/else chains to use pattern matching
Convert nested case statements into a
withexpressionIdentify and fix anti-patterns in a provided code sample
Write proper documentation with examples for a module
Optimize a function that performs N+1 database queries
# Solutions
# 1. Pattern matching refactor
# Before
def handle_response(response) do
if response.status == 200 do
{:ok, response.body}
else
if response.status == 404 do
{:error, :not_found}
else
{:error, :unknown}
end
end
end
# After
def handle_response(%{status: 200, body: body}), do: {:ok, body}
def handle_response(%{status: 404}), do: {:error, :not_found}
def handle_response(_), do: {:error, :unknown}
# 2. with expression
# Before
def process(data) do
case validate(data) do
{:ok, validated} ->
case transform(validated) do
{:ok, transformed} ->
case save(transformed) do
{:ok, saved} -> {:ok, saved}
error -> error
end
error -> error
end
error -> error
end
end
# After
def process(data) do
with {:ok, validated} <- validate(data),
{:ok, transformed} <- transform(validated),
{:ok, saved} <- save(transformed) do
{:ok, saved}
end
end
# 3. Fix anti-patterns
# Before
defmodule BadExample do
@count 0 # Won't work!
def increment, do: @count = @count + 1
def process(list) do
result = Enum.map(list, fn x ->
if x > 0 do
if x < 100 do
x * 2
else
x
end
else
0
end
end)
result
end
end
# After
defmodule GoodExample do
use GenServer
def start_link(initial) do
GenServer.start_link(__MODULE__, initial, name: __MODULE__)
end
def increment do
GenServer.cast(__MODULE__, :increment)
end
def process(list) do
Enum.map(list, &calculate/1)
end
defp calculate(x) when x > 0 and x < 100, do: x * 2
defp calculate(x) when x >= 100, do: x
defp calculate(_x), do: 0
# GenServer callbacks
def init(initial), do: {:ok, initial}
def handle_cast(:increment, state), do: {:noreply, state + 1}
end
# 4. Documentation
defmodule MyApp.Calculator do
@moduledoc """
Provides basic mathematical operations.
## Examples
iex> Calculator.add(1, 2)
3
iex> Calculator.divide(10, 2)
{:ok, 5.0}
"""
@doc """
Adds two numbers.
## Examples
iex> Calculator.add(2, 3)
5
iex> Calculator.add(-1, 1)
0
"""
@spec add(number(), number()) :: number()
def add(a, b), do: a + b
@doc """
Divides two numbers.
Returns `{:ok, result}` on success or `{:error, :division_by_zero}`.
## Examples
iex> Calculator.divide(10, 2)
{:ok, 5.0}
iex> Calculator.divide(10, 0)
{:error, :division_by_zero}
"""
@spec divide(number(), number()) :: {:ok, float()} | {:error, :division_by_zero}
def divide(_a, 0), do: {:error, :division_by_zero}
def divide(a, b), do: {:ok, a / b}
end
# 5. Fix N+1 queries
# Before (N+1 problem)
def list_users_with_posts do
users = Repo.all(User)
Enum.map(users, fn user ->
posts = Repo.all(from p in Post, where: p.user_id == ^user.id)
%{user | posts: posts}
end)
end
# After (single query with preload)
def list_users_with_posts do
User
|> Repo.all()
|> Repo.preload(:posts)
end
# Or with query
def list_users_with_posts do
from(u in User, preload: [:posts])
|> Repo.all()
end
Summary
The principles, in order:
- Embrace immutability. Don't fight it.
- Pattern match early. Use function heads.
- Let it crash. Trust supervisors.
- Keep functions small. One responsibility each.
- Use pipes for transformations, not for every call.
- Test behaviour, not implementation.
- Document thoroughly. Your future self is the audience.
- Run
mix formatbefore committing. - Use OTP. Don't reinvent supervision.
- Think in processes when the domain is concurrent.
Additional Resources
- Elixir Style Guide
- Credo, static code analysis
- Dialyxir, type checking
- Elixir Forum
- Elixir Slack
Where to Go From Here
You have the foundations: syntax, OTP, Ecto, Phoenix, ExUnit, and the conventions that hold them together. To go deeper:
- Build something real. A small Phoenix app with auth and a few LiveViews teaches more than another chapter of theory.
- Read "Programming Elixir" (Thomas) and "Designing Elixir Systems with OTP" (Wlaschin / Tate) once you've shipped a project or two.
- Contribute to an open-source library. Hex has thousands.
- Join Elixir Forum or the Elixir Slack and lurk for a while. The community writes well.
- Try a non-Phoenix domain: Nerves for embedded, Membrane for media, Broadway for data pipelines.
The language is small enough to fit in your head and large enough to keep surprising you. That's the whole pitch.