Skill v1.0.0
currentAutomated scan100/100version: "1.0.0" name: otp-essentials description: MANDATORY for ALL OTP work. Invoke before writing GenServer, Supervisor, Task, or Agent modules. file_patterns:
- "**/*.ex"
auto_suggest: true
OTP Essentials
RULES — Follow these with no exceptions
- Always use `@impl true` before GenServer/Agent callbacks (init, handle_call, handle_cast, handle_info, terminate)
- Keep `init/1` fast — no blocking calls, no DB queries; use
handle_continuefor expensive setup - Use `GenServer.call` for request/response, `GenServer.cast` for fire-and-forget — never cast when you need a result
- Always define a public API wrapping GenServer calls — callers should never use
GenServer.call(pid, ...)directly - Use `Task.async`/`Task.await` with bounded timeouts — never
Task.asyncwithout a correspondingTask.awaitorTask.yield - Name processes via Registry, not atoms — atom table is finite and never garbage collected
- Supervisors own process lifecycle — never start unsupervised long-running processes
GenServer
Public API Pattern
Always wrap GenServer calls behind a public module API. Callers should not know they're talking to a GenServer.
# Bad — leaks GenServer implementation to callersGenServer.call(MyApp.Cache, {:get, key})# Good — public API hides the GenServerdefmodule MyApp.Cache douse GenServer# --- Public API ---def start_link(opts) doname = Keyword.get(opts, :name, __MODULE__)GenServer.start_link(__MODULE__, opts, name: name)enddef get(key, server \\ __MODULE__) doGenServer.call(server, {:get, key})enddef put(key, value, server \\ __MODULE__) doGenServer.cast(server, {:put, key, value})end# --- Callbacks ---@impl truedef init(_opts) do{:ok, %{}}end@impl truedef handle_call({:get, key}, _from, state) do{:reply, Map.get(state, key), state}end@impl truedef handle_cast({:put, key, value}, state) do{:noreply, Map.put(state, key, value)}endend
Fast Init with handle_continue
Never block in init/1. Use handle_continue for expensive setup.
# Bad — blocks the supervisor while loading data@impl truedef init(opts) dodata = MyApp.Repo.all(MyApp.Item) # Blocks!{:ok, %{items: data}}end# Good — returns immediately, loads data asynchronously@impl truedef init(opts) do{:ok, %{items: []}, {:continue, :load_data}}end@impl truedef handle_continue(:load_data, state) dodata = MyApp.Repo.all(MyApp.Item){:noreply, %{state | items: data}}end
call vs cast
# call — synchronous, caller waits for reply (use for reads, queries)def get_count(server \\ __MODULE__) doGenServer.call(server, :get_count)end@impl truedef handle_call(:get_count, _from, state) do{:reply, state.count, state}end# cast — asynchronous, fire-and-forget (use for writes, side effects)def increment(server \\ __MODULE__) doGenServer.cast(server, :increment)end@impl truedef handle_cast(:increment, state) do{:noreply, %{state | count: state.count + 1}}end
handle_info for External Messages
Use handle_info for messages not sent via call/cast — timers, monitors, PubSub, etc.
@impl truedef init(_opts) doProcess.send_after(self(), :tick, 1_000){:ok, %{count: 0}}end@impl truedef handle_info(:tick, state) doProcess.send_after(self(), :tick, 1_000){:noreply, %{state | count: state.count + 1}}end
Supervisors
Supervision Strategies
# one_for_one — restart only the failed child (most common)children = [{MyApp.Cache, []},{MyApp.Worker, []}]Supervisor.start_link(children, strategy: :one_for_one)# one_for_all — restart ALL children when one fails# Use when children depend on each other's stateSupervisor.start_link(children, strategy: :one_for_all)# rest_for_one — restart failed child and all children started AFTER it# Use when later children depend on earlier onesSupervisor.start_link(children, strategy: :rest_for_one)
Application Supervision Tree
defmodule MyApp.Application douse Application@impl truedef start(_type, _args) dochildren = [MyApp.Repo,{Phoenix.PubSub, name: MyApp.PubSub},MyApp.Cache,MyAppWeb.Endpoint]opts = [strategy: :one_for_one, name: MyApp.Supervisor]Supervisor.start_link(children, opts)endend
DynamicSupervisor for Runtime Children
Use when you need to start processes on demand, not at boot.
defmodule MyApp.RoomSupervisor douse DynamicSupervisordef start_link(init_arg) doDynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__)end@impl truedef init(_init_arg) doDynamicSupervisor.init(strategy: :one_for_one)enddef start_room(room_id) dospec = {MyApp.Room, room_id: room_id}DynamicSupervisor.start_child(__MODULE__, spec)enddef stop_room(pid) doDynamicSupervisor.terminate_child(__MODULE__, pid)endend
Tasks
async/await for Concurrent Work
# Parallel fetch with bounded timeouttask1 = Task.async(fn -> fetch_user_profile(user_id) end)task2 = Task.async(fn -> fetch_user_posts(user_id) end)profile = Task.await(task1, 5_000)posts = Task.await(task2, 5_000)
async_stream for Batch Processing
# Process items concurrently with bounded concurrencyuser_ids|> Task.async_stream(&fetch_user/1, max_concurrency: 4, timeout: 10_000)|> Enum.map(fn {:ok, result} -> result end)
Supervised Tasks (fire-and-forget)
For work that should be supervised but doesn't need a result:
# Add to your supervision tree{Task.Supervisor, name: MyApp.TaskSupervisor}# Start supervised tasks (automatically restarted on crash)Task.Supervisor.start_child(MyApp.TaskSupervisor, fn ->send_welcome_email(user)end)
Agent
Use Agent for simple state when GenServer is overkill. If you need handle_info, timeouts, or complex logic, use GenServer instead.
defmodule MyApp.Counter douse Agentdef start_link(initial_value) doAgent.start_link(fn -> initial_value end, name: __MODULE__)enddef value doAgent.get(__MODULE__, & &1)enddef increment doAgent.update(__MODULE__, &(&1 + 1))endend
Process Naming
Registry (preferred)
# In application supervision tree{Registry, keys: :unique, name: MyApp.Registry}# In GenServer start_linkdef start_link(room_id) doGenServer.start_link(__MODULE__, room_id,name: {:via, Registry, {MyApp.Registry, {:room, room_id}}})end# Lookupdef get_room(room_id) docase Registry.lookup(MyApp.Registry, {:room, room_id}) do[{pid, _}] -> {:ok, pid}[] -> {:error, :not_found}endend
Atoms (only for singletons)
# OK — single global processGenServer.start_link(__MODULE__, opts, name: __MODULE__)# Bad — dynamic atom creation from user inputGenServer.start_link(__MODULE__, opts, name: String.to_atom("room_#{room_id}"))
Process Linking vs Monitoring
# Link — bidirectional, crash propagates (use in supervisors)Process.link(pid)# Monitor — unidirectional, receive :DOWN message (use for observation)ref = Process.monitor(pid)@impl truedef handle_info({:DOWN, _ref, :process, pid, reason}, state) do# Handle monitored process dying{:noreply, cleanup(state, pid)}end
ETS for Shared Read-Heavy State
When many processes need to read the same data and writes are infrequent:
# Create table in a GenServer (owner process)@impl truedef init(_opts) dotable = :ets.new(:my_cache, [:named_table, :set, :public, read_concurrency: true]){:ok, %{table: table}}end# Any process can read:ets.lookup(:my_cache, key)# Only owner should write (or use :public carefully):ets.insert(:my_cache, {key, value})
Common Anti-Patterns
# Bad — bottleneck GenServer (all requests go through one process)def get_user(id), do: GenServer.call(UserServer, {:get, id})# Fix: Use ETS, a database, or partition work across multiple processes# Bad — god process (one GenServer doing everything)# Fix: Split into focused processes, each with one responsibility# Bad — unmonitored Task.asyncTask.async(fn -> do_work() end)# no await or yield — caller loses track of work# Fix: Always await, or use Task.Supervisor.start_child for fire-and-forget# Bad — blocking the caller unnecessarilydef send_email(user) doGenServer.call(EmailServer, {:send, user}) # Waits for email to sendend# Fix: Use cast if caller doesn't need the resultdef send_email(user) doGenServer.cast(EmailServer, {:send, user})end
Testing
# Start GenServer in testtest "get and put values" dostart_supervised!({MyApp.Cache, name: :test_cache})assert MyApp.Cache.get(:key, :test_cache) == nilMyApp.Cache.put(:key, "value", :test_cache)assert MyApp.Cache.get(:key, :test_cache) == "value"end# Test with Tasktest "concurrent fetch" dotask = Task.async(fn -> MyApp.fetch_data() end)assert {:ok, data} = Task.await(task, 5_000)end
See testing-essentials skill for comprehensive testing patterns.