CtrlK
BlogDocsLog inGet started
Tessl Logo

igmarin/elixir-phoenix-skills

Curated library of 38 atomic skills, 7 personas, and 1 orchestrator for Elixir and Phoenix development. Organized by category: fundamentals, phoenix, database, testing, auth, infrastructure, quality, security, integrations, tooling, frameworks, personas, and orchestration. Covers core Elixir patterns, Phoenix LiveView, Ecto, OTP, Oban, testing, security, deployment, real-time, and modern tooling (Req, Swoosh, Cachex, Broadway, Ash).

73

Quality

91%

Does it follow best practices?

Impact

No eval scenarios have been run

SecuritybySnyk

Advisory

Suggest reviewing before use

Overview
Quality
Evals
Security
Files

SKILL.mdskills/fundamentals/otp-essentials/

name:
otp-essentials
type:
atomic
tags:
atomic
license:
MIT
description:
MANDATORY for ALL OTP work. Invoke before writing GenServer, Supervisor, Task, or Agent modules. Covers GenServer public API patterns, fast init with handle_continue, call vs cast, handle_info, supervision strategies, DynamicSupervisor, Tasks, Agent, Registry, ETS, and process linking. Trigger words: GenServer, Supervisor, OTP, Task, Agent, Registry, ETS, process, supervision.
metadata:
{"user-invocable":"true","version":"1.0.0","adapted-from":"j-morgan6/elixir-phoenix-guide","original-author":"Joseph Morgan"}

OTP Essentials

Use this skill before writing ANY GenServer, Supervisor, Task, or Agent module.

RULES — Follow these with no exceptions

  1. Always use @impl true before GenServer/Agent callbacks (init, handle_call, handle_cast, handle_info, terminate)
  2. Keep init/1 fast — no blocking calls, no DB queries; use handle_continue for expensive setup
  3. Use GenServer.call for request/response, GenServer.cast for fire-and-forget — never cast when you need a result
  4. Always define a public API wrapping GenServer calls — callers should never use GenServer.call(pid, ...) directly
  5. Use Task.async/Task.await with bounded timeouts — never Task.async without a corresponding Task.await or Task.yield
  6. Name processes via Registry, not atoms — atom table is finite and never garbage collected
  7. Supervisors own process lifecycle — never start unsupervised long-running processes
  8. Handle :DOWN messages from monitored processes — don't let them go unhandled
  9. Use Task.Supervisor for fire-and-forget supervised work
  10. Prefer ETS over a bottleneck GenServer for shared read-heavy state — one GenServer serializes all access

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:

GenServer.call(MyApp.Cache, {:get, key})

Good — public API hides the GenServer:

defmodule MyApp.Cache do
  use GenServer

  # --- Public API ---

  def start_link(opts) do
    name = Keyword.get(opts, :name, __MODULE__)
    GenServer.start_link(__MODULE__, opts, name: name)
  end

  def get(key, server \\ __MODULE__) do
    GenServer.call(server, {:get, key})
  end

  def put(key, value, server \\ __MODULE__) do
    GenServer.cast(server, {:put, key, value})
  end

  # --- Callbacks ---

  @impl true
  def init(_opts) do
    {:ok, %{}}
  end

  @impl true
  def handle_call({:get, key}, _from, state) do
    {:reply, Map.get(state, key), state}
  end

  @impl true
  def handle_cast({:put, key, value}, state) do
    {:noreply, Map.put(state, key, value)}
  end
end

Fast Init with handle_continue

Never block in init/1. Use handle_continue for expensive setup.

Bad — blocks the supervisor:

@impl true
def init(opts) do
  data = MyApp.Repo.all(MyApp.Item)  # Blocks!
  {:ok, %{items: data}}
end

Good — returns immediately:

@impl true
def init(opts) do
  {:ok, %{items: []}, {:continue, :load_data}}
end

@impl true
def handle_continue(:load_data, state) do
  data = 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__) do
  GenServer.call(server, :get_count)
end

@impl true
def handle_call(:get_count, _from, state) do
  {:reply, state.count, state}
end

# cast — asynchronous (use for writes, side effects)
def increment(server \\ __MODULE__) do
  GenServer.cast(server, :increment)
end

@impl true
def handle_cast(:increment, state) do
  {:noreply, %{state | count: state.count + 1}}
end

handle_info

@impl true
def init(_opts) do
  Process.send_after(self(), :tick, 1_000)
  {:ok, %{count: 0}}
end

@impl true
def handle_info(:tick, state) do
  Process.send_after(self(), :tick, 1_000)
  {:noreply, %{state | count: state.count + 1}}
end

Supervisors

Supervision Strategies

StrategyBehaviour
:one_for_oneRestart only the failed child (most common)
:one_for_allRestart ALL children when one fails
:rest_for_oneRestart failed child and all children started after it
Supervisor.start_link(children, strategy: :one_for_one)

Application Supervision Tree

