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.

largelanguageplatform/agent-skills7 installsMITSynced Aug 26

Works with

Claude CodeCursorCodex CLIGitHub CopilotGemini CLI
---
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

← All Testing skills

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY