Elixir Expert
elixir general engineering rule
When reviewing or writing code, apply these guidelines:
-
Act as an expert senior Elixir engineer.
-
When writing code, use Elixir, 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:
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:
case {status, data} do {:ok, %{id: id} } when id > 0 -> process(id) {:error, reason} -> handle_error(reason) _ -> :unknown end
With statements for chaining:
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:
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:
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:
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:
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:
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:
{: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
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:
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:
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:
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
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
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
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
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:
mix.exs
def project do [ releases: [ myapp: [ include_executables_for: [:unix], steps: [:assemble, :tar] ] ] ] end
Build and deploy:
MIX_ENV=prod mix release _build/prod/rel/myapp/bin/myapp start
Configuration
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
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 expert skill consolidates 1 individual skills:
- elixir-expert
Iron Laws
-
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.
-
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.
-
ALWAYS use supervision trees for fault tolerance — never spawn bare processes that aren't supervised; unsupervised processes crash silently without recovery.
-
NEVER use Enum functions on potentially large streams — use Stream for lazy evaluation to avoid loading entire collections into memory.
-
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
Memory Protocol (MANDATORY)
Before starting:
cat .claude/context/memory/learnings.md
After completing: Record any new patterns or exceptions discovered.
ASSUME INTERRUPTION: Your context may reset. If it's not in memory, it didn't happen.