Elixir has a sharper gap between working code and idiomatic code than most languages. AI assistants can write Elixir that compiles and passes tests — but misses the OTP patterns, function head dispatch, supervision trees, and pipe conventions that experienced Elixir developers use as a matter of course.
The result: code that works in dev, breaks under load, and doesn't behave like the Elixir ecosystem expects.
A CLAUDE.md at your project root closes this gap. Here are the 13 rules with the highest impact.
Rule 1: OTP first — processes, not objects
This is an OTP application. Model concurrent behavior with:
- GenServer for stateful processes
- Supervisor for fault tolerance and restart strategies
- Task for one-off concurrent work
- Agent for simple shared state (prefer GenServer for anything non-trivial)
Do NOT model concurrency with shared mutable state, mutexes, or global variables.
The process is the unit of concurrency and isolation.
Elixir's concurrency model is built on the Actor model via OTP. AI without this context tends to reach for abstractions from other languages. GenServer + Supervisor is how Elixir systems are built — this needs to be explicit.
Rule 2: Function head dispatch — not cond or case at the top
Use pattern matching in function heads for branching:
def process(%{status: :active} = user), do: ...
def process(%{status: :inactive} = user), do: ...
def process(%{status: status}), do: {:error, "unknown status: #{status}"}
Not:
def process(user) do
cond do
user.status == :active -> ...
user.status == :inactive -> ...
end
end
Reserve `case` for local branching within a function. Reserve `cond` for boolean expressions without a matching value.
This is the most common idiom gap. AI defaults to cond or case at the function level when function head dispatch is cleaner and more idiomatic. Elixir developers read function head dispatch as the primary dispatch mechanism.
Rule 3: Pipe operator discipline
Pipe rules:
- Use `|>` when transforming data through 3+ steps
- Each pipe step should be a single operation (avoid anonymous functions in pipes unless necessary)
- The first argument of every piped function must be the data being transformed
- Don't pipe into `IO.inspect/2` in production code — use Logger
- Break long pipes into named intermediate values when readability suffers
Avoid:
result = transform(filter(validate(data))) # nest style
Prefer:
result = data
|> validate()
|> filter()
|> transform()
The pipe operator is central to Elixir's readability. AI sometimes generates nested function calls or breaks the single-data-flow rule by adding extra arguments mid-pipe.
Rule 4: Pattern matching on with — not nested case
Use `with` for sequential operations where any step can fail:
with {:ok, user} <- fetch_user(id),
{:ok, account} <- fetch_account(user),
{:ok, _} <- charge(account, amount) do
{:ok, "charged"}
else
{:error, :not_found} -> {:error, "user not found"}
{:error, reason} -> {:error, reason}
end
Not nested case/if chains.
Return `{:ok, value}` and `{:error, reason}` tuples from all functions that can fail.
with is the idiomatic way to chain fallible operations. Nested case blocks grow quickly and are hard to extend. AI often generates nested case because it's the more universally recognizable pattern.
Rule 5: Supervision trees — crash and restart, don't defend
Supervision strategy:
- Use `:one_for_one` for independent workers
- Use `:one_for_all` when workers have shared state that becomes inconsistent on crash
- Use `:rest_for_one` when workers have ordered dependencies
- Start supervised processes in application.ex or a dedicated Supervisor module
- Do NOT rescue exceptions in GenServer callbacks to avoid crashes — let it crash, let the supervisor restart
The supervisor IS the error handler. Writing defensive rescue clauses in worker processes fights the design.
"Let it crash" is a core Elixir/Erlang philosophy that AI without guidance will work against. AI tends to add rescue blocks everywhere. The OTP pattern is to let processes crash and recover via supervision.
Rule 6: Ecto — changesets and queries
Database access: Ecto only.
- All data validation in changesets: `cast`, `validate_required`, `validate_format`
- Queries built with Ecto.Query DSL — no raw SQL except for complex queries using `fragment/1`
- No `Repo.get!` in business logic — use `Repo.get` and handle nil explicitly
- Preload associations explicitly: `Repo.preload(user, [:posts])` — no lazy loading
- Use `Repo.transaction` for operations that must be atomic
- Schema changesets are the single validation point — not controllers or context functions
Ecto's design is opinionated. AI without guidance uses get! everywhere (raising on nil), forgets preloads (causing N+1-equivalent issues), and puts validation in the wrong layer.
Rule 7: Contexts — bounded modules, not one schema one module
Organize with Phoenix contexts (even outside Phoenix):
- One context module per domain: `Accounts`, `Payments`, `Inventory`
- Context functions are the public API: `Accounts.get_user(id)`, `Accounts.create_user(attrs)`
- Schema modules are private to contexts — not called directly from controllers or other contexts
- No cross-context direct schema access — only through the owning context's API
This is the Phoenix 1.3+ architecture. Do not use the older one-schema-one-controller pattern.
Phoenix Contexts were introduced specifically to solve the "fat model" problem. AI trained on older Phoenix material generates the pre-context pattern. Specify the version explicitly.
Rule 8: Atoms — safe usage
Atom discipline:
- Never convert user-provided strings to atoms with `String.to_atom/1`
(atoms are not garbage collected — unlimited creation = memory leak / DoS vector)
- Use `String.to_existing_atom/1` when converting known strings to atoms
- Module names, function names, map keys in your own code: atoms are fine
- JSON parsing: use string keys by default, not atom keys (Jason default is correct)
String.to_atom/1 with user input is a security and stability vulnerability unique to Erlang/Elixir. AI generates it without this context. This rule needs to be explicit.
Rule 9: Message passing — send, receive, and GenServer calls
Process communication:
- Use `GenServer.call/3` for synchronous requests (returns a value)
- Use `GenServer.cast/2` for asynchronous fire-and-forget (no return)
- Use `send/2` + `receive` only for ad-hoc message passing not managed by OTP
- Always set timeouts on `call`: `GenServer.call(pid, msg, 5_000)`
- Handle unexpected messages in `handle_info/2` — don't let them accumulate in the mailbox
Never use `Process.sleep/1` for coordination — use `GenServer.call` or `Task.async/await`.
Process communication patterns are Elixir-specific. AI without guidance uses send/receive where GenServer is correct, or forgets to handle handle_info for system messages.
Rule 10: Testing with ExUnit — async and isolation
Testing:
- All tests: `use ExUnit.Case, async: true` unless they share global state (database)
- Database tests: use `Ecto.Adapters.SQL.Sandbox` for transaction isolation
- Test setup: `setup` blocks, not module attributes
- Use `assert {:ok, _} = MyModule.function(args)` — match on the structure
- Factory: ExMachina for test data, not hand-built maps
- No `Process.sleep` in tests — use `assert_receive` with a timeout for async assertions
- Mock external services with `Mox` — define behaviors and verify expectations
Elixir's async testing and Ecto sandbox require specific setup. AI without guidance creates sequential tests that run slowly or break isolation.
Rule 11: Telemetry and structured logging
Observability:
- Use `:telemetry` for emitting metrics from library/application code
- Use `Logger.metadata/1` to attach context to log entries: `Logger.metadata(user_id: id, request_id: req_id)`
- Log levels: debug (verbose dev), info (normal ops), warning (degraded), error (failure)
- No bare `IO.puts` or `IO.inspect` in production code — use Logger
- Attach telemetry handlers in application startup, not inline
Telemetry is the standard instrumentation library in the Elixir ecosystem. AI often uses IO.inspect for debugging without switching to Logger for production code.
Rule 12: Structs over bare maps for domain data
Domain data:
- Define structs for all domain entities: `defstruct [:id, :name, :email]`
- Use `@enforce_keys` for required fields: `@enforce_keys [:id, :name]`
- Structs provide compile-time key checking — bare maps do not
- Use `%User{}` not `%{id: ..., name: ...}` when the shape is known
- Typespec every public function: `@spec create_user(attrs :: map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}`
Structs with typespecs make dialyzer useful. AI often generates bare maps for domain data, losing the documentation and static analysis benefits.
Rule 13: Mix tasks and releases
Deployment:
- Use `mix release` for production deployments — not `mix run`
- Config: `config/runtime.exs` for runtime configuration (env vars read at startup)
`config/config.exs` for compile-time only
- Never hardcode secrets — use `System.fetch_env!/1` in `config/runtime.exs`
- Health checks: implement a simple HTTP endpoint, not a Mix task that shells out
- Migrations: always run with `--no-start` in CI: `mix ecto.migrate --no-start`
Release configuration is a common stumbling block. AI often suggests config/config.exs for runtime values, which means env vars are read at compile time and baked into the release — not what you want in Docker deployments.
Your CLAUDE.md starting point
# Elixir Project — AI Coding Rules
## Architecture
OTP application. GenServer + Supervisor for concurrency. Let it crash — supervisors handle recovery.
Contexts for domain boundaries. Schema modules private to their context.
## Patterns
Function head dispatch over cond/case at function level.
with for sequential fallible operations — {:ok, val} / {:error, reason} tuples everywhere.
Pipe operator for 3+ step data transforms.
## Ecto
Changesets for all validation. Explicit preloads. Repo.get not Repo.get!.
Raw SQL only via fragment/1. Transactions for atomic operations.
## Atoms
Never String.to_atom/1 on user input. String.to_existing_atom/1 for known strings only.
## Testing
async: true where possible. Ecto.Adapters.SQL.Sandbox for DB tests.
Mox for external service mocks. assert_receive for async. No Process.sleep in tests.
## Observability
Logger with metadata. Telemetry for metrics. No IO.puts/IO.inspect in production.
## Deployment
mix release. runtime.exs for env vars. System.fetch_env!/1 for secrets.
Why Elixir especially needs this
Elixir has unusually strong conventions — OTP patterns, the pipe operator, with, contexts — that diverge significantly from how imperative languages solve the same problems. AI trained on a broad corpus defaults to the more common patterns.
CLAUDE.md is how you tell the AI which decade and which paradigm it's working in.
The full rules pack across 13+ languages is at gumroad — $27.
Top comments (0)