skills/elixir/SKILL.md

44 KiB

Elixir — Comprehensive Development Guide

Quick Reference

Item Value
Current Version Elixir 1.19.5 (target for all new code)
Required OTP OTP 27+ (OTP 28 supported)
Phoenix Version 1.8.5 (latest — requires :formats on controllers)
Cortex Status Elixir not yet installed on cortex.hydrascale.net
AI Agent Tooling usage_rules hex package (~> 1.2) — always include
Production Framework Ash Framework for substantial projects
Paradigm Functional, concurrent, fault-tolerant on BEAM VM

CRITICAL RULES — Read First

  1. Target Elixir 1.19.5 — not 1.15, not 1.17. Use current idioms and features.
  2. Always add usage_rules to every project: {:usage_rules, "~> 1.1", only: [:dev]} — it generates AGENTS.md/CLAUDE.md from dependency docs, giving AI agents rich context about the libraries in use.
  3. Use Ash Framework for production/substantial projects (not necessarily POCs). Ash provides declarative resources, built-in authorization, and extensions for Postgres, Phoenix, GraphQL, JSON:API.
  4. Cortex is Elixir-first — Elixir is the primary orchestrator language, with interop to Python (Erlport/ports), Rust (Rustler NIFs), and Zig when needed.
  5. Never use deprecated patterns from pre-1.16 code (see Breaking Changes section).

Elixir 1.19 — What's New

Gradual Set-Theoretic Type System

Elixir 1.19 significantly advances the built-in type system. It is sound, gradual, and set-theoretic (types compose via union, intersection, negation).

Current capabilities (1.19):

  • Type inference from existing code — no annotations required yet (user-provided signatures coming in future releases)
  • Protocol dispatch type checking — the compiler warns when you pass a value to string interpolation, for comprehensions, or other protocol-dispatched operations that don't implement the required protocol
  • Anonymous function type inferencefn literals and &captures now propagate types, catching mismatches at compile time
  • Types: atom(), binary(), integer(), float(), pid(), port(), reference(), tuple(), list(), map(), function()
  • Compose with: atom() or integer() (union), atom() and integer() (intersection → none()), atom() and not nil (difference)
  • dynamic() type for gradual typing — represents runtime-checked values
  • Tuple precision: {:ok, binary()}, open tuples with ...: {:ok, binary(), ...}
  • Map types: %{key: value} for closed maps, %{optional(atom()) => term()} for open maps
  • Function types: (integer() -> boolean())

What this means in practice: The compiler now catches real bugs — passing a struct to string interpolation that doesn't implement String.Chars, using a non-enumerable in for, type mismatches in anonymous function calls. These are warnings today, errors in the future.

Up to 4x Faster Compilation

Two major improvements:

  1. Lazy module loading — modules are no longer loaded immediately when defined. The parallel compiler controls both compilation and loading, reducing pressure on the Erlang code server. Reports of 2x+ speedup on large projects.

  2. Parallel dependency compilation — set MIX_OS_DEPS_COMPILE_PARTITION_COUNT env var to partition OS dep compilation across CPU cores.

Potential regressions: If you spawn processes during compilation that invoke other project modules, use Kernel.ParallelCompiler.pmap/2 or Code.ensure_compiled!/1 before spawning. Also affects @on_load callbacks that reference sibling modules.

Other 1.19 Features

  • min/2 and max/2 allowed in guards
  • Access.values/0 — traverse all values in a map/keyword
  • String.count/2 — count occurrences of a pattern
  • Unicode 17.0.0 support
  • Multi-line IEx prompts
  • mix help Mod.fun/arity — get help for specific functions
  • New pretty printing infrastructure
  • OpenChain compliance with Source SBoM
  • Erlang/OTP 28 support

Breaking Changes Since 1.15

These are critical to know — older books and tutorials will use the old patterns.

Struct Update Syntax (1.18+)

# OLD (1.15) — no longer valid
%User{user | name: "new"}

# NEW (1.18+) — explicit pattern match required
%User{} = user
%User{user | name: "new"}
# Or in one expression:
%User{name: "new"} = Map.merge(user, %{name: "new"})

Actually the change is: %URI{my_uri | path: "/new"} now requires my_uri to match %URI{}. If the compiler can't verify it's the right struct type, it warns.

Regex as Struct Field Defaults (OTP 28)

# BROKEN on OTP 28 — regex literals can't be struct field defaults
defmodule MyMod do
  defstruct pattern: ~r/foo/  # Compile error on OTP 28
end

# FIX — use @default_pattern or compute at runtime
defmodule MyMod do
  @default_pattern ~r/foo/
  defstruct pattern: @default_pattern  # Still fails — use nil + init
end

Logger Backends Deprecated

# OLD
config :logger, backends: [:console]

# NEW — use LoggerHandler (Erlang's logger)
config :logger, :default_handler, []
config :logger, :default_formatter, format: "$time $metadata[$level] $message\n"

Mix Task Separator

# OLD — comma separator
mix do compile, test

# NEW (1.17+) — plus separator
mix do compile + test

mix cli/0 Replaces Multiple Config Keys

# OLD
def project do
  [default_task: "phx.server",
   preferred_cli_env: [test: :test],
   preferred_cli_target: [...]]
end

# NEW
def cli do
  [default_task: "phx.server",
   preferred_envs: [test: :test],
   preferred_targets: [...]]
end

Core Language Patterns

The Pipeline Principle

Elixir code flows through transformations via |>. Design functions to take the "subject" as the first argument.

