Elixir
Purpose
Build fault-tolerant, concurrent applications with Elixir/OTP — Phoenix web layer, LiveView interactivity, Ecto persistence, supervision trees, process architecture.
Agent Protocol
Trigger
User request includes: Elixir, Phoenix, Phoenix LiveView, OTP, BEAM, Elixir macros, Ecto, mix, phx.gen, Supervisor, GenServer, Phoenix channels, Phoenix PubSub, Elixir processes, iex.
Input Context
- Framework (Phoenix, Phoenix LiveView, bare Elixir)
- Persistence (Ecto with PostgreSQL, Ecto with SQLite, ETS)
- State management (GenServer, Agent, ETS, Phoenix PubSub)
- Deployment (Elixir releases, Docker, Gigalixir, Fly.io)
Output Artifact
Phoenix project structure, OTP supervision tree, Ecto schema, LiveView module, router config.
Response Format
Produce artifact directly. No preamble, no postamble, no explanations. No filler, no hedging, no transitions. Strip articles a/an/the where unambiguous. Compress output — why use many token when few do trick.
Completion Criteria
- Phoenix project generated with mix phx.new
- Contexts separated by domain boundary
- Ecto schemas with proper associations
- LiveView with state management and event handling
- Supervision tree with appropriate restart strategy
Max Response Length
4096 tokens
Workflow
Step 1: Phoenix Project Setup
# Install Phoenix
mix archive.install hex phx_new
# Create new Phoenix app
mix phx.new my_app --database postgres
mix phx.new my_app --database sqlite3 # Alternative
# Setup database
mix ecto.create
mix ecto.migrate
# Start server
mix phx.server
my_app/
lib/
my_app/
application.ex # OTP application start
repo.ex # Ecto Repo
accounts/ # Context: accounts
user.ex # Ecto schema
user_notifier.ex # Boundary call
user_token.ex
accounts.ex # Context module (public API)
catalog/ # Context: catalog
product.ex
category.ex
catalog.ex
store/ # Context: orders
order.ex
line_item.ex
store.ex
web/
endpoint.ex # Phoenix endpoint
router.ex # Router
controllers/ # Controllers (non-LiveView)
user_session_controller.ex
live/ # LiveViews
product_live/
index.ex
show.ex
cart_live/
index.ex
components/ # Shared components
layout.ex # App layout
navbar.ex
product_card.ex
templates/ # Templates (non-LiveView)
layout/
mailer.ex # Mailer (Swoosh/Bamboo)
my_app.ex # Module aliases
priv/
repo/
migrations/
20250101000000_create_users.exs
config/
config.exs
dev.exs
prod.exs
runtime.exs
mix.exs
Step 2: Ecto Schema and Migration
# priv/repo/migrations/20250101000000_create_users.exs
defmodule MyApp.Repo.Migrations.CreateUsers do
use Ecto.Migration
def change do
create table(:users, primary_key: false) do
add :id, :uuid, primary_key: true, default: fragment("gen_random_uuid()")
add :email, :string, null: false
add :username, :string, null: false
add :hashed_password, :string, null: false
add :role, :string, default: "user"
add :confirmed_at, :naive_datetime
add :deleted_at, :naive_datetime
timestamps()
end
create unique_index(:users, [:email])
create unique_index(:users, [:username])
create index(:users, [:deleted_at])
end
end
# lib/my_app/accounts/user.ex
defmodule MyApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users" do
field :email, :string
field :username, :string
field :role, :string, default: "user"
field :confirmed_at, :naive_datetime
field :deleted_at, :naive_datetime
has_many :orders, MyApp.Store.Order
has_one :profile, MyApp.Accounts.Profile
timestamps()
end
@doc false
def changeset(user, attrs) do
user
|> cast(attrs, [:email, :username, :role])
|> validate_required([:email, :username])
|> validate_format(:email, ~r/@/)
|> validate_length(:username, min: 3, max: 30)
|> unique_constraint(:email)
|> unique_constraint(:username)
end
end
Step 3: Context Boundary
# lib/my_app/accounts/accounts.ex
defmodule MyApp.Accounts do
@moduledoc """
Accounts context — user registration, authentication, profile management.
"""
import Ecto.Query, warn: false
alias MyApp.Repo
alias MyApp.Accounts.{User, UserToken, UserNotifier}
@doc """
Registers new user.
"""
def register_user(attrs) do
%User{}
|> User.registration_changeset(attrs)
|> Repo.insert()
end
@doc """
Returns user by id.
"""
def get_user!(id), do: Repo.get!(User, id)
@doc """
Authenticates user by email and password.
"""
def authenticate_by_email(email, password) do
user = Repo.get_by(User, email: String.downcase(email))
case check_password(user, password) do
true -> {:ok, user}
false -> {:error, :invalid_credentials}
end
end
defp check_password(nil, _password), do: false
defp check_password(user, password) do
Argon2.verify_pass(password, user.hashed_password)
end
@doc """
Lists all active users.
"""
def list_users do
Repo.all(from u in User, where: is_nil(u.deleted_at), order_by: u.inserted_at)
end
end
Step 4: Phoenix Router
# lib/my_app_web/router.ex
defmodule MyAppWeb.Router do
use MyAppWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, {MyAppWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug MyAppWeb.Plugs.Authenticate
end
pipeline :api do
plug :accepts, ["json"]
plug MyAppWeb.Plugs.ApiAuth
end
# Browser routes
scope "/", MyAppWeb do
pipe_through :browser
get "/", PageController, :index
get "/login", UserSessionController, :new
post "/login", UserSessionController, :create
delete "/logout", UserSessionController, :delete
live "/products", ProductLive.Index, :index
live "/products/new", ProductLive.Index, :new
live "/products/:id/edit", ProductLive.Index, :edit
live "/products/:id", ProductLive.Show, :show
end
# Authenticated routes
scope "/", MyAppWeb do
pipe_through [:browser, :require_authenticated]
live "/dashboard", DashboardLive, :index
live "/cart", CartLive.Index, :index
live "/orders", OrderLive.Index, :index
end
# API routes
scope "/api/v1", MyAppWeb do
pipe_through :api
post "/users", Api.UserController, :create
post "/sessions", Api.SessionController, :create
get "/products", Api.ProductController, :index
end
end
Step 5: LiveView
# lib/my_app_web/live/product_live/index.ex
defmodule MyAppWeb.ProductLive.Index do
use MyAppWeb, :live_view
alias MyApp.Catalog
alias MyApp.Catalog.Product
@impl true
def mount(_params, _session, socket) do
socket =
socket
|> assign(:page_title, "Products")
|> stream(:products, Catalog.list_products())
|> assign(:form, to_form(%{search: ""}))
{:ok, socket}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :edit, %{"id" => id}) do
socket
|> assign(:page_title, "Edit Product")
|> assign(:product, Catalog.get_product!(id))
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, "New Product")
|> assign(:product, %Product{})
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "Products")
|> assign(:product, nil)
end
@impl true
def handle_event("search", %{"search" => query}, socket) do
products = Catalog.search_products(query)
{:noreply, stream(socket, :products, products, reset: true)}
end
@impl true
def handle_info({MyAppWeb.ProductLive.Index, [:product_updated]}, socket) do
{:noreply, stream(socket, :products, Catalog.list_products(), reset: true)}
end
end
# lib/my_app_web/live/product_live/index.html.heex
<div class="product-list">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Products</h1>
<.link navigate={~p"/products/new"}>
<.button>New Product</.button>
</.link>
</div>
<.form for={@form} phx-change="search" class="mb-4">
<.input field={@form[:search]} placeholder="Search products..." />
</.form>
<.table
id="products"
rows={@streams.products}
row_click={fn {_id, product} -> navigate(~p"/products/#{product}")}
>
<:col :let={{_id, product}} label="Name"><%= product.name %></:col>
<:col :let={{_id, product}} label="Price"><%= product.price %></:col>
<:col :let={{_id, product}} label="Stock"><%= product.stock_count %></:col>
<:action :let={{_id, product}}>
<.link navigate={~p"/products/#{product}/edit"}>Edit</.link>
</:action>
</.table>
</div>
Step 6: OTP Supervision Tree
# lib/my_app/application.ex
defmodule MyApp.Application do
@moduledoc false
use Application
@impl true
def start(_type, _args) do
children = [
# Start Ecto repo
MyApp.Repo,
# Start Telemetry
{Phoenix.PubSub, name: MyApp.PubSub},
# Start Phoenix endpoint
MyAppWeb.Endpoint,
# Start workers
MyApp.Workers.ProductCache,
MyApp.Workers.EmailQueue,
MyApp.Workers.SessionCleaner,
# Start Oban for background jobs
{Oban, oban_config()},
]
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
defp oban_config do
Oban.Config.new(
repo: MyApp.Repo,
queues: [default: 10, emails: 5, cleanup: 1],
prune: :active,
)
end
end
# lib/my_app/workers/product_cache.ex
defmodule MyApp.Workers.ProductCache do
use GenServer
@cache_ttL :timer.minutes(5)
def start_link(_opts) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
@impl true
def init(state) do
# Schedule initial cache refresh
send(self(), :refresh)
{:ok, state}
end
@impl true
def handle_info(:refresh, state) do
products = MyApp.Catalog.list_products()
:ets.new(:product_cache, [:named_table, :public, read_concurrency: true])
:ets.insert(:product_cache, {:products, products})
Process.send_after(self(), :refresh, @cache_ttL)
{:noreply, state}
end
def get_products do
case :ets.lookup(:product_cache, :products) do
[{:products, products}] -> products
[] -> MyApp.Catalog.list_products()
end
end
end
Rules
- Contexts (Accounts, Catalog, Store) contain all business logic for a domain. Cross-context calls go through public API functions.
- Ecto schemas map to database tables. Changesets handle all validation and casting.
- LiveViews hold state in socket assigns. Phoenix.Component for reusable markup.
- Supervision tree with :one_for_one strategy for workers, :rest_for_one for dependencies.
- GenServer for stateful processes, Agent for simple state, ETS for cache.
- Phoenix.PubSub for cross-process broadcasts (LiveView updates, notifications).
- Mix tasks for one-off jobs. Oban for scheduled/recurring background work.
- Config per environment with runtime.exs for production secrets.
References
- references/elixir-advanced.md — Elixir Advanced Topics
- references/elixir-ecto-advanced.md — Elixir Ecto Advanced Patterns
- references/elixir-fundamentals.md — Elixir Fundamentals
- references/elixir-otp.md — Elixir & OTP
- references/elixir-testing-exunit.md — Elixir Testing with ExUnit
- references/phoenix-ecto.md — Phoenix & Ecto
Architecture Decision Trees
GenServer vs Agent vs ETS
State management pattern?
├── Mutable state with side effects → GenServer
│ Confirms that you need state transitions with side effects (sending emails, writing to DB).
│ Use handle_call for synchronous, handle_cast for fire-and-forget.
├── Simple state, no side effects → Agent
│ get/update pattern. No handle_call/cast boilerplate. Good for config, counters.
│ Risk: Agent.update is synchronous — wraps GenServer.call internally.
└── Read-heavy, rarely-changed → ETS
No process bottleneck. ~1µs reads. Good for reference data, session caches.
Risk: data lost on restart. Pair with Ecto for durability.
Supervision Strategy Selection
Failure recovery requirement?
├── Children independent → :one_for_one
├── Dependency chain → :rest_for_one
│ If DB worker dies, restart DB before workers that depend on it.
├── All must be up → :one_for_all
│ If any child dies, restart all. Use for tightly coupled services.
└── Dynamic children → DynamicSupervisor
For GenServers started at runtime (e.g., per-user processes).
Implementation Patterns
Pattern: Phoenix PubSub Broadcast with LiveView Update
# lib/my_app/catalog.ex
defmodule MyApp.Catalog do
alias MyApp.Repo
alias MyApp.Catalog.Product
def create_product(attrs) do
%Product{}
|> Product.changeset(attrs)
|> Repo.insert()
|> broadcast(:product_created)
end
defp broadcast({:ok, product}, event) do
Phoenix.PubSub.broadcast(MyApp.PubSub, "products", {event, product})
{:ok, product}
end
defp broadcast(error, _event), do: error
end
# lib/my_app_web/live/product_live/index.ex (mount)
def mount(_params, _session, socket) do
if connected?(socket), do: Phoenix.PubSub.subscribe(MyApp.PubSub, "products")
{:ok, stream(socket, :products, Catalog.list_products())}
end
def handle_info({:product_created, product}, socket) do
{:noreply, stream_insert(socket, :products, product, at: 0)}
end
Pattern: Ecto Multi for Transactional Operations
def place_order(cart_id, customer_id) do
Ecto.Multi.new()
|> Ecto.Multi.run(:cart, fn _repo, _changes ->
case get_cart(cart_id) do
%Cart{status: :active} = cart -> {:ok, cart}
_ -> {:error, :cart_not_found}
end
end)
|> Ecto.Multi.run(:order, fn repo, %{cart: cart} ->
%Order{customer_id: customer_id, total: cart.total}
|> Order.changeset(%{})
|> repo.insert()
end)
|> Ecto.Multi.run(:line_items, fn repo, %{order: order} ->
items = Enum.map(cart.items, fn item ->
%LineItem{order_id: order.id, product_id: item.product_id, quantity: item.quantity, price: item.price}
end)
repo.insert_all(LineItem, items)
end)
|> Ecto.Multi.run(:clear_cart, fn repo, _changes ->
repo.delete_all(from c in CartItem, where: c.cart_id == ^cart_id)
end)
|> Repo.transaction()
end
Production Considerations
Deployment Pipeline
mix release build → container image → CI tests → staging deploy → smoke tests → prod deploy
- Releases:
MIX_ENV=prod mix releasebundles BEAM + deps + config into self-contained directory. - Database migrations run as separate step BEFORE new release starts. Never auto-migrate on app boot.
- Use
RELEASE_NODEandRELEASE_COOKIEfor distributed Erlang node clustering. - Health checks:
/healthendpoint returns 200 when app is running. DB connectivity checked before startup completes.
Monitoring & Observability
- Telemetry events:
[:phoenix, :endpoint, :stop]for request duration,[:ecto, :query, :total]for DB. - Export: TelemetryMetricsPrometheus for
/metricsendpoint scraped by Prometheus. - Key metrics: request latency (p50/p95/p99), DB query timing, LiveView socket count, process count, memory.
- Logging: use
Logger.metadata()with request_id, user_id, tenant_id for structured logs. - Alert on: process mailbox size > 1000, ETS table growth, Oban queue depth.
Anti-Patterns
| Anti-Pattern | Why It Hurts | Fix |
|---|---|---|
| GenServer as DB replacement | State lost on crash, restart resets. No durability. | Use Ecto for persistent data; GenServer for cache/state only |
IO.inspect debugging in prod |
Compiles into release. Exposes internal state in logs. | Logger.debug(inspect(data)) removed by compiler in :prod |
| Phoenix context cross-calls | Context A calls Context B's Repo directly. Breaks encapsulation. | Always go through public context API functions |
| Large Ecto preloads in LiveView | 10+ preloads per query = slow mounts. | Paginate, lazy-load, or use ETS cache for reference data |
send_after without cancellation |
Timer survives GenServer crash. Fires into dead process. | Store timer ref, cancel on terminate with Process.cancel_timer/1 |
:infinity GenServer timeout |
Blocks caller indefinitely. Cascading failure on stuck call. | Always set explicit timeout (default 5000ms) |
Performance Optimization
- Streams over Enum for large collections:
Stream.map/Stream.filteravoid intermediate list allocation. - ETS read concurrency:
:named_table, :public, read_concurrency: truefor read-heavy workloads. - Ecto query optimization: use
Repo.preloadwithselect: [:id, :name]to fetch only needed columns. - Phoenix LiveView stream diffing:
stream_insert/stream_deleteover full re-render for list performance. IO.iodata_to_binary/1for string concatenation in hot paths — avoids binary copying.:persistent_termfor truly immutable configuration — fastest read on BEAM (~10ns).- Oban job batching: batch similar jobs into single worker execution to reduce DB roundtrips.
- Caching with Cachex:
Cachex.fetch(:products, key, fn -> Catalog.get_product(id) end)with TTL.
Security Considerations
- Phoenix Router:
:put_secure_browser_headersplug enabled by default. Don't disable. - Ecto changesets prevent mass-assignment via
cast/3. Never usecast(attrs, [:all]). - Password hashing:
Argon2(memory-hard) overbcrypt. Never store plaintext or MD5/SHA. - Session tokens stored in signed cookies (default). Use database-backed sessions for sensitive apps.
- CORS: configure
Plug.CORSwith explicit origins. NeverAccess-Control-Allow-Origin: *for auth endpoints. - SQL injection: Ecto's parameterized queries prevent injection. Never use
Ecto.Adapters.SQL.query!with string interpolation. - LiveView: validate user permissions in handle_params/handle_event, not just in assigns.
- Rate limiting: use Hammer or ExRated for per-IP/per-user rate limiting on auth endpoints.
- Dependencies: run
mix hex.auditin CI. Pin versions in mix.lock.