DEV Community

Olivia Craft
Olivia Craft

Posted on

CLAUDE.md for Elixir: 13 Rules That Make AI Write Idiomatic, OTP-Aware Elixir

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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

"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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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`.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()}`
Enter fullscreen mode Exit fullscreen mode

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`
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)