DEV Community

Olivia Craft
Olivia Craft

Posted on

CLAUDE.md for Elixir: 13 Rules That Make AI Write Idiomatic OTP Code

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

After:

def greet(%{role: :admin}), do: "Welcome back, admin"
def greet(%{role: :guest}), do: "Hello, guest"
def greet(%{name: name}), do: "Hi #{name}"
Enter fullscreen mode Exit fullscreen mode

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

After:

def total(items) when is_list(items) do
  Enum.reduce(items, 0, fn %{price: price}, acc -> acc + price end)
end
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

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

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

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

After:

require Logger

def process(order) do
  Logger.debug(fn -> "processing order #{order.id}" end)
  do_work(order)
end
Enter fullscreen mode Exit fullscreen mode

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

After:

def slugify(title) do
  title
  |> String.trim()
  |> String.downcase()
  |> String.replace(~r/\s+/, "-")
end
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

After:

def search(term) do
  pattern = "%#{term}%"

  from(u in User,
    where: fragment("name ILIKE ?", ^pattern)
  )
  |> Repo.all()
end
Enter fullscreen mode Exit fullscreen mode

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)