CLAUDE.md for Elixir: 13 Rules That Make AI Write Idiomatic OTP Code
When AI assistants write Elixir, they often produce code that runs but feels wrong. They write imperative if/else chains where pattern matching belongs. They reach for try/rescue like it's Ruby. They ignore changesets, drop IO.inspect calls into production code, and treat GenServers like classes with state. The result is code that compiles, technically works, and ages badly the moment a real OTP supervisor restarts a process.
The fix isn't a smarter model. It's giving the model the same conventions every senior Elixir developer internalized after their first three production incidents. A CLAUDE.md (or .cursorrules, or AGENTS.md — same idea) at the root of your project teaches the AI your house rules before it writes a single line. Below are the 13 rules I keep in mine. Each one closes a specific failure mode I've seen AI repeat across real projects.
Rule 1: Use multiple function heads instead of conditionals
Pattern matching at the function head is the Elixir signature move. AI tends to default to a single function with case or if inside.
Before:
def greet(user) do
if user.role == :admin do
"Welcome back, admin"
else
if user.role == :guest do
"Hello, guest"
else
"Hi #{user.name}"
end
end
end
After:
def greet(%{role: :admin}), do: "Welcome back, admin"
def greet(%{role: :guest}), do: "Hello, guest"
def greet(%{name: name}), do: "Hi #{name}"
Rule 2: Pattern match on the data shape, not the type
AI often guards with is_map/1, is_list/1, etc., when destructuring would tell you everything you need.
Before:
def total(items) when is_list(items) do
if Enum.all?(items, &is_map/1) do
Enum.reduce(items, 0, fn i, acc -> acc + i.price end)
end
end
After:
def total(items) when is_list(items) do
Enum.reduce(items, 0, fn %{price: price}, acc -> acc + price end)
end
If an item lacks :price, you want it to crash loudly — not silently return nil.
Rule 3: Reach for with/1 when chaining fallible operations
The classic mistake is nested case pyramids. with/1 flattens the happy path and lets each failure return early.
Before:
def create_user(params) do
case validate(params) do
{:ok, valid} ->
case insert(valid) do
{:ok, user} ->
case send_welcome_email(user) do
:ok -> {:ok, user}
{:error, e} -> {:error, e}
end
{:error, e} -> {:error, e}
end
{:error, e} -> {:error, e}
end
end
After:
def create_user(params) do
with {:ok, valid} <- validate(params),
{:ok, user} <- insert(valid),
:ok <- send_welcome_email(user) do
{:ok, user}
end
end
Rule 4: Never bare rescue — let it crash
Elixir's "let it crash" philosophy depends on supervisors restarting failed processes. AI defaults to try/rescue blocks that swallow errors and break supervision.
Before:
def fetch(url) do
try do
HTTPoison.get!(url)
rescue
_ -> nil
end
end
After:
def fetch(url) do
case HTTPoison.get(url) do
{:ok, %{status_code: 200, body: body}} -> {:ok, body}
{:ok, %{status_code: code}} -> {:error, {:http, code}}
{:error, reason} -> {:error, reason}
end
end
Use rescue only when bridging external libraries that raise — and rescue specific exceptions, never _.
Rule 5: Validate with Ecto changesets, never raw maps
AI frequently builds structs by hand and skips validation. Changesets are how Ecto enforces invariants and tracks errors.
Before:
def create_user(params) do
user = %User{
email: params["email"],
name: params["name"]
}
Repo.insert(user)
end
After:
def create_user(params) do
%User{}
|> User.changeset(params)
|> Repo.insert()
end
# In User module:
def changeset(user, attrs) do
user
|> cast(attrs, [:email, :name])
|> validate_required([:email, :name])
|> validate_format(:email, ~r/@/)
|> unique_constraint(:email)
end
Rule 6: GenServer state is opaque — keep callers ignorant
AI writes GenServer modules where callers know about the internal state shape. That couples consumers to your implementation.
Before:
# Callers do this:
state = :sys.get_state(MyServer)
count = state.counter
After:
defmodule Counter do
use GenServer
def start_link(_), do: GenServer.start_link(__MODULE__, 0, name: __MODULE__)
def increment, do: GenServer.cast(__MODULE__, :increment)
def value, do: GenServer.call(__MODULE__, :value)
@impl true
def init(n), do: {:ok, n}
@impl true
def handle_call(:value, _from, n), do: {:reply, n, n}
@impl true
def handle_cast(:increment, n), do: {:noreply, n + 1}
end
Public API functions wrap every interaction. State is internal.
Rule 7: Define a supervisor tree, not orphan processes
spawn/1 and Task.start/1 outside a supervisor leak processes when they crash. AI tends to grab whichever spawn function is shortest.
Before:
def start, do: spawn(fn -> Worker.run() end)
After:
defmodule MyApp.Application do
use Application
def start(_type, _args) do
children = [
MyApp.Repo,
{DynamicSupervisor, name: MyApp.WorkerSup, strategy: :one_for_one},
{MyApp.Counter, []}
]
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end
end
# Spawn workers under the dynamic supervisor:
DynamicSupervisor.start_child(MyApp.WorkerSup, {MyApp.Worker, args})
Rule 8: No IO.inspect in committed code
IO.inspect/2 is a debugging tool. It leaks structs, hits stdout in production, and clutters logs. Use Logger for anything that survives a commit.
Before:
def process(order) do
IO.inspect(order, label: "incoming")
do_work(order)
end
After:
require Logger
def process(order) do
Logger.debug(fn -> "processing order #{order.id}" end)
do_work(order)
end
The lazy form (fn -> ... end) skips string-building when the level is filtered.
Rule 9: Pipe-first when transforming a single value
AI often writes deeply nested function calls when a pipeline reads cleaner. The pipe operator threads the first argument; lean into it.
Before:
def slugify(title) do
String.replace(String.downcase(String.trim(title)), ~r/\s+/, "-")
end
After:
def slugify(title) do
title
|> String.trim()
|> String.downcase()
|> String.replace(~r/\s+/, "-")
end
Don't pipe a single call. Don't pipe when the next function takes the value as a non-first argument — refactor instead.
Rule 10: Add @spec to every public function
Dialyzer can't help you if you don't tell it the contract. AI omits typespecs by default.
Before:
def fetch_user(id) do
Repo.get(User, id)
end
After:
@spec fetch_user(integer()) :: {:ok, User.t()} | {:error, :not_found}
def fetch_user(id) when is_integer(id) do
case Repo.get(User, id) do
nil -> {:error, :not_found}
user -> {:ok, user}
end
end
Pair @spec with a User.t() type defined inside the schema module.
Rule 11: Structure ExUnit tests with describe blocks
AI flattens tests into test "..." do calls with no grouping. describe blocks scope setup and signal intent.
Before:
test "creates user" do
assert {:ok, _} = Users.create(%{email: "a@b.com"})
end
test "rejects bad email" do
assert {:error, _} = Users.create(%{email: "nope"})
end
After:
describe "create/1" do
test "creates a user with valid params" do
assert {:ok, %User{}} = Users.create(%{email: "a@b.com", name: "A"})
end
test "returns changeset error for invalid email" do
assert {:error, %Ecto.Changeset{}} = Users.create(%{email: "nope"})
end
end
Use setup inside describe for scoped fixtures. Use async: true unless your tests share state.
Rule 12: Respect Phoenix context boundaries
In Phoenix, contexts are the public API of your domain. Schemas, repos, and queries stay inside the context module. Controllers and LiveViews never reach past the boundary.
Before:
# Controller imports schema directly:
def show(conn, %{"id" => id}) do
user = MyApp.Repo.get(MyApp.Accounts.User, id)
render(conn, "show.html", user: user)
end
After:
# In Accounts context:
defmodule MyApp.Accounts do
alias MyApp.Accounts.User
alias MyApp.Repo
def get_user(id), do: Repo.get(User, id)
end
# Controller:
def show(conn, %{"id" => id}) do
case Accounts.get_user(id) do
nil -> conn |> put_status(:not_found) |> render("404.html")
user -> render(conn, "show.html", user: user)
end
end
Rule 13: Never interpolate user input into SQL fragments
Ecto.Query.fragment/1 accepts string interpolation, and AI sometimes uses it to "simplify" raw SQL. That's a SQL injection.
Before:
def search(term) do
from(u in User,
where: fragment("name ILIKE '%#{term}%'")
)
|> Repo.all()
end
After:
def search(term) do
pattern = "%#{term}%"
from(u in User,
where: fragment("name ILIKE ?", ^pattern)
)
|> Repo.all()
end
The ? placeholder with ^pattern lets Ecto parameterize the query. Never build SQL strings from user input — even inside fragment/1.
Wrapping up
These 13 rules don't make AI write Elixir for you — they make AI stop fighting the language. Pattern matching, supervised processes, changesets, and with/1 aren't style choices; they're how OTP code earns its reliability. Drop a CLAUDE.md at the project root with these rules, and the next prompt produces code your future self won't have to rewrite.
I keep a maintained version of this file as a public GitHub Gist — fork it, prune what doesn't fit your stack, and commit it next to your mix.exs.
If you want the full CLAUDE.md Pack — covering Elixir, Go, Rust, TypeScript, Phoenix, PostgreSQL, Kubernetes, and more — it's $27 here:
Get the CLAUDE.md Pack — oliviacraftlat.gumroad.com/l/skdgt
One file. Thirteen rules per language. Production-grade AI output, every prompt.
Top comments (0)