orders
|> Enum.filter(&(&1.status == :pending))
|> Enum.sort_by(& &1.created_at, DateTime)
|> Enum.map(&process_order/1)

Pattern Matching — The Heart of Elixir

# Function head matching — preferred over conditionals
def process(%Order{status: :pending} = order), do: ship(order)
def process(%Order{status: :shipped} = order), do: track(order)
def process(%Order{status: :delivered}), do: :noop

# Pin operator to match against existing values
expected = "hello"
^expected = some_function()  # Asserts equality

# Map/struct matching is partial — only listed keys must match
%{name: name} = %{name: "Michael", age: 42}  # name = "Michael"

With Expressions for Happy-Path Chaining

with {:ok, user} <- fetch_user(id),
     {:ok, account} <- fetch_account(user),
     {:ok, balance} <- check_balance(account) do
  {:ok, balance}
else
  {:error, :not_found} -> {:error, "User not found"}
  {:error, :insufficient} -> {:error, "Insufficient funds"}
  error -> {:error, "Unknown: #{inspect(error)}"}
end

Structs and Protocols

defmodule Money do
  defstruct [:amount, :currency]

  defimpl String.Chars do
    def to_string(%Money{amount: a, currency: c}), do: "#{a} #{c}"
  end

  defimpl Inspect do
    def inspect(%Money{amount: a, currency: c}, _opts) do
      "#Money<#{a} #{c}>"
    end
  end
end

Behaviours for Contracts

defmodule PaymentProvider do
  @callback charge(amount :: integer(), currency :: String.t()) ::
    {:ok, transaction_id :: String.t()} | {:error, reason :: term()}
  @callback refund(transaction_id :: String.t()) ::
    {:ok, term()} | {:error, reason :: term()}
end

defmodule Stripe do
  @behaviour PaymentProvider

  @impl true
  def charge(amount, currency), do: # ...

  @impl true
  def refund(transaction_id), do: # ...
end

Concurrency Model — BEAM Processes

Fundamentals

  • Processes are cheap — ~2KB initial memory, microseconds to spawn, millions can run concurrently
  • No shared memory — processes communicate via message passing only
  • Each process has its own heap — GC is per-process, no stop-the-world pauses
  • Preemptive scheduling — one scheduler per CPU core, ~4000 reductions then yield
  • Process isolation — one process crashing doesn't affect others

Spawning and Messaging

# Basic spawn + message passing
pid = spawn(fn ->
  receive do
    {:greet, name} -> IO.puts("Hello, #{name}!")
  end
end)

send(pid, {:greet, "Michael"})

# Linked processes — bidirectional crash propagation
pid = spawn_link(fn -> raise "boom" end)  # Caller also crashes

# Monitored processes — one-directional crash notification
ref = Process.monitor(pid)
receive do
  {:DOWN, ^ref, :process, ^pid, reason} -> IO.puts("Crashed: #{reason}")
end

Task — Structured Concurrency

# Fire and forget
Task.start(fn -> send_email(user) end)

# Async/await (linked to caller)
task = Task.async(fn -> expensive_computation() end)
result = Task.await(task, 30_000)  # 30s timeout

# Parallel map
Task.async_stream(urls, &fetch_url/1, max_concurrency: 10, timeout: 15_000)
|> Enum.to_list()

GenServer — Stateful Server Processes

defmodule Counter do
  use GenServer

  # Client API
  def start_link(initial \\ 0), do: GenServer.start_link(__MODULE__, initial, name: __MODULE__)
  def increment, do: GenServer.cast(__MODULE__, :increment)
  def get, do: GenServer.call(__MODULE__, :get)

  # Server callbacks
  @impl true
  def init(initial), do: {:ok, initial}

  @impl true
  def handle_cast(:increment, count), do: {:noreply, count + 1}

  @impl true
  def handle_call(:get, _from, count), do: {:reply, count, count}

  @impl true
  def handle_info(:tick, count) do
    IO.puts("Count: #{count}")
    Process.send_after(self(), :tick, 1000)
    {:noreply, count}
  end
end

Key design principle: GenServer callbacks run sequentially in a single process — this is both the synchronization mechanism and the potential bottleneck. Keep callbacks fast; delegate heavy work to spawned tasks.

Supervisors — Let It Crash

defmodule MyApp.Application do
  use Application

  @impl true
  def start(_type, _args) do
    children = [
      # Order matters — started top to bottom, stopped bottom to top
      MyApp.Repo,                           # Ecto repo
      {MyApp.Cache, []},                    # Custom GenServer
      {Task.Supervisor, name: MyApp.TaskSupervisor},
      MyAppWeb.Endpoint                     # Phoenix endpoint last
    ]

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

Restart strategies:

  • :one_for_one — only restart the crashed child (most common)
  • :one_for_all — restart all children if any crashes (tightly coupled)
  • :rest_for_one — restart crashed child and all children started after it

DynamicSupervisor — Runtime Child Management

defmodule MyApp.SessionSupervisor do
  use DynamicSupervisor

  def start_link(_), do: DynamicSupervisor.start_link(__MODULE__, :ok, name: __MODULE__)
  def init(:ok), do: DynamicSupervisor.init(strategy: :one_for_one)

  def start_session(session_id) do
    spec = {MyApp.Session, session_id}
    DynamicSupervisor.start_child(__MODULE__, spec)
  end
end

OTP Releases & Deployment

Building a Release

# Generate release config (Phoenix projects)
mix phx.gen.release

# Build the release
MIX_ENV=prod mix release

