Elixir and OTP skill for Claude Code
Processes for state and failure, not for code. Claude first asks whether a process is needed at all, then writes GenServers with fast init via handle_continue, catch-all handle_info clauses and clean client APIs. It builds supervision trees with deliberate strategies and restart types, uses Registry and DynamicSupervisor for per-entity processes, ETS for hot reads, and keeps Phoenix code behind contexts.
- name
elixir-otp - size215 lines · ≈2,100 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Designs and writes Elixir/OTP systems — when to use a process at all, GenServer callbacks and handle_continue, supervision trees and restart strategies, DynamicSupervisor and Registry, Task.async_stream for bounded concurrency, ETS for shared reads, plus Phoenix contexts, Ecto changesets, and LiveView basics. Use when writing GenServers or supervisors, debugging mailbox growth or crash loops, structuring a Phoenix app, or reviewing Elixir for OTP misuse. Try asking
- Write a GenServer that tracks active game sessions, one process per game
- Our GenServer mailbox keeps growing under load — what is wrong with the design?
- Review this supervision tree: which restart strategy should each child use?
Use it when
- Writing GenServers, Tasks, or supervisors
- A process is a bottleneck or its mailbox grows
- Structuring a Phoenix app into contexts
- Choosing between ETS, a GenServer, or a database for shared state
Skip it when
- Building durable job queues with retries in any stack — use background-jobs
- Designing websocket fan-out across languages — use realtime-websockets
How to check the result
mix compile --warnings-as-errors, format check, credo --strict and tests pass, and killing a worker in iex shows the supervisor restarting it cleanly.
The common mistake it prevents
Wrapping pure business logic in a single named GenServer, which serializes every request in the system through one mailbox.
Done well means: processes exist only to model runtime concerns (state, concurrency,
fault isolation), every process sits under a supervisor with a restart strategy that
matches its failure semantics, no GenServer is a global bottleneck, and
mix compile --warnings-as-errors, mix format --check-formatted, mix credo --strict,
and mix test pass.
Ground rules
- Do not use processes to organize code. Modules and functions organize code; processes organize runtime state and failure. A GenServer wrapping pure logic is a serialization bottleneck.
- Let it crash, but only below a supervisor and only for unexpected errors. Expected
failures (invalid input, not found) return
{:error, reason}. - Pattern match on the happy path; use
withfor multi-step pipelines that can fail. - Never
spawnunlinked, unsupervised processes in application code. UseTask.Supervisoror a supervised child. - Keep GenServer callbacks fast. Slow work (HTTP, heavy compute) goes to a Task, with the result sent back as a message.
Do I need a process?
| Need | Use |
|---|---|
| Pure transformation | Plain functions |
| One-off async work, result needed | Task.async + Task.await (linked to caller) |
| Many concurrent jobs, bounded | Task.async_stream(enum, fun, max_concurrency: n, timeout: ms) |
| Fire-and-forget work that must not crash caller | Task.Supervisor.start_child |
| Mutable state with serialized updates | GenServer |
| Read-heavy shared data (cache, config) | ETS table (read_concurrency: true) owned by a GenServer, or :persistent_term for rarely-changing data |
| One process per entity (game, session, device) | DynamicSupervisor + Registry via-tuple names |
| Periodic job | GenServer with Process.send_after/3, or Oban for persistent jobs |
| Durable background jobs | Oban (Postgres-backed), not GenServers |
GenServer template
defmodule MyApp.RateCounter do
use GenServer
require Logger
# Client API: the only functions other modules call
def start_link(opts) do
name = Keyword.get(opts, :name, __MODULE__)
GenServer.start_link(__MODULE__, opts, name: name)
end
def hit(server \\ __MODULE__, key), do: GenServer.cast(server, {:hit, key})
def count(server \\ __MODULE__, key), do: GenServer.call(server, {:count, key})
# Callbacks
@impl true
def init(opts) do
interval = Keyword.get(opts, :reset_ms, 60_000)
{:ok, %{counts: %{}, interval: interval}, {:continue, :schedule}} # keep init fast
end
@impl true
def handle_continue(:schedule, state) do
Process.send_after(self(), :reset, state.interval)
{:noreply, state}
end
@impl true
def handle_cast({:hit, key}, state),
do: {:noreply, update_in(state.counts[key], &((&1 || 0) + 1))}
@impl true
def handle_call({:count, key}, _from, state),
do: {:reply, Map.get(state.counts, key, 0), state}
@impl true
def handle_info(:reset, state) do
Process.send_after(self(), :reset, state.interval)
{:noreply, %{state | counts: %{}}}
end
def handle_info(msg, state) do # never let unknown messages pile up
Logger.warning("unexpected message: #{inspect(msg)}")
{:noreply, state}
end
end
callwhen the caller needs a reply or backpressure;castonly when losing the message on crash is acceptable. Defaultcalltimeout is 5000 ms.- Expensive startup goes in
handle_continue, sostart_linkreturns quickly and the supervisor is not blocked. - Put
@impl trueon every callback so typos become compile warnings. - Keep the state small; large binaries in state are copied on every
:sys.get_stateand inspected in crash logs.
Supervision
defmodule MyApp.Application do
use Application
@impl true
def start(_type, _args) do
children = [
MyApp.Repo,
{Phoenix.PubSub, name: MyApp.PubSub},
{Registry, keys: :unique, name: MyApp.GameRegistry},
{DynamicSupervisor, name: MyApp.GameSupervisor, strategy: :one_for_one},
{Task.Supervisor, name: MyApp.TaskSup},
MyApp.RateCounter,
MyAppWeb.Endpoint # last: start serving once deps are up
]
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end
end
| Strategy | When a child dies | Use when |
|---|---|---|
:one_for_one | Restart that child only | Children independent (default) |
:rest_for_one | Restart it and children started after it | Later children depend on earlier ones |
:one_for_all | Restart all | Children share state that must stay consistent |
restart: | Meaning |
|---|---|
:permanent | Always restart (default for GenServer) |
:transient | Restart only on abnormal exit (per-entity workers that finish) |
:temporary | Never restart (Tasks, one-shot jobs) |
- Default intensity is 3 restarts in 5 seconds; exceeding it kills the supervisor and
escalates. A crash loop at boot means a bad dependency or config, not a reason to
raise
max_restarts. - Order children so dependencies start first; the supervisor shuts down in reverse.
- Start per-entity processes with names through the Registry:
def start_game(id) do
DynamicSupervisor.start_child(MyApp.GameSupervisor, {MyApp.Game, id: id})
end
# in MyApp.Game
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: via(opts[:id]))
defp via(id), do: {:via, Registry, {MyApp.GameRegistry, id}}
Bounded concurrency
urls
|> Task.async_stream(&fetch/1, max_concurrency: 10, timeout: 15_000, on_timeout: :kill_task)
|> Enum.reduce({[], []}, fn
{:ok, {:ok, body}}, {ok, err} -> {[body | ok], err}
{:ok, {:error, reason}}, {ok, err} -> {ok, [reason | err]}
{:exit, reason}, {ok, err} -> {ok, [reason | err]}
end)
Task.async_stream defaults max_concurrency to the scheduler count; set it explicitly
for I/O. Use Task.Supervisor.async_stream_nolink when a crashing item must not take down
the caller.
Phoenix structure (1.7+)
- Contexts (
MyApp.Accounts,MyApp.Billing) are the public API to the domain; controllers and LiveViews call contexts, neverRepodirectly. - Ecto changesets validate and cast at the boundary; one changeset function per use case
(
registration_changeset,email_changeset). - Verified routes
~p"/users/#{user}"catch broken links at compile time. - LiveView: assign only what the template renders; use streams (
stream/3) for large lists; subscribe to PubSub inmountonlyif connected?(socket). - Wrap multi-step writes in
Ecto.MultiandRepo.transaction. - Preload associations explicitly (
Repo.preload/preload:in queries); Ecto never lazy-loads, so missing preloads raiseEcto.Association.NotLoadederrors in templates.
Debugging live systems
:observer.start() # GUI: process tree, memory, ETS
Process.info(pid, [:message_queue_len, :memory, :current_function])
:sys.get_state(MyApp.RateCounter)
:recon.proc_count(:message_queue_len, 5) # with the :recon dependency
A growing message_queue_len means the process cannot keep up: shard it (one process per
key via Registry/PartitionSupervisor), move reads to ETS, or apply backpressure with call.
Gotchas
GenServer.callto yourself from inside a callback deadlocks (times out after 5 s).- Large binaries (>64 bytes) are reference-counted; a long-lived process that touches many of them can hold memory until it garbage collects. Hibernate or restructure.
- Atoms are never garbage collected: never
String.to_atom/1on user input; useString.to_existing_atom/1. Task.asyncmust be awaited by the same process; unawaited tasks send messages that pile up in the caller’s mailbox.- ExUnit
async: truewith a shared named process causes flaky tests; start per-test instances withstart_supervised!({MyServer, name: nil})and pass the pid. - Catch-all
handle_infoclauses that silently drop messages hide bugs; log them.
Verification
mix compile --warnings-as-errors
mix format --check-formatted
mix credo --strict
mix test --warnings-as-errors
mix dialyzer # if dialyxir is configured
For OTP changes also: kill the process in iex (Process.exit(pid, :kill)) and confirm
the supervisor restarts it and dependents recover.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.