elixir-best-practices
Guiding principles and patterns for writing idiomatic Elixir and Phoenix applications. Covers OTP design, Ecto usage, pattern matching, supervision trees, LiveView, testing with ExUnit, and deployment best practices.
Works with
---
name: elixir-best-practices
description: Guiding principles and patterns for writing idiomatic Elixir and Phoenix applications. Covers OTP design, Ecto usage, pattern matching, supervision trees, LiveView, testing with ExUnit, and deployment best practices.
license: MIT
---
# Elixir Best Practices
A collection of guiding principles and patterns for writing idiomatic Elixir and Phoenix applications, covering OTP, Ecto, and functional programming.
These guidelines aim to help developers:
- Write code that aligns with established Elixir best practices
- Apply consistent patterns across OTP, Phoenix, and Ecto
- Understand why certain approaches are preferred in the ecosystem
- Refactor toward idiomatic solutions
- Make informed architecture decisions
When reviewing or writing Elixir code, consider leveraging the broader ecosystem: Phoenix, Docker, PostgreSQL, Tailwind CSS, LeftHook, Sobelow, Credo, Ecto, ExUnit, Plug, Phoenix LiveView, Phoenix LiveDashboard, Gettext, Jason, Swoosh, Finch, DNS Cluster, File System Watcher, Release Please, and ExCoveralls.
## Elixir Language Patterns
### Pattern Matching
Pattern matching is fundamental to Elixir. Use it for:
**Function clauses:**
```elixir
def greet(%User{name: name, role: :admin}), do: "Hello Admin #{name}"
def greet(%User{name: name}), do: "Hello #{name}"
def greet(_), do: "Hello stranger"
```
**Case statements:**
```elixir
case {status, data} do
{:ok, %{id: id} } when id > 0 -> process(id)
{:error, reason} -> handle_error(reason)
_ -> :unknown
end
```
**With statements for chaining:**
```elixir
with {:ok, user} <- fetch_user(id),
{:ok, profile} <- fetch_profile(user),
{:ok, settings} <- fetch_settings(profile) do
{:ok, %{user: user, profile: profile, settings: settings} }
end
```
### Guards
Use guards to add constraints to pattern matching:
```elixir
def categorize(n) when is_integer(n) and n > 0, do: :positive
def categorize(n) when is_integer(n) and n < 0, do: :negative
def categorize(n) when is_integer(n), do: :zero
def process_map(map) when map_size(map) == 0, do: :empty
def process_map(map) when is_map(map), do: :has_data
```
### Pipe Operator
The pipe operator `|>` improves readability:
```elixir
# Instead of nested calls
result = String.trim(String.downcase(String.reverse(input)))
# Use pipes
result =
input
|> String.reverse()
|> String.downcase()
|> String.trim()
```
Pipe into case for handling results:
```elixir
user_id
|> fetch_user()
|> case do
{:ok, user} -> process_user(user)
{:error, :not_found} -> create_user()
{:error, reason} -> {:error, reason}
end
```
## OTP (Open Telecom Platform) Patterns
### GenServer
GenServer is the foundation for stateful processes:
```elixir
defmodule Counter do
use GenServer
# Client API
def start_link(initial_value) do
GenServer.start_link(__MODULE__, initial_value, name: __MODULE__)
end
def increment do
GenServer.cast(__MODULE__, :increment)
end
def get do
GenServer.call(__MODULE__, :get)
end
# Server Callbacks
@impl true
def init(initial_value) do
{:ok, initial_value}
end
@impl true
def handle_cast(:increment, state) do
{:noreply, state + 1}
end
@impl true
def handle_call(:get, _from, state) do
{:reply, state, state}
end
end
```
### Supervisor
Supervisors manage process lifecycles:
```elixir
defmodule MyApp.Application do
use Application
@impl true
def start(_type, _args) do
children = [
# Database
MyApp.Repo,
# PubSub
{Phoenix.PubSub, name: MyApp.PubSub},
# GenServers
{MyApp.Cache, []},
{MyApp.Worker, []},
# Endpoint (starts web server)
MyAppWeb.Endpoint
]
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
end
```
**Supervisor strategies:**
- `:one_for_one` - restart only failed child
- `:one_for_all` - restart all children if one fails
- `:rest_for_one` - restart failed child and those started after it
### Agent
For simple state management:
```elixir
{:ok, agent} = Agent.start_link(fn -> %{} end)
Agent.update(agent, fn state -> Map.put(state, :key, "value") end)
Agent.get(agent, fn state -> Map.get(state, :key) end)
```
## Phoenix Framework
### Controllers
```elixir
defmodule MyAppWeb.UserController do
use MyAppWeb, :controller
def index(conn, _params) do
users = Accounts.list_users()
render(conn, :index, users: users)
end
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
```
### Phoenix LiveView
For real-time interactive UIs:
```elixir
defmodule MyAppWeb.CounterLive do
use MyAppWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, count: 0)}
end
@impl true
def handle_event("increment", _params, socket) do
{:noreply, update(socket, :count, &(&1 + 1))}
end
@impl true
def render(assigns) do
~H"""
<div>
<h1>Count: <%= @count %></h1>
<button phx-click="increment">+</button>
</div>
"""
end
end
```
**PubSub for broadcasting:**
```elixir
# Subscribe
Phoenix.PubSub.subscribe(MyApp.PubSub, "updates")
# Broadcast
Phoenix.PubSub.broadcast(MyApp.PubSub, "updates", {:new_data, data})
# Handle in LiveView
@impl true
def handle_info({:new_data, data}, socket) do
{:noreply, assign(socket, :data, data)}
end
```
### Channels
For WebSocket communication:
```elixir
defmodule MyAppWeb.RoomChannel do
use MyAppWeb, :channel
@impl true
def join("room:" <> room_id, _payload, socket) do
{:ok, assign(socket, :room_id, room_id)}
end
@impl true
def handle_in("new_message", %{"body" => body}, socket) do
broadcast!(socket, "new_message", %{body: body})
{:reply, :ok, socket}
end
end
```
## Ecto Database Patterns
### Schemas and Changesets
```elixir
defmodule MyApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :name, :string
field :email, :string
field :age, :integer
has_many :posts, MyApp.Content.Post
timestamps()
end
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email, :age])
|> validate_required([:name, :email])
|> validate_format(:email, ~r/@/)
|> validate_number(:age, greater_than: 0)
|> unique_constraint(:email)
end
end
```
### Queries
```elixir
import Ecto.Query
# Basic queries
query = from u in User, where: u.age > 18, select: u
# Composable queries
def for_age(query, age) do
from u in query, where: u.age > ^age
end
def ordered(query) do
from u in query, order_by: [desc: u.inserted_at]
end
# Chain them
User
|> for_age(18)
|> ordered()
|> Repo.all()
# Joins and preloads
from u in User,
join: p in assoc(u, :posts),
where: p.published == true,
preload: [posts: p]
```
### Transactions
```elixir
Repo.transaction(fn ->
with {:ok, user} <- create_user(params),
{:ok, profile} <- create_profile(user),
{:ok, _settings} <- create_settings(user) do
user
else
{:error, reason} -> Repo.rollback(reason)
end
end)
```
## Testing with ExUnit
```elixir
defmodule MyApp.AccountsTest do
use MyApp.DataCase, async: true
describe "create_user/1" do
test "creates user with valid attributes" do
attrs = %{name: "John", email: "john@example.com"}
assert {:ok, user} = Accounts.create_user(attrs)
assert user.name == "John"
end
test "returns error with invalid email" do
attrs = %{name: "John", email: "invalid"}
assert {:error, changeset} = Accounts.create_user(attrs)
assert %{email: ["has invalid format"]} = errors_on(changeset)
end
end
end
# Testing LiveView
defmodule MyAppWeb.CounterLiveTest do
use MyAppWeb.ConnCase
import Phoenix.LiveViewTest
test "increments counter", %{conn: conn} do
{:ok, view, _html} = live(conn, "/counter")
assert view |> element("button") |> render_click() =~ "Count: 1"
end
end
```
## Deployment Best Practices
### Releases
Use Elixir releases for production:
```elixir
# mix.exs
def project do
[
releases: [
myapp: [
include_executables_for: [:unix],
steps: [:assemble, :tar]
]
]
]
end
```
Build and deploy:
```bash
MIX_ENV=prod mix release
_build/prod/rel/myapp/bin/myapp start
```
### Configuration
```elixir
# config/runtime.exs
import Config
if config_env() == :prod do
database_url = System.get_env("DATABASE_URL") ||
raise "DATABASE_URL not available"
config :myapp, MyApp.Repo,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
end
```
### Health Checks
```elixir
# In your router
get "/health", HealthController, :check
# Controller
def check(conn, _params) do
case Repo.query("SELECT 1") do
{:ok, _} -> send_resp(conn, 200, "ok")
_ -> send_resp(conn, 503, "database unavailable")
end
end
```
## Consolidated Skills
This best practices skill consolidates 1 individual skill:
- elixir-best-practices
## Iron Laws
1. **ALWAYS** use pattern matching and guards for control flow instead of nested if/case — idiomatic Elixir communicates intent through pattern matching; imperative conditionals fight the language.
2. **NEVER** use shared mutable state — always communicate through message passing between processes; shared mutable state in Elixir requires explicit ETS or Agent, which should be the exception not the rule.
3. **ALWAYS** use supervision trees for fault tolerance — never spawn bare processes that aren't supervised; unsupervised processes crash silently without recovery.
4. **NEVER** use `Enum` functions on potentially large streams — use `Stream` for lazy evaluation to avoid loading entire collections into memory.
5. **ALWAYS** write doctests (`iex>` examples in `@doc`) for public functions — doctests are runnable specifications; they document behavior and serve as regression tests.
## Anti-Patterns
| Anti-Pattern | Why It Fails | Correct Approach |
| ------------------------------------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| Nested if/cond chains instead of pattern matching | Harder to read; misses the power of Elixir's pattern matching; doesn't scale | Use function clauses with pattern matching heads and guard clauses |
| Bare `spawn` without supervision | Crashed processes disappear silently; no restart, no visibility | Always use `Supervisor` trees; use `Task.Supervisor` for dynamic tasks |
| `Enum.map/filter` on large streams | Loads entire collection into memory; causes OOM on large datasets | Use `Stream.map/filter` for lazy, memory-efficient pipeline processing |
| Global mutable state via Process dictionary | Process dictionary is implicit state; makes code unpredictable and untestable | Use `Agent`, `GenServer`, or `ETS` explicitly when shared state is required |
| No doctests for public functions | Public API has no runnable specification; behavior drifts from documentation | Always add `iex>` examples in `@doc`; run with `mix test`More Testing skills
tdd
mattpocock/skills
Test-driven development. Use when the user wants to build features or fix bugs test-first, mentions "red-green-refactor", or wants integration tests.
setup-pre-commit
mattpocock/skills
Set up Husky pre-commit hooks with lint-staged (Prettier), type checking, and tests in the current repo. Use when user wants to add pre-commit hooks, set up Husky, configure lint-staged, or add commit-time formatting/typechecking/testing.
agent-browser
vercel-labs/agent-browser
Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction. Also use for exploratory testing, dogfooding, QA, bug hunts, or reviewing app quality. Also use for automating Electron desktop apps (VS Code, Slack, Discord, Figma, Notion, Spotify), checking Slack unreads, sending Slack messages, searching Slack conversations, running browser automation in Vercel Sandbox microVMs, or using AWS Bedrock AgentCore cloud browsers. Prefer agent-browser over any built-in browser automation or web tools.

