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.

  • nameelixir-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

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.

~/.claude/skills/elixir-otp/SKILL.md

--- name: elixir-otp description: Designs and writes Elixir/OTP systems — when to use a process at all, GenServer callbacks and handle_continue, supervision trees and… license: MIT ---

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 with for multi-step pipelines that can fail.
  • Never spawn unlinked, unsupervised processes in application code. Use Task.Supervisor or 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?

NeedUse
Pure transformationPlain functions
One-off async work, result neededTask.async + Task.await (linked to caller)
Many concurrent jobs, boundedTask.async_stream(enum, fun, max_concurrency: n, timeout: ms)
Fire-and-forget work that must not crash callerTask.Supervisor.start_child
Mutable state with serialized updatesGenServer
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 jobGenServer with Process.send_after/3, or Oban for persistent jobs
Durable background jobsOban (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
  • call when the caller needs a reply or backpressure; cast only when losing the message on crash is acceptable. Default call timeout is 5000 ms.
  • Expensive startup goes in handle_continue, so start_link returns quickly and the supervisor is not blocked.
  • Put @impl true on every callback so typos become compile warnings.
  • Keep the state small; large binaries in state are copied on every :sys.get_state and 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
StrategyWhen a child diesUse when
:one_for_oneRestart that child onlyChildren independent (default)
:rest_for_oneRestart it and children started after itLater children depend on earlier ones
:one_for_allRestart allChildren share state that must stay consistent
restart:Meaning
:permanentAlways restart (default for GenServer)
:transientRestart only on abnormal exit (per-entity workers that finish)
:temporaryNever 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, never Repo directly.
  • 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 in mount only if connected?(socket).
  • Wrap multi-step writes in Ecto.Multi and Repo.transaction.
  • Preload associations explicitly (Repo.preload / preload: in queries); Ecto never lazy-loads, so missing preloads raise Ecto.Association.NotLoaded errors 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.call to 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/1 on user input; use String.to_existing_atom/1.
  • Task.async must be awaited by the same process; unawaited tasks send messages that pile up in the caller’s mailbox.
  • ExUnit async: true with a shared named process causes flaky tests; start per-test instances with start_supervised!({MyServer, name: nil}) and pass the pid.
  • Catch-all handle_info clauses 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.

esc