skills/elixir/SKILL.md

50 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 1.19.5 / OTP 27 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 (March 2026)

  • Cortex runs Ubuntu 24.04 with Caddy as web server
  • Elixir 1.19.5 / OTP 27 installed via Erlang Solutions + GitHub releases
  • Symbiont Elixir service running on port 8111 (Python Symbiont retired)
  • systemd unit: symbiont-ex-api.service

Proven Installation Steps for Ubuntu 24.04

Important: Ubuntu 24.04 repos ship ancient Elixir 1.14 / OTP 25. Do NOT use apt install elixir.

# Step 1: Remove Ubuntu's outdated Erlang/Elixir packages
apt-get remove -y erlang-base elixir erlang-dev erlang-parsetools erlang-syntax-tools
apt-get autoremove -y

# Step 2: Install Erlang 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 -y esl-erlang   # Installs OTP 27.x

# Step 3: Install precompiled Elixir from GitHub releases
# Check latest: curl -sL https://api.github.com/repos/elixir-lang/elixir/releases?per_page=5 | grep tag_name
ELIXIR_VERSION="1.19.5"
cd /opt
wget https://github.com/elixir-lang/elixir/releases/download/v${ELIXIR_VERSION}/elixir-otp-27.zip
unzip elixir-otp-27.zip -d elixir-${ELIXIR_VERSION}

# Step 4: Symlink binaries
for bin in elixir elixirc iex mix; do
  ln -sf /opt/elixir-${ELIXIR_VERSION}/bin/${bin} /usr/local/bin/${bin}
done

# Step 5: Verify
elixir --version   # Should show Elixir 1.19.5 (compiled with Erlang/OTP 27)

Upgrading Elixir

To find the latest stable version:

curl -sL https://api.github.com/repos/elixir-lang/elixir/releases?per_page=10 | grep tag_name

Look for the highest non-rc tag. Then repeat Steps 3-5 above with the new version number.

systemd Service Template

[Unit]
Description=Symbiont Elixir API
After=network.target

[Service]
Type=simple
# CRITICAL: mix requires HOME to be set
Environment=HOME=/root
Environment=MIX_ENV=prod
WorkingDirectory=/root/symbiont_ex
ExecStart=/usr/local/bin/mix run --no-halt
Restart=on-failure

[Install]
WantedBy=multi-user.target

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

AI Agent Lessons Learned (Symbiont Migration, March 2026)

Hard-won lessons from building the Elixir Symbiont orchestrator. These are things Claude (and other AI agents) got wrong or didn't know — preserved here so we don't repeat them.

System.cmd/3 Does NOT Have an :input Option

This is a persistent hallucination. No version of Elixir (1.14 through 1.19) supports passing stdin via System.cmd/3. The :input option simply does not exist.

Wrong (will silently ignore the option or error):

System.cmd("claude", ["-p", "--model", "haiku"], input: prompt)

Correct — use System.shell/2 with a pipe:

escaped = prompt |> String.replace("'", "'\\''")
{output, exit_code} = System.shell("printf '%s' '#{escaped}' | claude -p --model haiku 2>&1")

Or use Erlang Ports directly for full stdin/stdout control:

port = Port.open({:spawn_executable, "/usr/local/bin/claude"}, [:binary, :exit_status, args: ["-p"]])
Port.command(port, prompt)
Port.command(port, :eof)  # signal end of input

Float.round/2 Requires a Float Argument

Float.round(0, 4) crashes with FunctionClauseError because 0 is an integer, not a float. This commonly bites when summing an empty list — Enum.sum([]) returns 0 (integer), not 0.0.

Wrong:

entries |> Enum.map(& &1["cost"]) |> Enum.sum() |> Float.round(4)
# Crashes when entries is empty!

Correct — use a float accumulator:

entries
|> Enum.reduce(0.0, fn entry, acc -> acc + to_float(entry["cost"]) end)
|> Float.round(4)

defp to_float(nil), do: 0.0
defp to_float(n) when is_float(n), do: n
defp to_float(n) when is_integer(n), do: n * 1.0
defp to_float(_), do: 0.0

Heredoc Closing """ Must Be on Its Own Line

Module attributes with heredocs are tricky. The closing """ cannot share a line with content.

Wrong (syntax error):

@prompt """
Classify this task: """

Correct — use string concatenation for prompts with trailing content:

@prompt "Classify this task. Respond with JSON: " <>
  ~s({"tier": 1|2|3, "reason": "brief explanation"}\n\n) <>
  "Task: "

OTP Application Supervisors vs. Test Isolation

When your application.ex starts GenServers in the supervision tree, those processes auto-start when mix test runs. Tests that call start_link for the same named process will crash with {:error, {:already_started, pid}}.

Solution: Start an empty supervisor in test mode:

# application.ex
def start(_type, _args) do
  if Application.get_env(:symbiont, :port) == 0 do
    Supervisor.start_link([], strategy: :one_for_one, name: Symbiont.Supervisor)
  else
    start_full()
  end
end
# config/test.exs
config :symbiont, port: 0  # Signals test mode

Then each test's setup block starts only the processes it needs, with on_exit cleanup:

setup do
  safe_stop(Symbiont.Ledger)
  {:ok, _} = Symbiont.Ledger.start_link(data_dir: tmp_dir)
  on_exit(fn -> safe_stop(Symbiont.Ledger) end)
end

def safe_stop(name) do
  case Process.whereis(name) do
    nil -> :ok
    pid -> try do GenServer.stop(pid) catch :exit, _ -> :ok end
  end
end

use Plug.Test Is Deprecated

In modern Plug (1.15+), use Plug.Test emits a deprecation warning. Replace with explicit imports:

# Old (deprecated)
use Plug.Test

# New
import Plug.Test    # for conn/2, conn/3
import Plug.Conn    # for put_req_header/3, etc.

Ubuntu 24.04 Ships Ancient Elixir

Ubuntu's apt repos have Elixir 1.14 and OTP 25. These are years behind and missing critical features. Never use apt install elixir on Ubuntu. See the "Proven Installation Steps" section above for the correct approach.

Key gotcha: Ubuntu's erlang-base package conflicts with esl-erlang from Erlang Solutions. You must apt-get remove all Ubuntu erlang packages before installing esl-erlang.

systemd Needs Environment=HOME=/root

mix and other Elixir tooling require the HOME environment variable. systemd services don't inherit it. Without Environment=HOME=/root in the unit file, services crash with "could not find the user home."

How to Find the Real Latest Stable Elixir Version

Don't trust AI training data for version numbers. Query the source of truth:

curl -sL https://api.github.com/repos/elixir-lang/elixir/releases?per_page=10 | grep tag_name

Look for the highest version that does NOT contain -rc or -dev. As of March 2026, that's v1.19.5.


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