Claude Code with Elixir: Phoenix, LiveView, OTP, and Ecto
Using Claude Code with Elixir without fighting the BEAM
Elixir occupies a specific position in the language landscape. It is functional, pattern-matched, process-oriented, and built on decades of Erlang BEAM reliability. It is also the language where the distance between "Claude knows Elixir" and "Claude writes Elixir that a senior Elixir developer would recognise" is the widest of any popular backend language.
The problem is training data distribution. The internet contains vastly more Python, JavaScript, and Ruby examples than Elixir. Claude knows the Elixir standard library, Phoenix, and OTP conceptually, but its defaults lean toward imperative patterns. It writes if chains instead of pattern-matching function clauses. It inverts pipeline order. It misses changeset composition. It generates GenServer callbacks with wrong arities.
None of these are unfixable. Every failure mode has a CLAUDE.md rule that prevents it. This guide covers the configuration that closes the gap: functional idioms, OTP supervision, GenServer, Ecto, LiveView, channels, and ExUnit. If you have not yet installed Claude Code, the Claude Code setup guide covers installation before any of this applies.
The Elixir CLAUDE.md template
Place this CLAUDE.md at the root of your Phoenix project. Claude reads it at the start of every session and uses it as the ground truth for project conventions. Adjust version numbers and dependencies to match your actual mix.exs.
# Elixir / Phoenix project rules
## Runtime and versions
- Elixir: 1.17
- OTP: 27
- Phoenix: 1.7
- Database: PostgreSQL 15, accessed via Ecto 3.11
- Node: not used (Phoenix LiveView, no separate JS build)
- Run: `mix compile` to check, `mix test` to run tests
## Project structure
- Application entry: lib/my_app/application.ex (supervision tree root)
- Business logic: lib/my_app/ (contexts, schemas, workers)
- Web layer: lib/my_app_web/ (controllers, live views, channels, components)
- Contexts: one module per bounded context, e.g. MyApp.Accounts, MyApp.Orders
- Schemas: MyApp.Accounts.User, not MyApp.User
- Tests: test/ mirrors lib/ structure
- Fixtures: test/support/fixtures/, one module per context
## Functional style rules
- Pattern matching over if/else. Use function clauses to dispatch on values.
- with-chains for happy-path multi-step flows. Return {:error, reason} on failure.
- |> (pipe) for data transformation. Never rebind a variable when a pipe reads clearly.
- No nested case expressions deeper than 2 levels. Extract a private function instead.
- No mutation. No rebinding of variables in loops. Prefer Enum and Stream.
- Atoms for status tuples: {:ok, value} and {:error, reason} only. No truthy/nil returns.
## Mix commands
- Check: `mix compile --warnings-as-errors`
- Test: `mix test` (all), `mix test test/my_app/accounts_test.exs` (single file)
- Format: `mix format` before every commit
- Lint: `mix credo --strict`
- Deps: `mix deps.get` after modifying mix.exs, never manually edit mix.lock
## Hard rules
- No IO.inspect or IO.puts in non-test code. Use Logger.
- No raw SQL in contexts. Use Ecto query API.
- No GenServer calls from inside another GenServer call (deadlock risk).
- No Process.sleep in production code.
- All public functions have @doc and @spec annotations.
- mix format and mix credo must pass before marking any task complete.
The functional style rules section is the highest-leverage part. Claude's default when generating multi-branch logic is if with nested cond. With "pattern matching over if/else" explicit in CLAUDE.md, Claude generates function clause dispatch instead.
Functional patterns: what to enforce in CLAUDE.md
Claude Code's biggest Elixir failure mode is writing code that works but is not idiomatic. It compiles, the tests pass, and a code review flags it immediately.
The three most common non-idiomatic outputs and their CLAUDE.md fixes:
Pattern 1: if-else instead of function clauses.
Without configuration, Claude generates:
def process_order(order) do
if order.status == :pending do
charge_customer(order)
else if order.status == :paid do
fulfil_order(order)
else
{:error, :invalid_status}
end
end
With the pattern-matching rule in CLAUDE.md, Claude generates:
def process_order(%{status: :pending} = order), do: charge_customer(order)
def process_order(%{status: :paid} = order), do: fulfil_order(order)
def process_order(%{status: status}), do: {:error, {:invalid_status, status}}
The second version is shorter, exhaustive at the clause level, and lets the compiler warn on missing patterns if the struct has guards.
Pattern 2: case nesting instead of with.
Claude nests case expressions to handle multi-step operations:
case fetch_user(user_id) do
{:ok, user} ->
case validate_plan(user) do
{:ok, plan} ->
case charge(user, plan) do
{:ok, receipt} -> {:ok, receipt}
{:error, reason} -> {:error, reason}
end
{:error, reason} -> {:error, reason}
end
{:error, reason} -> {:error, reason}
end
The idiomatic form uses with:
with {:ok, user} <- fetch_user(user_id),
{:ok, plan} <- validate_plan(user),
{:ok, receipt} <- charge(user, plan) do
{:ok, receipt}
else
{:error, reason} -> {:error, reason}
end
Add to CLAUDE.md: "Use with-chains for multi-step happy-path flows where each step returns {:ok, value} or {:error, reason}. Never nest case expressions deeper than 2 levels."
Pattern 3: pipe order.
Claude occasionally reverses the data flow in pipes, placing the transformation before the subject:
# Wrong: map before the collection
String.upcase |> Enum.map(names)
Correct form:
names |> Enum.map(&String.upcase/1)
The rule in CLAUDE.md: "The subject of transformation is always the first argument in a pipe chain. Data flows left to right through |>." For more on project-level rules that shape all of Claude's output, the CLAUDE.md explained guide covers structure and scope.
OTP supervision trees
Claude Code knows OTP conceptually but generates supervision trees without considering restart strategies, process naming, or the separation between static and dynamic supervisors.
Add an OTP section to your CLAUDE.md:
## OTP conventions
### Application supervisor (lib/my_app/application.ex)
def start(_type, _args) do
children = [
MyApp.Repo,
{Phoenix.PubSub, name: MyApp.PubSub},
MyAppWeb.Endpoint,
MyApp.Workers.Scheduler
]
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
### Supervision strategies
- :one_for_one: default for independent workers
- :one_for_all: only when workers share state that becomes invalid on any failure
- :rest_for_one: ordered start/stop dependency chains
### Dynamic supervisors
- Use DynamicSupervisor for runtime-spawned children (per-user processes, per-job workers)
- Register with a name based on a stable key, never a PID
- MyApp.GameSupervisor for domain-named supervisors
### Named process registry
- Use Registry for named process lookup in dynamic trees
- {:via, Registry, {MyApp.Registry, key}} as the name option
- Never use Process.register for domain processes (global atom table pollution)
The one_for_one versus one_for_all distinction is where Claude picks incorrectly most often. It defaults to one_for_all for groups of workers that share a database connection, even when the correct answer is one_for_one with independent crash boundaries. The explicit rule gives Claude the decision logic.
The Registry rule matters for LiveView applications with many concurrent user sessions. Claude will sometimes use named processes with atom-based names (e.g., MyApp.Session.user_123), which exhausts the atom table under load. The Registry pattern uses strings as keys and avoids this.
GenServer patterns
GenServer is where Claude produces the most structurally incorrect code without guidance. Callback arities, return tuples, and the init contract all have specific requirements that Claude's training data does not always reinforce correctly.
Add to CLAUDE.md:
## GenServer conventions
### Module structure
defmodule MyApp.Workers.Counter do
use GenServer
# Public API (client functions)
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
def increment(pid), do: GenServer.cast(pid, :increment)
def value(pid), do: GenServer.call(pid, :value)
# Server callbacks
@impl true
def init(opts) do
initial = Keyword.get(opts, :initial, 0)
{:ok, %{count: initial}}
end
@impl true
def handle_call(:value, _from, state) do
{:reply, state.count, state}
end
@impl true
def handle_cast(:increment, state) do
{:noreply, %{state | count: state.count + 1}}
end
@impl true
def handle_info(:tick, state) do
schedule_tick()
{:noreply, state}
end
defp schedule_tick, do: Process.send_after(self(), :tick, 1_000)
end
### Return tuple rules
- init: {:ok, state} or {:stop, reason}
- handle_call: {:reply, reply, state} or {:noreply, state} (for async reply)
- handle_cast: {:noreply, state}
- handle_info: {:noreply, state}
- All callbacks: add {:stop, reason, state} only for intentional shutdown
### Hard rules
- Always use @impl true before every callback
- Separate public API from server callbacks with a comment
- Never call GenServer.call from a handle_call (deadlock)
- State is always a map or struct, never a bare value
The @impl true annotation is the most important rule here. It causes the compiler to warn when a callback is misnamed or has the wrong arity, which is the most common GenServer mistake (writing handle_cast/2 with the wrong signature). Without @impl true, the compiler accepts incorrectly named functions as private helpers and the callback never fires.
The "state is always a map or struct" rule prevents a category of GenServer code that works for small state but becomes unreadable as it grows. Claude will sometimes generate bare tuples or keyword lists as state. A map with explicit keys is always the right starting point.
Ecto: schemas, changesets, and queries
Ecto's changeset pattern is different enough from ActiveRecord or Django ORM that Claude needs explicit guidance. Without it, Claude generates schemas with embedded validation logic, changesets that bypass the changeset function, and queries with N+1 patterns.
Add to CLAUDE.md:
## Ecto conventions
### Schema structure
defmodule MyApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :name, :string
field :role, Ecto.Enum, values: [:user, :admin]
field :password, :string, virtual: true
field :password_hash, :string
has_many :orders, MyApp.Orders.Order
belongs_to :team, MyApp.Accounts.Team
timestamps()
end
def changeset(user, attrs) do
user
|> cast(attrs, [:email, :name, :role, :password])
|> validate_required([:email, :name])
|> validate_format(:email, ~r/@/)
|> validate_length(:password, min: 8)
|> unique_constraint(:email)
|> put_password_hash()
end
defp put_password_hash(%Ecto.Changeset{valid?: true, changes: %{password: pw}} = cs) do
change(cs, password_hash: Argon2.hash_pwd_salt(pw))
end
defp put_password_hash(changeset), do: changeset
end
### Query conventions
import Ecto.Query
# Named query functions on the schema module
def active_users do
from u in User,
where: u.deactivated_at is nil,
order_by: [asc: u.inserted_at]
end
# Preload associations for views
def with_orders(query \\ User), do: from(u in query, preload: [:orders])
# Use Repo.preload for post-fetch loading
user = Repo.get!(User, id) |> Repo.preload(:orders)
### Changeset rules
- cast only allowed fields, never use Map.merge or direct struct assignment
- All validation in the changeset function, not in context functions
- Separate changesets for create vs update if validation rules differ:
def create_changeset(user, attrs) -- includes password validation
def update_changeset(user, attrs) -- no password by default
### Migration rules
- Never run `mix ecto.migrate` without explicit instruction
- All migrations include both `up` and `down` functions
- Add indices explicitly: add_index :users, [:email], unique: true
- No schema changes to existing columns without a migration
The "cast only allowed fields" rule is the Ecto equivalent of strong params in Rails. Claude will sometimes generate context functions that accept a raw map and pass it directly into a struct, bypassing the changeset validation entirely. The rule forces all external data through cast/3.
The separate create/update changeset rule handles a common pattern: password is required on create, optional on update. Without an explicit instruction, Claude generates a single changeset that either always requires password or always omits it. The named changeset approach is idiomatic Ecto. For broader database workflow patterns that apply across frameworks, the Claude Code database guide covers query optimisation and connection pool configuration.
Phoenix LiveView patterns
LiveView is where Elixir's realtime capability is most visible, and where Claude needs the most precise conventions. The mount/handle_event/handle_info lifecycle, the assign pattern, and PubSub integration all have specific forms.
Add to CLAUDE.md:
## Phoenix LiveView conventions
### LiveView module structure
defmodule MyAppWeb.DashboardLive do
use MyAppWeb, :live_view
@impl true
def mount(_params, _session, socket) do
if connected?(socket), do: subscribe()
{:ok, assign(socket, items: [], loading: true)}
end
@impl true
def handle_params(params, _uri, socket) do
{:noreply, apply_params(socket, params)}
end
@impl true
def handle_event("save", %{"form" => params}, socket) do
case MyApp.Items.create(params) do
{:ok, item} ->
{:noreply, stream_insert(socket, :items, item)}
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
@impl true
def handle_info({:item_created, item}, socket) do
{:noreply, stream_insert(socket, :items, item)}
end
defp subscribe do
Phoenix.PubSub.subscribe(MyApp.PubSub, "items")
end
defp apply_params(socket, _params), do: socket
end
### assign conventions
- Use assign/2 or assign/3 for all socket state, never modify socket.assigns directly
- Use stream/3 and stream_insert/4 for collections rendered with for on the client
- Use assign_async/3 for slow data loads: assign_async(socket, [:items], fn -> ... end)
- Initialise ALL assigns in mount. Never access an assign that was not set in mount.
### handle_event conventions
- Event names: kebab-case strings ("save-form", "delete-item")
- Params: always pattern match on the expected keys
- Return {:noreply, socket} or {:reply, reply, socket}
- Validate data via Ecto changesets before persisting
### PubSub pattern
# Publisher (context function)
def create_item(attrs) do
with {:ok, item} <- Repo.insert(Item.changeset(%Item{}, attrs)) do
Phoenix.PubSub.broadcast(MyApp.PubSub, "items", {:item_created, item})
{:ok, item}
end
end
# Subscriber (LiveView handle_info above)
### Component conventions
defmodule MyAppWeb.ItemCardComponent do
use MyAppWeb, :live_component
@impl true
def update(assigns, socket) do
{:ok, assign(socket, assigns)}
end
@impl true
def handle_event("delete", _params, socket) do
# component-local events
{:noreply, socket}
end
end
Two rules above prevent the most common LiveView bugs in Claude-generated code.
"Initialise ALL assigns in mount" prevents assign_not_in_socket errors that crash the LiveView when a template references an assign that was conditionally set. Claude will sometimes set assigns only in handle_event callbacks, which breaks on initial render.
The stream/3 rule matters for collections. Claude defaults to assigning a list to a socket key and re-rendering the entire list on every update. Streams handle large collections incrementally on the client side and are the correct LiveView 0.19+ pattern. Without the rule, Claude generates the old list-assign approach.
ExUnit test patterns
Elixir's built-in test framework is structured and fast. Claude generates working ExUnit tests but will miss the conventions that keep test suites consistent: context modules, async tests, DataCase versus ConnCase, and ExMachina or fixture conventions.
Add to CLAUDE.md:
## Testing conventions
### Test file structure
defmodule MyApp.AccountsTest do
use MyApp.DataCase, async: true # or ConnCase for web tests
alias MyApp.Accounts
alias MyApp.Accounts.User
describe "create_user/1" do
test "creates a user with valid attrs" do
attrs = %{email: "test@example.com", name: "Test User", password: "secure123"}
assert {:ok, %User{} = user} = Accounts.create_user(attrs)
assert user.email == "test@example.com"
end
test "returns error changeset with missing email" do
assert {:error, %Ecto.Changeset{}} = Accounts.create_user(%{name: "Test"})
end
test "rejects duplicate email" do
attrs = %{email: "dup@example.com", name: "First", password: "secure123"}
{:ok, _} = Accounts.create_user(attrs)
assert {:error, changeset} = Accounts.create_user(attrs)
assert "has already been taken" in errors_on(changeset).email
end
end
end
### Test case selection
- DataCase: any test touching the Repo (database sandbox, async: true safe)
- ConnCase: controller and LiveView tests (sets up conn, async: false for session)
- ExUnit.Case: pure function tests, no side effects (async: true always)
### Fixtures
# test/support/fixtures/accounts_fixtures.ex
defmodule MyApp.AccountsFixtures do
def user_fixture(attrs \\ %{}) do
{:ok, user} =
attrs
|> Enum.into(%{email: "test#{System.unique_integer()}@example.com",
name: "Test User",
password: "secure_password_123"})
|> MyApp.Accounts.create_user()
user
end
end
### Rules
- async: true on all DataCase tests (Ecto sandbox is async-safe)
- async: false on ConnCase tests unless you are certain there is no shared state
- Use describe blocks to group tests by function under test
- Test the context function, not the Ecto query directly
- No mocking of Ecto. Use the test DB sandbox.
- Run: mix test --trace for verbose output during debugging
The async: true rule is the most impactful test performance lever in Elixir. Claude will sometimes set async: false conservatively on DataCase tests, serialising the entire test suite. The Ecto sandbox is designed for concurrent tests. The rule sets the correct default.
The "test the context function, not the Ecto query directly" rule keeps tests at the right abstraction level. Without it, Claude sometimes writes tests that reach into the schema module and call changeset functions directly, bypassing the context layer. That tests the wrong thing and produces a suite that passes even when the context function is broken. For test-design principles that apply across languages and frameworks, the Claude Code testing guide covers TDD loops and coverage strategies.
Permission hooks for Elixir projects
Several mix commands need gating before Claude Code can run them without review. Migrations, generator tasks, and release commands are the main candidates.
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(mix compile*)",
"Bash(mix test*)",
"Bash(mix format*)",
"Bash(mix credo*)",
"Bash(mix deps.get)",
"Bash(mix phx.routes*)",
"Bash(mix ecto.rollback --step 1)"
],
"deny": [
"Bash(mix ecto.migrate*)",
"Bash(mix ecto.drop*)",
"Bash(mix ecto.reset*)",
"Bash(mix phx.gen.*)",
"Bash(mix release*)",
"Bash(mix deps.update*)"
]
}
}
The mix phx.gen.* deny blocks Phoenix generators, which write multiple files and can overwrite existing ones. Claude should write files directly rather than using generators, which produce boilerplate that needs significant editing anyway. The mix ecto.migrate deny forces a review step before any schema change hits the database.
mix ecto.rollback --step 1 is allowed because it is a safe one-step rollback, not a full down migration. The mix ecto.reset deny blocks the full drop-and-recreate cycle, which would destroy development data. For the complete picture of how Claude Code permission hooks work across project types, the Claude Code hooks guide covers the permission system in detail.
Where Claude Code needs manual review in Elixir
Even with the CLAUDE.md above in place, two areas require close review.
OTP process registration at scale. Claude will generate correct per-process Registry patterns for small counts but may miss the performance implications for large dynamic supervision trees. If your application spawns thousands of processes per node, review the process naming strategy and consider partitioned registries.
GenServer timeout and hibernation. Claude rarely adds :timeout or :hibernate to GenServer return tuples without explicit instruction. For long-running servers that handle infrequent messages, hibernation ({:noreply, state, :hibernate}) reduces memory use significantly. If memory per GenServer matters for your application, add a rule: "Add {:noreply, state, :hibernate} for GenServers that wait longer than 30 seconds between messages."
LiveView dead renders. Claude will sometimes generate LiveView code that assumes connected?(socket) is true in every render path. Static (dead) renders happen on first page load before the websocket connects. Any assign that depends on connected?(socket) must have a default initialised in mount for the dead render path. The rule in CLAUDE.md covers this, but verify any time Claude generates conditional PubSub subscriptions.
For the configuration principles that make Claude Code reliable across any language, the Claude Code best practices guide covers the workflow habits that compound over a project.
Getting the most from Claude Code in an Elixir codebase
The CLAUDE.md template in this guide produces an Elixir setup where Claude writes function clauses over if-else, uses with-chains for multi-step flows, generates correct GenServer callbacks with @impl true, runs changesets through the proper pipeline, uses stream assigns in LiveView, and treats mix test as its primary verification loop.
The distinctive thing about Elixir with Claude Code is the compile-time safety net. Elixir's compiler catches arity mismatches, missing pattern clauses with --warnings-as-errors, and undefined module references. With mix compile --warnings-as-errors in the allowed commands list, Claude's iteration loop catches its own mistakes fast.
The pattern is the same one that applies across typed and functional languages: Claude operates at the level of the context it has been given. A Phoenix project without CLAUDE.md produces Claude that mixes if and pattern matching, forgets @impl true, and treats socket assigns as a mutable map. A project with the configuration above produces Claude that generates code a senior Elixir developer would be satisfied shipping.
Claudify ships with an Elixir-specific CLAUDE.md template pre-configured for Phoenix LiveView, OTP supervision, Ecto changesets, and the ExUnit conventions above.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify