Best Practices
Master Elixir idioms, naming conventions, design patterns, and learn to write idiomatic, maintainable code.
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
Key principles for writing idiomatic Elixir:
- Embrace immutability - Don't fight it
- Pattern match early - Use function heads
- Let it crash - Trust supervisors
- Keep functions small - Single responsibility
- Use pipes wisely - For transformations
- Test behavior - Not implementation
- Document thoroughly - Future you will thank you
- Follow conventions - Use mix format
- Use OTP - Don't reinvent wheels
- Think in processes - Embrace concurrency
Additional Resources
- Elixir Style Guide
- Credo - Static code analysis
- Dialyxir - Type checking
- Elixir Forum
- Elixir Slack
Conclusion
You've completed the Elixir tutorial! You now have:
- Strong foundations in Elixir syntax and semantics
- Understanding of functional programming concepts
- Knowledge of OTP and building fault-tolerant systems
- Experience with Phoenix web framework
- Testing and documentation skills
- Best practices for writing maintainable code
Next Steps:
- Build real projects
- Contribute to open source
- Read "Programming Elixir" and "Designing Elixir Systems with OTP"
- Join the community
- Keep learning and experimenting!
Happy coding with Elixir! 🎉