# The release is self-contained — no Erlang/Elixir needed on target
_build/prod/rel/my_app/bin/my_app start       # Foreground
_build/prod/rel/my_app/bin/my_app daemon       # Background
_build/prod/rel/my_app/bin/my_app remote       # Attach IEx to running node
_build/prod/rel/my_app/bin/my_app eval "MyApp.Seeds.run()"  # One-off command

Release Configuration Files

  • config/config.exscompile-time config (before code compiles)
  • config/runtime.exsruntime config (executed on every boot) — use for env vars, secrets
  • rel/env.sh.eex — shell environment for the release (VM flags, env vars)
  • rel/vm.args.eex — Erlang VM flags (node name, cookie, memory limits)

Docker Multistage Build Pattern

# === Builder Stage ===
FROM hexpm/elixir:1.19.5-erlang-27.3-debian-bookworm-20250317 AS builder

RUN apt-get update -y && apt-get install -y build-essential git && apt-get clean
WORKDIR /app

ENV MIX_ENV=prod
RUN mix local.hex --force && mix local.rebar --force

COPY mix.exs mix.lock ./
RUN mix deps.get --only prod && mix deps.compile

COPY config/config.exs config/prod.exs config/
COPY priv priv
COPY lib lib
COPY assets assets  # If Phoenix

RUN mix assets.deploy  # If Phoenix
RUN mix compile
RUN mix release

# === Runner Stage ===
FROM debian:bookworm-slim AS runner