defmodule MyApp.Application do
  use Application

  @impl true
  def start(_type, _args) do
    children = [
      MyApp.Repo,
      {Phoenix.PubSub, name: MyApp.PubSub},
      MyApp.Cache,
      MyAppWeb.Endpoint
    ]

    opts = [strategy: :one_for_one, name: MyApp.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

DynamicSupervisor for Runtime Children

defmodule MyApp.RoomSupervisor do
  use DynamicSupervisor

  def start_link(init_arg) do
    DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
  end

  @impl true
  def init(_init_arg) do
    DynamicSupervisor.init(strategy: :one_for_one)
  end

  def start_room(room_id) do
    spec = {MyApp.Room, room_id: room_id}
    DynamicSupervisor.start_child(__MODULE__, spec)
  end

  def stop_room(pid) do
    DynamicSupervisor.terminate_child(__MODULE__, pid)
  end
end

Supervision Tree Setup Workflow

When wiring up a new supervision tree, follow this sequence:

  1. Define children — list in dependency order (dependencies first)
  2. Choose strategyone_for_one unless children are interdependent
  3. Add to Application.start/2 — or to a parent supervisor's child list
  4. Verify startup — run mix run --no-halt or iex -S mix and confirm no crashes
  5. Inspect with Observer:observer.start() in IEx to view the live supervision tree
  6. Check child countsSupervisor.count_children(MyApp.Supervisor) confirms expected active/specs counts
  7. Test restart behaviorProcess.exit(pid, :kill) and confirm the supervisor restarts the child
# Quick verification in IEx
iex> Supervisor.which_children(MyApp.Supervisor)
# [{MyApp.Cache, #PID<0.200.0>, :worker, [MyApp.Cache]}, ...]

iex> Supervisor.count_children(MyApp.Supervisor)
# %{active: 3, specs: 3, supervisors: 0, workers: 3}

Tasks

async/await for Concurrent Work

# Parallel fetch with bounded timeout
task1 = 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

user_ids
|> Task.async_stream(&fetch_user/1, max_concurrency: 4, timeout: 10_000)
|> Enum.map(fn {:ok, result} -> result end)

Supervised Tasks

# Add to your supervision tree
{Task.Supervisor, name: MyApp.TaskSupervisor}

# Start supervised tasks
Task.Supervisor.start_child(MyApp.TaskSupervisor, fn ->
  send_welcome_email(user)
end)

Agent

Use Agent for simple state that doesn't need the full GenServer pattern:

defmodule MyApp.Counter do
  use Agent

  def start_link(initial_value),
    do: Agent.start_link(fn -> initial_value end, name: __MODULE__)

  def value, do: Agent.get(__MODULE__, & &1)
  def increment, do: Agent.update(__MODULE__, &(&1 + 1))
end

Process Naming

Registry (preferred)

# In application supervision tree
{Registry, keys: :unique, name: MyApp.Registry}

# In GenServer start_link
def start_link(room_id) do
  GenServer.start_link(__MODULE__, room_id,
    name: {:via, Registry, {MyApp.Registry, {:room, room_id}}}
  )
end

# Lookup
def get_room(room_id) do
  case Registry.lookup(MyApp.Registry, {:room, room_id}) do
    [{pid, _}] -> {:ok, pid}
    [] -> {:error, :not_found}
  end
end

ETS for Shared Read-Heavy State

A GenServer owns the ETS table (ensuring cleanup on crash) while reads bypass it entirely.

defmodule MyApp.EtsCache do
  use GenServer

  @table :my_app_cache

  # --- Public API ---

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  # Reads go directly to ETS — no GenServer roundtrip
  def get(key) do
    case :ets.lookup(@table, key) do
      [{^key, value}] -> {:ok, value}
      [] -> {:error, :not_found}
    end
  end

  # Writes go through the GenServer to serialize mutations
  def put(key, value), do: GenServer.call(__MODULE__, {:put, key, value})
  def delete(key), do: GenServer.call(__MODULE__, {:delete, key})

  # --- Callbacks ---

  @impl true
  def init(_opts) do
    table = :ets.new(@table, [:named_table, :set, :public, read_concurrency: true])
    {:ok, %{table: table}}
  end

  @impl true
  def handle_call({:put, key, value}, _from, state) do
    :ets.insert(@table, {key, value})
    {:reply, :ok, state}
  end

  @impl true
  def handle_call({:delete, key}, _from, state) do
    :ets.delete(@table, key)
    {:reply, :ok, state}
  end
end

Key ETS options:

OptionMeaning
:set / :bagUnique keys vs. duplicate keys allowed
:public / :protectedAny process reads/writes vs. owner writes, all read
read_concurrency: trueOptimise for concurrent reads
write_concurrency: trueOptimise for concurrent writes (trades some read performance)

Integration

PredecessorThis SkillSuccessor
elixir-essentialsotp-essentialstelemetry-essentials
elixir-essentialsotp-essentialsoban-essentials
testing-essentialsotp-essentialstelemetry-essentials

skills

fundamentals

otp-essentials

README.md

tile.json