RUN apt-get update -y && \
    apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates && \
    apt-get clean && rm -f /var/lib/apt/lists/*_*

ENV LANG=en_US.UTF-8
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen

WORKDIR /app
RUN useradd --create-home app && chown -R app:app /app
USER app

COPY --from=builder --chown=app:app /app/_build/prod/rel/my_app ./

CMD ["bin/my_app", "start"]

Distributed Erlang in Production

# In rel/env.sh.eex — set node name and cookie
export RELEASE_DISTRIBUTION=name
export RELEASE_NODE=my_app@${HOSTNAME}
export RELEASE_COOKIE=my_secret_cookie

# Or in runtime.exs
config :my_app, MyApp.Cluster,
  strategy: Cluster.Strategy.DNSPoll,
  config: [
    polling_interval: 5_000,
    query: "my-app.local",
    node_basename: "my_app"
  ]

Key libraries for clustering: libcluster (automatic node discovery), Horde (distributed supervisor/registry), Phoenix.PubSub (cross-node pub/sub already built into Phoenix).


Monitoring & Instrumentation

The Elixir Observability Stack

  • PromEx — Elixir library exposing /metrics endpoint for Prometheus scraping. Includes plugins for Phoenix, Ecto, LiveView, BEAM VM, Oban
  • Prometheus — scrapes and stores time-series metrics
  • Loki + Promtail — log aggregation (Promtail scrapes Docker container logs → Loki stores)
  • Grafana — visualization dashboards for both metrics and logs
  • Alloy — OpenTelemetry collector bridging metrics to Prometheus

Adding PromEx to a Phoenix App

# mix.exs
{:prom_ex, "~> 1.9"}

# lib/my_app/prom_ex.ex
defmodule MyApp.PromEx do
  use PromEx, otp_app: :my_app

  @impl true
  def plugins do
    [
      PromEx.Plugins.Application,
      PromEx.Plugins.Beam,
      {PromEx.Plugins.Phoenix, router: MyAppWeb.Router},
      {PromEx.Plugins.Ecto, repos: [MyApp.Repo]},
      PromEx.Plugins.Oban
    ]
  end

  @impl true
  def dashboards do
    [{:prom_ex, "application.json"}, {:prom_ex, "beam.json"},
     {:prom_ex, "phoenix.json"}, {:prom_ex, "ecto.json"}]
  end
end

Key BEAM Metrics to Watch

  • erlang_vm_process_count — total BEAM processes (normal: thousands, alarm at millions)
  • erlang_vm_memory_bytes_total — total VM memory
  • erlang_vm_atom_count — atoms are never GC'd; watch for leaks
  • phoenix_endpoint_stop_duration_milliseconds — request latency
  • ecto_repo_query_duration_milliseconds — database query time

usage_rules — AI Agent Documentation

Always include in every project. This is non-negotiable.

# mix.exs deps
{:usage_rules, "~> 1.1", only: [:dev]}

# mix.exs project config
def project do
  [
    # ... other config
    usage_rules: [
      packages: :all,  # Or specific: ["phoenix", "ecto", ~r/ash/]
      output: :agents_md,  # Generates AGENTS.md
      mode: :linked  # Or :inlined for single-file
    ]
  ]
end

Key commands:

mix usage_rules.gen          # Generate AGENTS.md from deps
mix usage_rules.search_docs  # Search hex documentation
mix usage_rules.gen_skill    # Generate SKILL.md for Cowork

This consolidates usage-rules.md files from all dependencies into a single reference document, giving any AI agent working on the project full context about library APIs, patterns, and conventions.


Ash Framework — Production Backend

Ash is a declarative, resource-oriented framework for building Elixir backends. Use it for substantial projects — it handles data layer, authorization, validation, and API generation.

Core Concepts

  • Resources — the central abstraction (like models but richer): attributes, actions, relationships, calculations, aggregates, policies
  • Domains — organizational containers that group related resources and define the public API
  • Actions — CRUD + custom actions defined declaratively on resources
  • Data Layers — pluggable persistence (AshPostgres, AshSqlite, ETS for dev)
  • Policies — declarative authorization rules on resources/actions
  • Extensions — AshPhoenix, AshGraphql, AshJsonApi, AshAuthentication, AshOban

Quick Start

mix igniter.new my_app --install ash,ash_postgres,ash_phoenix

Resource Example

defmodule MyApp.Blog.Post do
  use Ash.Resource,
    domain: MyApp.Blog,
    data_layer: AshPostgres.DataLayer

  postgres do
    table "posts"
    repo MyApp.Repo
  end

  attributes do
    uuid_primary_key :id
    attribute :title, :string, allow_nil?: false
    attribute :body, :string, allow_nil?: false
    attribute :status, :atom, constraints: [one_of: [:draft, :published]], default: :draft
    timestamps()
  end

  actions do
    defaults [:read, :destroy]

    create :create do
      accept [:title, :body]
    end

    update :publish do
      change set_attribute(:status, :published)
      change set_attribute(:published_at, &DateTime.utc_now/0)
    end
  end

  relationships do
    belongs_to :author, MyApp.Accounts.User
    has_many :comments, MyApp.Blog.Comment
  end

  policies do
    policy action_type(:create) do
      authorize_if actor_attribute_equals(:role, :author)
    end
    policy action_type(:read) do
      authorize_if always()
    end
  end
end

Domain Example

defmodule MyApp.Blog do
  use Ash.Domain

  resources do
    resource MyApp.Blog.Post
    resource MyApp.Blog.Comment
  end
end

Using Resources

# Create
MyApp.Blog.Post
|> Ash.Changeset.for_create(:create, %{title: "Hello", body: "World"})
|> Ash.create!()

# Read with filters
MyApp.Blog.Post
|> Ash.Query.filter(status == :published)
|> Ash.Query.sort(inserted_at: :desc)
|> Ash.Query.limit(10)
|> Ash.read!()

# Custom action
post |> Ash.Changeset.for_update(:publish) |> Ash.update!()

Mix Project Structure

my_app/
├── config/
│   ├── config.exs          # Compile-time config
│   ├── dev.exs
│   ├── prod.exs
│   ├── runtime.exs         # Runtime config (secrets, env vars)
│   └── test.exs
├── lib/
│   ├── my_app/
│   │   ├── application.ex  # OTP Application + supervisor tree
│   │   ├── repo.ex         # Ecto Repo
│   │   └── ...             # Domain modules
│   └── my_app_web/         # Phoenix web layer (if applicable)
│       ├── endpoint.ex
│       ├── router.ex
│       ├── controllers/
│       ├── live/            # LiveView modules
│       └── components/
├── priv/
│   ├── repo/migrations/
│   └── static/
├── test/
│   ├── support/
│   └── ...
├── mix.exs
├── mix.lock
├── .formatter.exs
└── AGENTS.md               # Generated by usage_rules

Testing

# test/my_app/blog_test.exs
defmodule MyApp.BlogTest do
  use MyApp.DataCase, async: true  # Async when no shared state

  describe "creating posts" do
    test "creates with valid attributes" do
      assert {:ok, post} = Blog.create_post(%{title: "Hi", body: "World"})
      assert post.title == "Hi"
      assert post.status == :draft
    end

    test "fails without title" do
      assert {:error, changeset} = Blog.create_post(%{body: "World"})
      assert "can't be blank" in errors_on(changeset).title
    end
  end
end

Testing philosophy: Use async: true wherever possible. Ecto's SQL Sandbox allows concurrent test execution. For GenServer tests, start under a test supervisor. For external services, use Mox for behaviour-based mocking.


Phoenix Framework — v1.8.5 (Current)

Phoenix is the web framework for Elixir. Version 1.8.5 is current. It provides MVC controllers, real-time LiveView, Channels for WebSocket communication, and comprehensive tooling for authentication, testing, and deployment.

What's New in Phoenix 1.8

  • Scopes in generators — secure data access by default (e.g., current_user automatically applied)
  • Magic links (passwordless auth) and "sudo mode" in mix phx.gen.auth
  • daisyUI integration — light/dark/system mode support out of the box
  • Simplified layouts — single root.html.heex wraps everything; dynamic layouts are function components
  • use Phoenix.Controller now requires :formats — must specify formats: [:html] or formats: [:html, :json]
  • Updated security headerscontent-security-policy with base-uri 'self'; frame-ancestors 'self'; dropped deprecated x-frame-options and x-download-options
  • config variable removed from Phoenix.Endpoint — use Application.compile_env/3 instead
  • Deprecated: :namespace, :put_default_views, layouts without modules, :trailing_slash in router

Project Setup

# New project (Phoenix Express — quickest path)
curl https://new.phoenixframework.org/my_app | sh

# Traditional setup
mix phx.new my_app
mix phx.new my_app --no-ecto    # Without database
mix phx.new my_app --no-html    # API only
mix phx.new my_app --database sqlite3  # SQLite instead of Postgres

Directory Structure

my_app/
├── lib/
│   ├── my_app/              # Business logic (contexts, schemas)
│   │   ├── application.ex   # Supervision tree
│   │   ├── repo.ex          # Ecto Repo
│   │   └── catalog/         # Context: Catalog
│   │       ├── product.ex   # Schema
│   │       └── catalog.ex   # Context module
│   └── my_app_web/          # Web layer
│       ├── endpoint.ex      # HTTP entry point
│       ├── router.ex        # Routes + pipelines
│       ├── components/
│       │   ├── core_components.ex  # Shared UI components
│       │   └── layouts.ex          # Layout components
│       ├── controllers/
│       │   ├── page_controller.ex
│       │   └── page_html/          # Templates for controller
│       │       └── home.html.heex
│       └── live/             # LiveView modules
│           └── counter_live.ex
├── config/
│   ├── config.exs            # Compile-time config
│   ├── dev.exs / prod.exs
│   └── runtime.exs           # Runtime config (env vars, secrets)
├── priv/
│   ├── repo/migrations/
│   └── static/               # Static assets
├── test/
│   ├── support/
│   │   ├── conn_case.ex      # Test helpers for controllers
│   │   └── data_case.ex      # Test helpers for data layer
│   └── my_app_web/
└── mix.exs

Router & Pipelines

defmodule MyAppWeb.Router do
  use MyAppWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", MyAppWeb do
    pipe_through :browser

    get "/", PageController, :home
    live "/counter", CounterLive         # LiveView route
    resources "/products", ProductController  # RESTful CRUD
  end

  scope "/api", MyAppWeb do
    pipe_through :api

    resources "/items", ItemController, except: [:new, :edit]
  end
end

Verified Routes — ~p Sigil

Always use ~p instead of string paths. Compile-time verified against your router.

# In templates (HEEx)
~H"""
<a href={~p"/products/#{product}"}>View</a>
<a href={~p"/products/#{product}/edit"}>Edit</a>
"""

# In controllers
redirect(conn, to: ~p"/products/#{product}")

# With query params
~p"/products?page=#{page}&sort=name"
~p"/products?#{%{page: 1, sort: "name"}}"

# URL generation (includes host)
url(~p"/products/#{product}")
# => "https://example.com/products/42"

Controllers (1.8 Style)

defmodule MyAppWeb.ProductController do
  use MyAppWeb, :controller

  # REQUIRED in 1.8: specify formats
  # This is typically set in MyAppWeb :controller function

  def index(conn, _params) do
    products = Catalog.list_products()
    render(conn, :index, products: products)
  end

  def show(conn, %{"id" => id}) do
    product = Catalog.get_product!(id)
    render(conn, :show, product: product)
  end

  def create(conn, %{"product" => product_params}) do
    case Catalog.create_product(product_params) do
      {:ok, product} ->
        conn
        |> put_flash(:info, "Product created.")
        |> redirect(to: ~p"/products/#{product}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :new, changeset: changeset)
    end
  end
end

View module naming: For ProductController, Phoenix looks for ProductHTML (for :html format) and ProductJSON (for :json format).

# lib/my_app_web/controllers/product_html.ex
defmodule MyAppWeb.ProductHTML do
  use MyAppWeb, :html
  embed_templates "product_html/*"  # Loads .heex files from directory
end

# lib/my_app_web/controllers/product_json.ex
defmodule MyAppWeb.ProductJSON do
  def index(%{products: products}), do: %{data: for(p <- products, do: data(p))}
  def show(%{product: product}), do: %{data: data(product)}
  defp data(product), do: %{id: product.id, title: product.title, price: product.price}
end

Components and HEEx

defmodule MyAppWeb.CoreComponents do
  use Phoenix.Component

  # Declare attributes with types and docs
  attr :type, :string, default: "button"
  attr :class, :string, default: nil
  attr :rest, :global  # Passes through all other HTML attrs
  slot :inner_block, required: true

  def button(assigns) do
    ~H"""
    <button type={@type} class={["btn", @class]} {@rest}>
      {render_slot(@inner_block)}
    </button>
    """
  end

  # Table component with slots
  attr :rows, :list, required: true
  slot :col, required: true do
    attr :label, :string
  end

  def table(assigns) do
    ~H"""
    <table>
      <thead>
        <tr>
          <th :for={col <- @col}>{col[:label]}</th>
        </tr>
      </thead>
      <tbody>
        <tr :for={row <- @rows}>
          <td :for={col <- @col}>{render_slot(col, row)}</td>
        </tr>
      </tbody>
    </table>
    """
  end
end

HEEx syntax notes:

  • {@var} — render assign (curly braces, not <%= %>)
  • :if={condition} — conditional rendering on any tag
  • :for={item <- list} — iteration on any tag
  • <.component_name /> — call function component with dot notation
  • <Component.name /> — call remote component with module name
  • {render_slot(@inner_block)} — render slot content
  • <:slot_name>content</:slot_name> — named slot content

LiveView

defmodule MyAppWeb.SearchLive do
  use MyAppWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok, assign(socket, query: "", results: [])}
  end

  def handle_params(%{"q" => query}, _uri, socket) do
    {:noreply, assign(socket, query: query, results: search(query))}
  end

  def handle_params(_params, _uri, socket), do: {:noreply, socket}

  def handle_event("search", %{"query" => query}, socket) do
    {:noreply,
     socket
     |> assign(query: query, results: search(query))
     |> push_patch(to: ~p"/search?q=#{query}")}
  end

  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <form phx-submit="search">
        <input name="query" value={@query} phx-debounce="300" />
        <button type="submit">Search</button>
      </form>

      <div :for={result <- @results} class="result">
        <h3>{result.title}</h3>
        <p>{result.summary}</p>
      </div>
    </Layouts.app>
    """
  end

  defp search(query), do: MyApp.Search.find(query)
end

LiveView lifecycle: mount/3handle_params/3render/1. Events via handle_event/3. Server pushes via handle_info/2.

Key patterns:

  • assign/2,3 — set socket assigns
  • push_navigate/2 — navigate to new LiveView (full mount)
  • push_patch/2 — update URL without full remount (calls handle_params)
  • push_event/3 — push event to client JS hooks
  • stream/3,4 — efficient list rendering for large collections (inserts/deletes without re-rendering entire list)
  • async_assign/3 + assign_async/3 — async data loading with loading/error states

Channels — Real-Time WebSocket

# In endpoint.ex
socket "/socket", MyAppWeb.UserSocket,
  websocket: true,
  longpoll: false

# lib/my_app_web/channels/user_socket.ex
defmodule MyAppWeb.UserSocket do
  use Phoenix.Socket

  channel "room:*", MyAppWeb.RoomChannel

  def connect(%{"token" => token}, socket, _connect_info) do
    case Phoenix.Token.verify(socket, "user auth", token, max_age: 86400) do
      {:ok, user_id} -> {:ok, assign(socket, :user_id, user_id)}
      {:error, _} -> :error
    end
  end

  def id(socket), do: "users_socket:#{socket.assigns.user_id}"
end

# lib/my_app_web/channels/room_channel.ex
defmodule MyAppWeb.RoomChannel do
  use MyAppWeb, :channel

  def join("room:" <> room_id, _params, socket) do
    {:ok, assign(socket, :room_id, room_id)}
  end

  def handle_in("new_msg", %{"body" => body}, socket) do
    broadcast!(socket, "new_msg", %{
      body: body,
      user_id: socket.assigns.user_id
    })
    {:noreply, socket}
  end
end

Force disconnect all sessions for a user:

MyAppWeb.Endpoint.broadcast("users_socket:#{user.id}", "disconnect", %{})

Contexts — Business Logic Boundary

Contexts are plain Elixir modules that encapsulate data access and business rules. They are the API between your web layer and your domain.

# Generate with: mix phx.gen.context Catalog Product products title:string price:decimal
defmodule MyApp.Catalog do
  import Ecto.Query
  alias MyApp.Repo
  alias MyApp.Catalog.Product

  def list_products do
    Repo.all(Product)
  end

  def get_product!(id), do: Repo.get!(Product, id)

  def create_product(attrs \\ %{}) do
    %Product{}
    |> Product.changeset(attrs)
    |> Repo.insert()
  end

  def update_product(%Product{} = product, attrs) do
    product
    |> Product.changeset(attrs)
    |> Repo.update()
  end

  def delete_product(%Product{} = product) do
    Repo.delete(product)
  end

  def change_product(%Product{} = product, attrs \\ %{}) do
    Product.changeset(product, attrs)
  end
end

Context design principles:

  • One context per bounded domain (Catalog, Accounts, Orders)
  • Contexts own their schemas — other contexts reference by ID, not struct
  • Cross-context calls go through the public context API, never access another context's Repo directly
  • Contexts can nest related schemas (Comments under Posts)

Authentication — mix phx.gen.auth

mix phx.gen.auth Accounts User users

Phoenix 1.8 generates:

  • Magic links (passwordless) — email-based login links
  • "Sudo mode" — re-authentication for sensitive actions
  • Session-based auth with secure token handling
  • Email confirmation and password reset flows
  • require_authenticated_user plug for protected routes

Scopes (New in 1.8)

Scopes make secure data access the default in generators. When you generate resources with a scope, all queries are automatically filtered by the scoped user.

mix phx.gen.live Posts Post posts title body:text --scope current_user

This generates code that automatically passes current_user to context functions, ensuring users only see their own data.

Ecto — Database Layer

# Schema
defmodule MyApp.Catalog.Product do
  use Ecto.Schema
  import Ecto.Changeset

  schema "products" do
    field :title, :string
    field :price, :decimal
    field :status, Ecto.Enum, values: [:draft, :published, :archived]
    has_many :reviews, MyApp.Reviews.Review
    belongs_to :category, MyApp.Catalog.Category
    timestamps(type: :utc_datetime)
  end

  def changeset(product, attrs) do
    product
    |> cast(attrs, [:title, :price, :status, :category_id])
    |> validate_required([:title, :price])
    |> validate_number(:price, greater_than: 0)
    |> unique_constraint(:title)
    |> foreign_key_constraint(:category_id)
  end
end

# Queries
import Ecto.Query

# Composable queries
def published(query \\ Product) do
  from p in query, where: p.status == :published
end

def recent(query, days \\ 7) do
  from p in query, where: p.inserted_at > ago(^days, "day")
end

def with_reviews(query) do
  from p in query, preload: [:reviews]
end

# Usage: Product |> published() |> recent(30) |> with_reviews() |> Repo.all()

Telemetry — Built-in Observability

Phoenix 1.8 includes a Telemetry supervisor that tracks request duration, Ecto query times, and VM metrics out of the box.

# lib/my_app_web/telemetry.ex (auto-generated)
defmodule MyAppWeb.Telemetry do
  use Supervisor
  import Telemetry.Metrics

  def metrics do
    [
      summary("phoenix.endpoint.stop.duration", unit: {:native, :millisecond}),
      summary("phoenix.router_dispatch.stop.duration", tags: [:route], unit: {:native, :millisecond}),
      summary("my_app.repo.query.total_time", unit: {:native, :millisecond}),
      summary("vm.memory.total", unit: {:byte, :kilobyte}),
      summary("vm.total_run_queue_lengths.total"),
      summary("vm.total_run_queue_lengths.cpu"),
    ]
  end
end

Integrates with PromEx for Prometheus/Grafana dashboards (see Monitoring section).

Phoenix Security Best Practices

Never pass untrusted input to: Code.eval_string/3, :os.cmd/2, System.cmd/3, System.shell/2, :erlang.binary_to_term/2

Ecto prevents SQL injection by default — the query DSL parameterizes all inputs. Only Ecto.Adapters.SQL.query/4 with raw string interpolation is vulnerable.

Safe deserialization:

# UNSAFE — even with :safe flag
:erlang.binary_to_term(user_input, [:safe])

# SAFE — prevents executable terms
Plug.Crypto.non_executable_binary_to_term(user_input, [:safe])

CSRF protection is built into the :browser pipeline via protect_from_forgery. Content Security Policy is set by put_secure_browser_headers.

Testing Phoenix

# Controller test
defmodule MyAppWeb.ProductControllerTest do
  use MyAppWeb.ConnCase

  test "GET /products", %{conn: conn} do
    conn = get(conn, ~p"/products")
    assert html_response(conn, 200) =~ "Products"
  end

  test "POST /products creates product", %{conn: conn} do
    conn = post(conn, ~p"/products", product: %{title: "Widget", price: 9.99})
    assert redirected_to(conn) =~ ~p"/products/"
  end
end

# LiveView test
defmodule MyAppWeb.CounterLiveTest do
  use MyAppWeb.ConnCase
  import Phoenix.LiveViewTest

  test "increments counter", %{conn: conn} do
    {:ok, view, html} = live(conn, ~p"/counter")
    assert html =~ "Count: 0"

    assert view
           |> element("button", "+1")
           |> render_click() =~ "Count: 1"
  end
end

# Channel test
defmodule MyAppWeb.RoomChannelTest do
  use MyAppWeb.ChannelCase

  test "broadcasts new messages" do
    {:ok, _, socket} = subscribe_and_join(socket(MyAppWeb.UserSocket), MyAppWeb.RoomChannel, "room:lobby")

    push(socket, "new_msg", %{"body" => "hello"})
    assert_broadcast "new_msg", %{body: "hello"}
  end
end

Phoenix Generators Cheat Sheet

# HTML CRUD (controller + views + templates + context + schema + migration)
mix phx.gen.html Catalog Product products title:string price:decimal

# LiveView CRUD
mix phx.gen.live Catalog Product products title:string price:decimal

# JSON API
mix phx.gen.json Catalog Product products title:string price:decimal

# Context + schema only (no web layer)
mix phx.gen.context Catalog Product products title:string price:decimal

# Schema + migration only
mix phx.gen.schema Product products title:string price:decimal

# Authentication
mix phx.gen.auth Accounts User users

# Channel
mix phx.gen.channel Room

# Presence
mix phx.gen.presence

# Release files (Dockerfile, release.ex, overlay scripts)
mix phx.gen.release
mix phx.gen.release --docker  # Include Dockerfile

Phoenix Release & Deployment

# Generate release infrastructure
mix phx.gen.release --docker

# Build release
MIX_ENV=prod mix deps.get --only prod
MIX_ENV=prod mix compile
MIX_ENV=prod mix assets.deploy
MIX_ENV=prod mix release

# Release commands
_build/prod/rel/my_app/bin/server     # Start with Phoenix server
_build/prod/rel/my_app/bin/migrate    # Run migrations
_build/prod/rel/my_app/bin/my_app remote  # Attach IEx console

Runtime config (config/runtime.exs):

import Config

if config_env() == :prod do
  database_url = System.fetch_env!("DATABASE_URL")
  secret_key_base = System.fetch_env!("SECRET_KEY_BASE")

  config :my_app, MyApp.Repo,
    url: database_url,
    pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")

  config :my_app, MyAppWeb.Endpoint,
    url: [host: System.fetch_env!("PHX_HOST"), port: 443, scheme: "https"],
    http: [ip: {0, 0, 0, 0}, port: String.to_integer(System.get_env("PORT") || "4000")],
    secret_key_base: secret_key_base
end

Common Libraries

Library Purpose Hex
Phoenix Web framework + LiveView {:phoenix, "~> 1.8"}
Ecto Database wrapper + query DSL {:ecto_sql, "~> 3.12"}
Ash Declarative resource framework {:ash, "~> 3.0"}
Oban Background job processing {:oban, "~> 2.18"}
Req HTTP client (modern, composable) {:req, "~> 0.5"}
Jason JSON encoding/decoding {:jason, "~> 1.4"}
Swoosh Email sending {:swoosh, "~> 1.16"}
ExUnit Testing (built-in)
Mox Mock behaviours for testing {:mox, "~> 1.1", only: :test}
Credo Static analysis / linting {:credo, "~> 1.7", only: [:dev, :test]}
Dialyxir Static typing via Dialyzer {:dialyxir, "~> 1.4", only: [:dev, :test]}
libcluster Automatic BEAM node clustering {:libcluster, "~> 3.4"}
Horde Distributed supervisor/registry {:horde, "~> 0.9"}
Nx Numerical computing / tensors {:nx, "~> 0.9"}
Bumblebee Pre-trained ML models on BEAM {:bumblebee, "~> 0.6"}
Broadway Data ingestion pipelines {:broadway, "~> 1.1"}
PromEx Prometheus metrics for Elixir {:prom_ex, "~> 1.9"}
Finch HTTP client (low-level, pooled) {:finch, "~> 0.19"}
usage_rules AI agent docs from deps {:usage_rules, "~> 1.1", only: :dev}

Interop — Elixir as Orchestrator

Python via Ports/Erlport

# Using erlport for bidirectional Python calls
{:ok, pid} = :python.start([{:python_path, ~c"./python_scripts"}])
result = :python.call(pid, :my_module, :my_function, [arg1, arg2])
:python.stop(pid)

Rust via Rustler NIFs

# mix.exs
{:rustler, "~> 0.34"}

# lib/my_nif.ex
defmodule MyApp.NativeSort do
  use Rustler, otp_app: :my_app, crate: "native_sort"

  # NIF stubs — replaced at load time by Rust implementations
  def sort(_list), do: :erlang.nif_error(:nif_not_loaded)
end

System Commands via Ports

# One-shot command
{output, 0} = System.cmd("ffmpeg", ["-i", input, "-o", output])

# Long-running port
port = Port.open({:spawn, "python3 worker.py"}, [:binary, :exit_status])
send(port, {self(), {:command, "process\n"}})
receive do
  {^port, {:data, data}} -> handle_response(data)
end

Cortex Deployment Story

Current State

  • Cortex runs Ubuntu 24.04 with Caddy as web server
  • Elixir is not yet installed — needs asdf or direct install
  • Docker is planned but not installed — needed for containerized Elixir apps
  • Symbiont (Python) is the current orchestrator — Elixir will gradually take over

Installation Plan for Cortex

# Option 1: asdf (recommended — manages multiple versions)
git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.14.0
echo '. "$HOME/.asdf/asdf.sh"' >> ~/.bashrc
asdf plugin add erlang
asdf plugin add elixir
asdf install erlang 27.3
asdf install elixir 1.19.5-otp-27
asdf global erlang 27.3
asdf global elixir 1.19.5-otp-27

# Option 2: Direct from Erlang Solutions
wget https://packages.erlang-solutions.com/erlang-solutions_2.0_all.deb
dpkg -i erlang-solutions_2.0_all.deb
apt-get update && apt-get install esl-erlang elixir

BEAMOps Principles for Cortex

From "Engineering Elixir Applications" — the deployment philosophy:

  1. Environment Integrity — identical builds dev/staging/prod via Docker + releases
  2. Infrastructure as Code — Caddy config, systemd units, backup scripts all version-controlled
  3. OTP Releases — self-contained, no runtime deps, bin/my_app start
  4. Distributed Erlang — nodes discover each other, share state via PubSub, global registry
  5. Instrumentation — PromEx + Prometheus + Grafana + Loki for full observability
  6. Health Checks + Rollbacks — Docker health checks trigger automatic rollback on failed deploys
  7. Zero-downtime deploys — rolling updates via Docker Swarm or mix release hot upgrades

CI Pipeline for Elixir (GitHub Actions)

name: Elixir CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      db:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
        ports: ['5432:5432']
    steps:
      - uses: actions/checkout@v4
      - uses: erlef/setup-beam@v1
        with:
          elixir-version: '1.19.5'
          otp-version: '27.3'
      - run: mix deps.get
      - run: mix compile --warnings-as-errors
      - run: mix format --check-formatted
      - run: mix credo --strict
      - run: mix test
      - run: mix deps.unlock --check-unused

Anti-Patterns to Avoid

Process Anti-Patterns

  • GenServer as code organization — don't wrap pure functions in a GenServer. Use modules.
  • Agent for complex state — if you need more than get/update, use GenServer directly.
  • Spawning unsupervised processes — always use Task.Supervisor or link to a supervisor.

Code Anti-Patterns

  • Primitive obsession — use structs, not bare maps, for domain concepts.
  • Boolean parameters — use atoms or keyword options: format: :json not json: true.
  • Large modules — split by concern, not by entity type. Domain logic, web layer, workers.
  • String keys in internal maps — use atoms internally, strings only at boundaries (JSON, forms).

Design Anti-Patterns

  • Monolithic contexts — Phoenix contexts should be small, focused. Split Accounts from Authentication.
  • God GenServer — one process handling all state for the app. Distribute responsibility.
  • Synchronous calls to slow services — use Task.async + Task.await with timeouts.

Quick Recipes

HTTP Request with Req

Req.get!("https://api.example.com/data",
  headers: [{"authorization", "Bearer #{token}"}],
  receive_timeout: 15_000
).body

JSON Encode/Decode

Jason.encode!(%{name: "Michael", role: :admin})
Jason.decode!(~s({"name": "Michael"}), keys: :atoms)

Ecto Query

from p in Post,
  where: p.status == :published,
  where: p.inserted_at > ago(7, "day"),
  order_by: [desc: p.inserted_at],
  limit: 10,
  preload: [:author, :comments]

Background Job with Oban

defmodule MyApp.Workers.EmailWorker do
  use Oban.Worker, queue: :mailers, max_attempts: 3

  @impl true
  def perform(%Oban.Job{args: %{"to" => to, "template" => template}}) do
    MyApp.Mailer.send(to, template)
  end
end

# Enqueue
%{to: "user@example.com", template: "welcome"}
|> MyApp.Workers.EmailWorker.new(schedule_in: 60)
|> Oban.insert!()

LiveView Component

defmodule MyAppWeb.CounterLive do
  use MyAppWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok, assign(socket, count: 0)}
  end

  def handle_event("increment", _, socket) do
    {:noreply, update(socket, :count, &(&1 + 1))}
  end

  def render(assigns) do
    ~H"""
    <div>
      <h1>Count: {@count}</h1>
      <button phx-click="increment">+1</button>
    </div>
    """
  end
end

Resources

  • Elixir Official Docs — always check 1.19.5 version
  • Ash Framework Docs — resource-oriented patterns
  • Phoenix HexDocs — web framework
  • Elixir Forum — community Q&A
  • Elixir School — learning resource
  • "Elixir in Action" by Saša Jurić — deep BEAM/OTP understanding (note: covers 1.15, check breaking changes above)
  • "Engineering Elixir Applications" by Fairholm & D'Lacoste — BEAMOps deployment patterns