- LIKE wildcard injection: sanitize %, _ in search queries (devices, sites, gaiia) - Jason.decode! → Jason.decode with error handling for untrusted input - inspect() leak: replace with generic error messages, log details server-side - SSRF protection: URL validator blocks private IPs, localhost, non-HTTP schemes - SSRF validation added to HTTP monitoring executor and integration credentials - GraphQL complexity limits: always applied, not just in prod - GraphQL introspection: also check GET query params, not just body - Stripe webhook: explicit nil/empty checks for signature and body - Cookie security: secure flag for session (prod), http_only+secure for remember_me - Honeybadger API key: read from env var with fallback - String.to_integer → Integer.parse with fallback for URL params - String.to_atom → whitelist map for HTTP methods - Gaiia webhook: remove secret_len and expected signature from log - Admin API: add rate limiting pipeline - to_atom_keys: per-key fallback instead of all-or-nothing rescue
52 KiB
Elixir Best Practices Reference
A comprehensive guide covering OTP patterns, Phoenix LiveView, Ecto, testing, performance, code organization, and deployment.
Table of Contents
1. OTP Patterns
GenServer Best Practices
Keep GenServer callbacks thin
The GenServer module should be a thin wrapper. Push business logic into pure functions in separate modules.
# ✅ Good: Thin GenServer, logic in pure module
defmodule MyApp.RateLimiter do
use GenServer
def start_link(opts) do
name = Keyword.fetch!(opts, :name)
config = Keyword.fetch!(opts, :config)
GenServer.start_link(__MODULE__, config, name: name)
end
def check(server, key), do: GenServer.call(server, {:check, key})
@impl true
def init(config) do
{:ok, RateLimiter.State.new(config)}
end
@impl true
def handle_call({:check, key}, _from, state) do
{result, new_state} = RateLimiter.Logic.check(state, key)
{:reply, result, new_state}
end
end
defmodule MyApp.RateLimiter.Logic do
# Pure functions — easy to test without starting a process
def check(state, key) do
now = System.monotonic_time(:millisecond)
# ... pure logic returning {result, new_state}
end
end
# ❌ Bad: Fat GenServer with all logic inline
defmodule MyApp.RateLimiter do
use GenServer
@impl true
def handle_call({:check, key}, _from, state) do
now = System.monotonic_time(:millisecond)
bucket = Map.get(state.buckets, key, %{tokens: state.max, last: now})
elapsed = now - bucket.last
refilled = min(bucket.tokens + elapsed * state.rate, state.max)
# ... 40 more lines of logic mixed with GenServer plumbing
end
end
Always define a client API
# ✅ Good: Client API hides GenServer details
defmodule MyApp.Cache do
use GenServer
# --- Client API ---
def get(server \\ __MODULE__, key) do
GenServer.call(server, {:get, key})
end
def put(server \\ __MODULE__, key, value, ttl \\ :infinity) do
GenServer.call(server, {:put, key, value, ttl})
end
# --- Server Callbacks ---
@impl true
def handle_call({:get, key}, _from, state) do
{:reply, Map.get(state, key), state}
end
end
# Callers just do:
MyApp.Cache.get(:user_123)
Use handle_continue for post-init work
defmodule MyApp.DataLoader do
use GenServer
@impl true
def init(config) do
# Don't block the supervisor — return immediately, do heavy work in continue
{:ok, %{config: config, data: nil}, {:continue, :load_data}}
end
@impl true
def handle_continue(:load_data, state) do
data = expensive_load(state.config)
{:noreply, %{state | data: data}}
end
end
Timeouts and :hibernate
# For processes that are mostly idle, hibernate to reclaim memory
@impl true
def handle_call(:get_status, _from, state) do
{:reply, state.status, state, :hibernate}
end
# Use Process.send_after for periodic work instead of :timer
@impl true
def init(state) do
schedule_cleanup()
{:ok, state}
end
@impl true
def handle_info(:cleanup, state) do
new_state = do_cleanup(state)
schedule_cleanup()
{:noreply, new_state}
end
defp schedule_cleanup do
Process.send_after(self(), :cleanup, :timer.minutes(5))
end
Reply later with handle_call + manual reply
@impl true
def handle_call(:expensive_op, from, state) do
# Don't block the GenServer — spawn the work
Task.start(fn ->
result = do_expensive_thing()
GenServer.reply(from, result)
end)
{:noreply, state}
end
Supervisor Trees
Use granular supervision strategies
defmodule MyApp.Application do
use Application
@impl true
def start(_type, _args) do
children = [
# Infrastructure first
MyApp.Repo,
{Phoenix.PubSub, name: MyApp.PubSub},
# Application-specific supervisors
MyApp.WorkerSupervisor,
MyApp.SchedulerSupervisor,
# Web layer last (depends on everything above)
MyAppWeb.Endpoint
]
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
end
Choose the right strategy
# :one_for_one — children are independent
# Most common. Use when children don't depend on each other.
Supervisor.start_link(children, strategy: :one_for_one)
# :one_for_all — if one dies, restart all
# Use when children are tightly coupled (e.g., producer + consumer).
Supervisor.start_link(children, strategy: :one_for_all)
# :rest_for_one — if one dies, restart it and all children started after it
# Use when later children depend on earlier ones.
Supervisor.start_link([
MyApp.Database, # if this crashes...
MyApp.Cache, # ...restart this
MyApp.API # ...and this
], strategy: :rest_for_one)
DynamicSupervisor for runtime-spawned children
defmodule MyApp.SessionSupervisor do
use DynamicSupervisor
def start_link(init_arg) do
DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
end
@impl true
def init(_init_arg) do
DynamicSupervisor.init(strategy: :one_for_one, max_children: 1000)
end
def start_session(session_id, opts) do
spec = {MyApp.Session, [id: session_id] ++ opts}
DynamicSupervisor.start_child(__MODULE__, spec)
end
def stop_session(pid) do
DynamicSupervisor.terminate_child(__MODULE__, pid)
end
end
When to Use Agent vs GenServer vs Task
| Use Case | Choice | Why |
|---|---|---|
| Simple state wrapper | Agent |
No custom messages needed |
| State + complex message handling | GenServer |
Need handle_info, timeouts, multi-step logic |
| Fire-and-forget work | Task.start |
Don't need result |
| Async work, need result | Task.async + Task.await |
Short-lived, supervised via caller |
| Background work under supervisor | Task.Supervisor.start_child |
Crash isolation, can be async_nolink |
| Long-running background job | GenServer |
Needs lifecycle management, restart |
| Pool of concurrent workers | Task.Supervisor |
Bounded concurrency with max_children |
# Agent: dead simple state. Use sparingly — often a GenServer is clearer.
{:ok, counter} = Agent.start_link(fn -> 0 end, name: MyApp.Counter)
Agent.update(MyApp.Counter, &(&1 + 1))
Agent.get(MyApp.Counter, & &1) #=> 1
# Task: fire-and-forget
Task.start(fn -> send_welcome_email(user) end)
# Task: async/await (short-lived, caller-linked)
task = Task.async(fn -> fetch_external_data(url) end)
result = Task.await(task, 5_000)
# Task.Supervisor: supervised tasks
{:ok, result} = Task.Supervisor.async_nolink(MyApp.TaskSup, fn ->
risky_external_call()
end) |> Task.yield(10_000) || {:error, :timeout}
Anti-pattern: Using Agent for complex state management
# ❌ Bad: Agent doing too much
Agent.update(pid, fn state ->
# Complex multi-step logic that should be in a GenServer
state
|> validate_transition()
|> apply_side_effects() # Side effects inside Agent.update = bad
|> update_counters()
end)
# ✅ Good: Use GenServer when logic is non-trivial
Process Linking vs Monitoring
# Link: bidirectional. If either process crashes, the other gets an EXIT signal.
# Use for: tightly coupled processes that should die together.
Process.link(pid)
# Monitor: unidirectional. Observer gets a :DOWN message, monitored process is unaffected.
# Use for: watching a process you don't own / shouldn't crash with.
ref = Process.monitor(pid)
receive do
{:DOWN, ^ref, :process, ^pid, reason} ->
Logger.warning("Process #{inspect(pid)} went down: #{inspect(reason)}")
end
# In GenServer, handle :DOWN in handle_info:
@impl true
def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do
{:noreply, remove_worker(state, pid)}
end
Rule of thumb:
- Link = "we live and die together" (parent-child, co-dependent)
- Monitor = "I want to know when you die" (observer pattern)
Registry Patterns
# Start registry in supervision tree
children = [
{Registry, keys: :unique, name: MyApp.Registry},
# ...
]
# Register a GenServer via name
defmodule MyApp.Session do
use GenServer
def start_link(session_id) do
GenServer.start_link(__MODULE__, session_id,
name: via_tuple(session_id)
)
end
def get_state(session_id) do
GenServer.call(via_tuple(session_id), :get_state)
end
defp via_tuple(session_id) do
{:via, Registry, {MyApp.Registry, {:session, session_id}}}
end
end
# Lookup
case Registry.lookup(MyApp.Registry, {:session, "abc123"}) do
[{pid, _value}] -> {:ok, pid}
[] -> {:error, :not_found}
end
# Dispatch to all registered processes (use :duplicate keys)
{Registry, keys: :duplicate, name: MyApp.PubSubRegistry}
Registry.dispatch(MyApp.PubSubRegistry, :event_type, fn entries ->
for {pid, _} <- entries, do: send(pid, {:event, payload})
end)
Partitioned Registry for high concurrency
{Registry, keys: :unique, name: MyApp.Registry, partitions: System.schedulers_online()}
2. Phoenix LiveView
Assigns Optimization
temporary_assigns — free memory after render
# ✅ Good: Large lists that only need to render once
def mount(_params, _session, socket) do
{:ok,
assign(socket, :messages, list_messages()),
temporary_assigns: [messages: []]}
end
# After the first render, socket.assigns.messages is reset to []
# The client retains the DOM. New messages are appended via handle_event/handle_info.
Streams — the modern replacement for temporary_assigns
Streams (LiveView 0.19+) are the preferred way to handle large collections. They track items by ID and send only diffs.
def mount(_params, _session, socket) do
{:ok,
socket
|> stream(:messages, list_messages())
|> assign(:page, 1)}
end
# Adding items
def handle_info({:new_message, message}, socket) do
{:noreply, stream_insert(socket, :messages, message)}
end
# Removing items
def handle_event("delete", %{"id" => id}, socket) do
message = Messages.get!(id)
Messages.delete(message)
{:noreply, stream_delete(socket, :messages, message)}
end
# Bulk reset
def handle_event("refresh", _, socket) do
{:noreply, stream(socket, :messages, list_messages(), reset: true)}
end
In the template:
<div id="messages" phx-update="stream">
<div :for={{dom_id, message} <- @streams.messages} id={dom_id}>
<p><%= message.body %></p>
</div>
</div>
Anti-pattern: Storing huge lists in assigns without streaming
# ❌ Bad: 10,000 items sitting in process memory permanently
def mount(_, _, socket) do
{:ok, assign(socket, :items, Repo.all(Item))} # All in memory, all diffed every render
end
# ✅ Good: Use streams
def mount(_, _, socket) do
{:ok, stream(socket, :items, Repo.all(Item))}
end
handle_params vs mount
# mount/3 — called ONCE when the LiveView process starts.
# Use for: one-time setup, subscriptions, session-derived state.
# handle_params/3 — called on EVERY URL change (including initial mount).
# Use for: loading data based on URL params, pagination, filtering.
defmodule MyAppWeb.ProductLive.Index do
use MyAppWeb, :live_view
# Called once
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "products")
end
{:ok, socket}
end
# Called on every navigation (including initial)
@impl true
def handle_params(params, _uri, socket) do
page = String.to_integer(params["page"] || "1")
category = params["category"]
{:noreply,
socket
|> assign(:page, page)
|> assign(:category, category)
|> stream(:products, list_products(page, category), reset: true)}
end
end
Anti-pattern: Loading URL-dependent data in mount
# ❌ Bad: Data loaded in mount won't refresh on live_patch navigation
def mount(%{"id" => id}, _session, socket) do
product = Products.get!(id)
{:ok, assign(socket, :product, product)}
end
# The user live_patches to /products/456 — mount is NOT called again,
# so the old product stays. Use handle_params instead.
live_session Boundaries
# live_session groups LiveViews that share authentication/layout.
# Navigating between different live_sessions triggers a full page load (dead render + reconnect).
# Within the same live_session, live_patch/live_redirect keeps the WebSocket alive.
scope "/", MyAppWeb do
pipe_through [:browser]
live_session :public,
on_mount: [{MyAppWeb.InitAssigns, :default}] do
live "/", HomeLive.Index
live "/about", AboutLive
end
live_session :authenticated,
on_mount: [{MyAppWeb.UserAuth, :ensure_authenticated}] do
live "/dashboard", DashboardLive
live "/settings", SettingsLive
live "/projects/:id", ProjectLive.Show
end
live_session :admin,
on_mount: [{MyAppWeb.UserAuth, :ensure_admin}] do
live "/admin", AdminLive.Index
live "/admin/users", AdminLive.Users
end
end
# on_mount hook for authentication
defmodule MyAppWeb.UserAuth do
import Phoenix.LiveView
import Phoenix.Component
def on_mount(:ensure_authenticated, _params, %{"user_token" => token}, socket) do
case Accounts.get_user_by_session_token(token) do
nil -> {:halt, redirect(socket, to: "/login")}
user -> {:cont, assign(socket, :current_user, user)}
end
end
def on_mount(:ensure_authenticated, _params, _session, socket) do
{:halt, redirect(socket, to: "/login")}
end
end
JS Hooks + Server Coordination
# In the template
<div id="chart" phx-hook="Chart" data-points={Jason.encode!(@points)}></div>
# phx-hook value must match the hook name in app.js
// assets/js/hooks/chart.js
const Chart = {
mounted() {
this.chart = new ChartLib(this.el, {
data: JSON.parse(this.el.dataset.points)
});
// Receive events from server
this.handleEvent("update-chart", ({ points }) => {
this.chart.update(points);
});
// Send events to server
this.el.addEventListener("click", (e) => {
this.pushEvent("chart-clicked", { x: e.offsetX, y: e.offsetY });
});
},
updated() {
// Called when the element's attributes change from server
const points = JSON.parse(this.el.dataset.points);
this.chart.update(points);
},
destroyed() {
this.chart.destroy();
}
};
export default Chart;
// assets/js/app.js
import Chart from "./hooks/chart";
import InfiniteScroll from "./hooks/infinite_scroll";
let Hooks = { Chart, InfiniteScroll };
let liveSocket = new LiveSocket("/live", Socket, {
params: { _csrf_token: csrfToken },
hooks: Hooks
});
# Server-side: push events to the hook
def handle_info({:data_updated, points}, socket) do
{:noreply, push_event(socket, "update-chart", %{points: points})}
end
# Handle events from the hook
def handle_event("chart-clicked", %{"x" => x, "y" => y}, socket) do
# Process click coordinates
{:noreply, socket}
end
phx-update Strategies
<%!-- Default: replace entire container contents --%>
<div id="notifications">
<%= for n <- @notifications do %>
<div id={"notification-#{n.id}"}><%= n.text %></div>
<% end %>
</div>
<%!-- Stream: managed by stream_insert/stream_delete --%>
<div id="messages" phx-update="stream">
<div :for={{dom_id, msg} <- @streams.messages} id={dom_id}>
<%= msg.body %>
</div>
</div>
<%!-- Append: new children appended, existing preserved (legacy, prefer streams) --%>
<div id="log" phx-update="append">
<%= for entry <- @log_entries do %>
<div id={"log-#{entry.id}"}><%= entry.text %></div>
<% end %>
</div>
<%!-- Ignore: server never updates this container (for JS-managed DOM) --%>
<div id="js-widget" phx-update="ignore">
<div>JS manages this subtree</div>
</div>
Dead Views vs Live Views
# Dead view: traditional request/response. Use for:
# - Static pages (about, terms, privacy)
# - SEO-critical content that doesn't need interactivity
# - Simple forms that don't need real-time validation
# In router:
get "/terms", PageController, :terms
# Live view: persistent WebSocket. Use for:
# - Real-time updates (dashboards, notifications)
# - Complex interactive forms
# - Collaborative features
# - Any UI that benefits from server push
# Rule of thumb: if it doesn't need a WebSocket, a dead view is simpler and cheaper.
3. Ecto Best Practices
Composable Queries
defmodule MyApp.Devices.Query do
import Ecto.Query
def base, do: from(d in MyApp.Devices.Device, as: :device)
def by_site(query, site_id) do
where(query, [device: d], d.site_id == ^site_id)
end
def by_status(query, status) do
where(query, [device: d], d.status == ^status)
end
def with_interfaces(query) do
preload(query, [:interfaces])
end
def search(query, nil), do: query
def search(query, ""), do: query
def search(query, term) do
term = "%#{term}%"
where(query, [device: d], ilike(d.hostname, ^term) or ilike(d.ip_address, ^term))
end
def paginate(query, page, per_page \\ 25) do
offset = (page - 1) * per_page
query
|> limit(^per_page)
|> offset(^offset)
end
def order_by_hostname(query) do
order_by(query, [device: d], asc: d.hostname)
end
end
# Usage — compose like pipes:
import MyApp.Devices.Query
base()
|> by_site(site_id)
|> by_status(:active)
|> search(params["q"])
|> order_by_hostname()
|> paginate(page)
|> with_interfaces()
|> Repo.all()
Multi-Tenancy Patterns
Option A: Foreign Key (simpler, recommended for most apps)
# Every query scopes by tenant_id
defmodule MyApp.Devices do
def list_devices(%Tenant{id: tenant_id}) do
Device
|> where(tenant_id: ^tenant_id)
|> Repo.all()
end
end
# Enforce at the changeset level too
def changeset(device, attrs, tenant) do
device
|> cast(attrs, [:hostname, :ip_address])
|> put_assoc(:tenant, tenant)
|> unique_constraint([:hostname, :tenant_id])
end
Option B: Postgres Schema Prefix (stronger isolation)
# Each tenant gets a separate Postgres schema
defmodule MyApp.Repo do
def put_tenant_prefix(tenant_slug) do
# Call this at the start of each request
prefix = "tenant_#{tenant_slug}"
put_dynamic_repo({__MODULE__, prefix})
prefix
end
end
# In queries
def list_devices(tenant_prefix) do
Device
|> Repo.all(prefix: tenant_prefix)
end
# Migrations must run per-tenant
def migrate_tenant(tenant_prefix) do
Ecto.Migrator.run(Repo, :up, all: true, prefix: tenant_prefix)
end
Trade-offs:
- Foreign key: simpler ops, cross-tenant queries easy, must remember to scope every query
- Prefix: stronger isolation, harder cross-tenant queries, migration complexity
Changesets
Embedded schemas for complex nested data
defmodule MyApp.Devices.SNMPConfig do
use Ecto.Schema
import Ecto.Changeset
# Not a database table — embedded in a JSONB column
embedded_schema do
field :community, :string
field :version, Ecto.Enum, values: [:v1, :v2c, :v3]
field :port, :integer, default: 161
field :timeout_ms, :integer, default: 5000
end
def changeset(config, attrs) do
config
|> cast(attrs, [:community, :version, :port, :timeout_ms])
|> validate_required([:community, :version])
|> validate_number(:port, greater_than: 0, less_than: 65536)
|> validate_number(:timeout_ms, greater_than: 0)
end
end
defmodule MyApp.Devices.Device do
use Ecto.Schema
import Ecto.Changeset
schema "devices" do
field :hostname, :string
embeds_one :snmp_config, MyApp.Devices.SNMPConfig, on_replace: :update
timestamps()
end
def changeset(device, attrs) do
device
|> cast(attrs, [:hostname])
|> cast_embed(:snmp_config, required: true)
|> validate_required([:hostname])
end
end
cast_assoc vs put_assoc
# cast_assoc: user-facing forms where you cast nested params
# Expects params like %{"addresses" => [%{"street" => "123 Main"}]}
def changeset(user, attrs) do
user
|> cast(attrs, [:name])
|> cast_assoc(:addresses, with: &Address.changeset/2)
end
# put_assoc: programmatic. You already have the struct/changeset.
def add_address(user, address) do
user
|> change()
|> put_assoc(:addresses, [address | user.addresses])
end
Repo.transaction Patterns
Multi for composable transactions
alias Ecto.Multi
def create_team_with_owner(team_attrs, user) do
Multi.new()
|> Multi.insert(:team, Team.changeset(%Team{}, team_attrs))
|> Multi.insert(:membership, fn %{team: team} ->
Membership.changeset(%Membership{}, %{
team_id: team.id,
user_id: user.id,
role: :owner
})
end)
|> Multi.run(:audit, fn repo, %{team: team} ->
repo.insert(AuditLog.entry(:team_created, team, user))
end)
|> Repo.transaction()
|> case do
{:ok, %{team: team, membership: membership}} ->
{:ok, team}
{:error, :team, changeset, _changes} ->
{:error, changeset}
{:error, :membership, changeset, _changes} ->
{:error, changeset}
end
end
Manual transactions with early returns
Repo.transaction(fn ->
with {:ok, user} <- create_user(attrs),
{:ok, _profile} <- create_profile(user, profile_attrs),
:ok <- send_welcome_email(user) do
user
else
{:error, reason} -> Repo.rollback(reason)
end
end)
Preload Strategies (Avoid N+1)
# ❌ Bad: N+1 queries
devices = Repo.all(Device)
Enum.map(devices, fn d ->
interfaces = Repo.all(from i in Interface, where: i.device_id == ^d.id)
%{d | interfaces: interfaces}
end)
# ✅ Good: Preload in the query (single JOIN)
Repo.all(
from d in Device,
join: i in assoc(d, :interfaces),
preload: [interfaces: i]
)
# ✅ Good: Separate preload query (two queries total, often faster than JOIN for large datasets)
Device
|> Repo.all()
|> Repo.preload(:interfaces)
# ✅ Good: Nested preloads
Repo.all(
from d in Device,
preload: [interfaces: :ip_addresses, site: :organization]
)
# ✅ Good: Custom preload query
active_interfaces_query = from i in Interface, where: i.status == :up
Repo.all(
from d in Device,
preload: [interfaces: ^active_interfaces_query]
)
Upserts
# Insert or update on conflict
def upsert_device(attrs) do
%Device{}
|> Device.changeset(attrs)
|> Repo.insert(
on_conflict: {:replace, [:hostname, :ip_address, :updated_at]},
conflict_target: :serial_number,
returning: true
)
end
# Bulk upsert
def upsert_metrics(metrics) do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
entries = Enum.map(metrics, fn m ->
%{
device_id: m.device_id,
metric_name: m.name,
value: m.value,
inserted_at: now,
updated_at: now
}
end)
Repo.insert_all(Metric, entries,
on_conflict: {:replace, [:value, :updated_at]},
conflict_target: [:device_id, :metric_name],
returning: true
)
end
Advisory Locks
# Use PostgreSQL advisory locks for distributed locking
defmodule MyApp.Lock do
@doc """
Runs `fun` only if the advisory lock is acquired.
Lock is automatically released when the transaction commits.
"""
def with_lock(lock_key, fun) when is_integer(lock_key) do
Repo.transaction(fn ->
case Repo.query("SELECT pg_try_advisory_xact_lock($1)", [lock_key]) do
{:ok, %{rows: [[true]]}} ->
fun.()
{:ok, %{rows: [[false]]}} ->
Repo.rollback(:lock_not_acquired)
end
end)
end
# Helper to convert string keys to integers
def key(name) when is_binary(name) do
:erlang.phash2(name)
end
end
# Usage
case MyApp.Lock.with_lock(Lock.key("sync:site:#{site_id}"), fn ->
perform_sync(site_id)
end) do
{:ok, result} -> {:ok, result}
{:error, :lock_not_acquired} -> {:ok, :already_running}
end
4. Testing
ExUnit Best Practices
Describe blocks and clear naming
defmodule MyApp.DevicesTest do
use MyApp.DataCase, async: true
alias MyApp.Devices
describe "create_device/1" do
test "creates device with valid attributes" do
attrs = %{hostname: "switch-01", ip_address: "10.0.0.1"}
assert {:ok, device} = Devices.create_device(attrs)
assert device.hostname == "switch-01"
end
test "returns error changeset with invalid attributes" do
assert {:error, %Ecto.Changeset{}} = Devices.create_device(%{})
end
test "enforces unique hostname per site" do
site = insert_site()
attrs = %{hostname: "switch-01", site_id: site.id}
{:ok, _} = Devices.create_device(attrs)
assert {:error, changeset} = Devices.create_device(attrs)
assert "has already been taken" in errors_on(changeset).hostname
end
end
end
Setup blocks
describe "device operations" do
setup do
site = insert_site()
device = insert_device(site: site)
%{site: site, device: device}
end
test "updates device hostname", %{device: device} do
assert {:ok, updated} = Devices.update_device(device, %{hostname: "new-name"})
assert updated.hostname == "new-name"
end
end
# Named setup for reuse
setup :create_user
defp create_user(_context) do
%{user: insert_user()}
end
Property-Based Testing with StreamData
defmodule MyApp.ParserTest do
use ExUnit.Case, async: true
use ExUnitProperties
property "parsing and formatting are inverse operations" do
check all ip <- ip_address_generator() do
assert ip == ip |> MyApp.IP.format() |> MyApp.IP.parse!()
end
end
property "all valid SNMP communities pass validation" do
check all community <- string(:alphanumeric, min_length: 1, max_length: 32) do
changeset = SNMPConfig.changeset(%SNMPConfig{}, %{
community: community,
version: :v2c
})
assert changeset.valid?
end
end
property "rate limiter never allows more than max requests" do
check all requests <- list_of(constant(:request), min_length: 1, max_length: 100),
max <- integer(1..10) do
state = RateLimiter.Logic.new(max_requests: max, window_ms: 1000)
{allowed, _state} =
Enum.reduce(requests, {0, state}, fn _, {count, s} ->
case RateLimiter.Logic.check(s, "key") do
{:ok, new_s} -> {count + 1, new_s}
{:error, new_s} -> {count, new_s}
end
end)
assert allowed <= max
end
end
# Custom generators
defp ip_address_generator do
gen all a <- integer(0..255),
b <- integer(0..255),
c <- integer(0..255),
d <- integer(0..255) do
{a, b, c, d}
end
end
end
Mox for Behaviours
# 1. Define a behaviour
defmodule MyApp.SNMP.Client do
@callback get(String.t(), String.t(), keyword()) :: {:ok, term()} | {:error, term()}
@callback walk(String.t(), String.t(), keyword()) :: {:ok, [term()]} | {:error, term()}
end
# 2. Real implementation
defmodule MyApp.SNMP.NetSNMP do
@behaviour MyApp.SNMP.Client
@impl true
def get(host, oid, opts \\ []) do
# Real SNMP call
end
end
# 3. Define mock in test/support/mocks.ex
Mox.defmock(MyApp.SNMP.MockClient, for: MyApp.SNMP.Client)
# 4. Configure in config/test.exs
config :my_app, :snmp_client, MyApp.SNMP.MockClient
# 5. Use in application code
defmodule MyApp.Poller do
@snmp_client Application.compile_env(:my_app, :snmp_client)
def poll_device(device) do
@snmp_client.get(device.ip_address, @sys_descr_oid)
end
end
# 6. Test with expectations
defmodule MyApp.PollerTest do
use MyApp.DataCase, async: true
import Mox
setup :verify_on_exit!
test "polls device and stores result" do
device = insert_device(ip_address: "10.0.0.1")
expect(MyApp.SNMP.MockClient, :get, fn "10.0.0.1", _oid, _opts ->
{:ok, "Cisco IOS"}
end)
assert {:ok, result} = MyApp.Poller.poll_device(device)
assert result.sys_descr == "Cisco IOS"
end
test "handles SNMP timeout" do
device = insert_device()
expect(MyApp.SNMP.MockClient, :get, fn _, _, _ ->
{:error, :timeout}
end)
assert {:error, :timeout} = MyApp.Poller.poll_device(device)
end
end
Async Test Isolation
# DataCase uses Ecto SQL Sandbox for async isolation
defmodule MyApp.DataCase do
use ExUnit.CaseTemplate
using do
quote do
alias MyApp.Repo
import Ecto
import Ecto.Changeset
import Ecto.Query
import MyApp.DataCase
end
end
setup tags do
MyApp.DataCase.setup_sandbox(tags)
:ok
end
def setup_sandbox(tags) do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(MyApp.Repo,
shared: !tags[:async]
)
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
end
end
# Mark test modules as async when they don't share state
defmodule MyApp.AccountsTest do
use MyApp.DataCase, async: true # Runs in parallel
end
Factory Patterns (Without ExMachina)
# Simple factory module — no library needed
defmodule MyApp.Factory do
alias MyApp.Repo
def build(:user) do
%MyApp.Accounts.User{
email: "user-#{System.unique_integer([:positive])}@example.com",
name: "Test User",
hashed_password: Bcrypt.hash_pwd_salt("password123")
}
end
def build(:device) do
%MyApp.Devices.Device{
hostname: "device-#{System.unique_integer([:positive])}",
ip_address: "10.0.0.#{:rand.uniform(254)}",
status: :active
}
end
def build(:site) do
%MyApp.Sites.Site{
name: "Site #{System.unique_integer([:positive])}",
slug: "site-#{System.unique_integer([:positive])}"
}
end
# Override fields
def build(factory, attrs) do
factory
|> build()
|> struct!(attrs)
end
# Insert into DB
def insert!(factory, attrs \\ []) do
factory
|> build(attrs)
|> Repo.insert!()
end
# Convenience helpers for tests
def insert_user(attrs \\ []), do: insert!(:user, attrs)
def insert_device(attrs \\ []), do: insert!(:device, attrs)
def insert_site(attrs \\ []), do: insert!(:site, attrs)
end
# Import in DataCase:
# import MyApp.Factory
# Usage in tests:
test "deactivates device" do
device = insert_device(status: :active)
assert {:ok, updated} = Devices.deactivate(device)
assert updated.status == :inactive
end
Trait-like patterns
def build(:device, :with_interfaces) do
device = build(:device)
interfaces = for i <- 1..4, do: build(:interface, device_id: device.id, index: i)
%{device | interfaces: interfaces}
end
def insert_device_with_interfaces(attrs \\ []) do
device = insert_device(attrs)
for i <- 1..4 do
insert!(:interface, device_id: device.id, index: i)
end
Repo.preload(device, :interfaces)
end
ConnCase vs DataCase
# DataCase: tests that need the database but NOT HTTP/LiveView
# - Context module tests (Accounts.create_user/1)
# - Business logic tests
# - Query tests
# ConnCase: tests that need HTTP connection or LiveView
# - Controller tests
# - LiveView tests
# - Plug tests
# - API endpoint tests
defmodule MyAppWeb.DeviceLiveTest do
use MyAppWeb.ConnCase, async: true
import Phoenix.LiveViewTest
test "lists devices", %{conn: conn} do
device = insert_device(hostname: "switch-01")
{:ok, view, html} = live(conn, ~p"/devices")
assert html =~ "switch-01"
end
test "creates device via form", %{conn: conn} do
{:ok, view, _} = live(conn, ~p"/devices/new")
view
|> form("#device-form", device: %{hostname: "new-switch"})
|> render_submit()
assert_redirect(view, ~p"/devices")
end
end
5. Performance
ETS for Caching
defmodule MyApp.Cache do
use GenServer
@table __MODULE__
@default_ttl :timer.minutes(5)
def start_link(_opts) do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
@impl true
def init(_) do
table = :ets.new(@table, [
:set,
:public, # Any process can read (fast, no bottleneck)
:named_table,
read_concurrency: true
])
schedule_cleanup()
{:ok, %{table: table}}
end
# Client API — direct ETS access, no GenServer bottleneck for reads
def get(key) do
case :ets.lookup(@table, key) do
[{^key, value, expires_at}] ->
if System.monotonic_time(:millisecond) < expires_at do
{:ok, value}
else
:ets.delete(@table, key)
:miss
end
[] ->
:miss
end
end
def put(key, value, ttl \\ @default_ttl) do
expires_at = System.monotonic_time(:millisecond) + ttl
:ets.insert(@table, {key, value, expires_at})
:ok
end
def delete(key), do: :ets.delete(@table, key)
# Fetch-or-compute pattern
def fetch(key, ttl \\ @default_ttl, fun) do
case get(key) do
{:ok, value} -> value
:miss ->
value = fun.()
put(key, value, ttl)
value
end
end
# Periodic cleanup of expired entries
@impl true
def handle_info(:cleanup, state) do
now = System.monotonic_time(:millisecond)
:ets.foldl(fn {key, _value, expires_at}, acc ->
if now >= expires_at, do: :ets.delete(@table, key)
acc
end, nil, @table)
schedule_cleanup()
{:noreply, state}
end
defp schedule_cleanup, do: Process.send_after(self(), :cleanup, :timer.minutes(1))
end
# Usage
MyApp.Cache.fetch("device:#{device_id}:interfaces", :timer.minutes(10), fn ->
Repo.preload(device, :interfaces).interfaces
end)
:persistent_term for Config
# persistent_term is optimized for reads (zero-copy), expensive for writes.
# Perfect for: config, feature flags, compiled schemas.
defmodule MyApp.Config do
def load! do
# Called once at startup or on config reload
:persistent_term.put({__MODULE__, :features}, %{
new_dashboard: true,
beta_api: false
})
:persistent_term.put({__MODULE__, :limits}, %{
max_devices_per_site: 10_000,
poll_interval_ms: 30_000
})
end
# Lightning-fast reads — no message passing, no copying
def feature_enabled?(flag) do
features = :persistent_term.get({__MODULE__, :features})
Map.get(features, flag, false)
end
def get_limit(key) do
limits = :persistent_term.get({__MODULE__, :limits})
Map.fetch!(limits, key)
end
end
# ❌ Don't: Write to persistent_term frequently
# Every write triggers a global GC of all copies. Use ETS for frequently changing data.
Telemetry Instrumentation
# Emit telemetry events from your application
defmodule MyApp.Devices do
def poll_device(device) do
start_time = System.monotonic_time()
result = do_poll(device)
:telemetry.execute(
[:my_app, :device, :poll],
%{duration: System.monotonic_time() - start_time},
%{device_id: device.id, site_id: device.site_id, result: elem(result, 0)}
)
result
end
# Or use :telemetry.span/3 for automatic start/stop/exception events
def sync_site(site) do
:telemetry.span([:my_app, :site, :sync], %{site_id: site.id}, fn ->
result = do_sync(site)
{result, %{device_count: length(result.devices)}}
end)
end
end
# Attach handlers in application startup
defmodule MyApp.Telemetry do
def setup do
:telemetry.attach_many("my-app-metrics", [
[:my_app, :device, :poll],
[:my_app, :site, :sync, :stop],
[:my_app, :site, :sync, :exception],
[:phoenix, :endpoint, :stop],
[:my_app, :repo, :query]
], &handle_event/4, nil)
end
def handle_event([:my_app, :device, :poll], measurements, metadata, _config) do
duration_ms = System.convert_time_unit(measurements.duration, :native, :millisecond)
if duration_ms > 5_000 do
Logger.warning("Slow poll for device #{metadata.device_id}: #{duration_ms}ms")
end
# Push to metrics system (Prometheus, StatsD, etc.)
:telemetry.execute([:prometheus, :histogram, :observe], %{
name: "device_poll_duration_ms",
value: duration_ms,
labels: %{result: metadata.result}
})
end
end
GenStage / Broadway for Backpressure
# Broadway: higher-level, batteries-included pipeline
defmodule MyApp.SNMPPollPipeline do
use Broadway
def start_link(_opts) do
Broadway.start_link(__MODULE__,
name: __MODULE__,
producer: [
module: {MyApp.PollProducer, []},
transformer: {__MODULE__, :transform, []},
concurrency: 1
],
processors: [
default: [concurrency: 50, max_demand: 10]
],
batchers: [
default: [batch_size: 100, batch_timeout: 1_000, concurrency: 5]
]
)
end
def transform(event, _opts) do
%Broadway.Message{
data: event,
acknowledger: {__MODULE__, :ack_id, :ack_data}
}
end
@impl true
def handle_message(_, %Broadway.Message{data: device} = message, _) do
case poll_device(device) do
{:ok, result} ->
Broadway.Message.put_data(message, result)
{:error, reason} ->
Broadway.Message.failed(message, reason)
end
end
@impl true
def handle_batch(_, messages, _, _) do
# Bulk insert results
results = Enum.map(messages, & &1.data)
MyApp.Metrics.bulk_insert(results)
messages
end
@impl true
def handle_failed(messages, _context) do
Enum.each(messages, fn msg ->
Logger.error("Poll failed: #{inspect(msg.data)} — #{inspect(msg.status)}")
end)
messages
end
end
Connection Pooling with Finch
# In application supervisor
children = [
{Finch, name: MyApp.Finch,
pools: %{
"https://api.example.com" => [size: 25, count: 2],
:default => [size: 10]
}
}
]
# Usage
def get_device_info(device_id) do
Finch.build(:get, "https://api.example.com/devices/#{device_id}",
[{"authorization", "Bearer #{token()}"}]
)
|> Finch.request(MyApp.Finch)
|> case do
{:ok, %{status: 200, body: body}} -> {:ok, Jason.decode!(body)}
{:ok, %{status: status}} -> {:error, {:http_error, status}}
{:error, reason} -> {:error, reason}
end
end
# Streaming large responses
Finch.build(:get, url)
|> Finch.stream(MyApp.Finch, acc, fn
{:status, status}, acc -> %{acc | status: status}
{:headers, headers}, acc -> %{acc | headers: headers}
{:data, chunk}, acc -> %{acc | body: acc.body <> chunk}
end)
Binary Optimization
# Binaries > 64 bytes are reference-counted (shared, not copied).
# Sub-binaries point into the original — beware keeping references to large binaries.
# ❌ Bad: Keeping a small slice alive holds the entire large binary in memory
def extract_header(<<header::binary-size(4), _rest::binary>>) do
header # This is a sub-binary — the entire original stays in memory
end
# ✅ Good: Copy when you need only a small part of a large binary
def extract_header(<<header::binary-size(4), _rest::binary>>) do
:binary.copy(header) # New, independent 4-byte binary
end
# Pattern matching on binaries is very efficient
def parse_tlv(<<type::8, length::16, value::binary-size(length), rest::binary>>) do
{type, value, rest}
end
# Use iodata/iolists to avoid concatenation
# ❌ Bad: Repeated concatenation creates intermediate binaries
def build_response(items) do
Enum.reduce(items, "", fn item, acc ->
acc <> "<li>" <> item.name <> "</li>"
end)
end
# ✅ Good: iolists — zero-copy, sent directly to IO
def build_response(items) do
Enum.map(items, fn item ->
["<li>", item.name, "</li>"]
end)
end
# Pass to IO.iodata_to_binary/1 only if you truly need a flat binary
6. Code Organization
Context Boundaries (DDD-lite)
Phoenix contexts are your primary code organization tool. Think of them as bounded contexts.
lib/my_app/
├── accounts/ # User management
│ ├── accounts.ex # Public API (the context module)
│ ├── user.ex # Schema
│ ├── user_token.ex # Schema
│ └── user_notifier.ex
├── devices/ # Device management
│ ├── devices.ex # Public API
│ ├── device.ex # Schema
│ ├── interface.ex # Schema
│ └── query.ex # Composable queries
├── monitoring/ # Polling & metrics
│ ├── monitoring.ex
│ ├── poller.ex
│ ├── metric.ex
│ └── alert_rule.ex
└── sites/
├── sites.ex
└── site.ex
The context module is the public API
defmodule MyApp.Devices do
@moduledoc """
The Devices context — manages network devices and their interfaces.
"""
alias MyApp.Repo
alias MyApp.Devices.{Device, Interface, Query}
# --- Public API ---
def list_devices(site_id, opts \\ []) do
Query.base()
|> Query.by_site(site_id)
|> Query.maybe_search(opts[:search])
|> Query.paginate(opts[:page] || 1, opts[:per_page] || 25)
|> Repo.all()
end
def get_device!(id), do: Repo.get!(Device, id)
def create_device(attrs) do
%Device{}
|> Device.changeset(attrs)
|> Repo.insert()
end
# Cross-context: accept IDs, not structs from other contexts
def assign_to_site(device_id, site_id) do
device_id
|> get_device!()
|> Device.changeset(%{site_id: site_id})
|> Repo.update()
end
end
Anti-pattern: Reaching into another context's schemas
# ❌ Bad: Controller directly queries another context's schema
def index(conn, params) do
devices = Repo.all(
from d in MyApp.Devices.Device,
join: s in MyApp.Sites.Site, on: d.site_id == s.id,
where: s.organization_id == ^conn.assigns.org_id
)
end
# ✅ Good: Go through context APIs
def index(conn, params) do
devices = MyApp.Devices.list_devices_for_org(conn.assigns.org_id, params)
end
When to Split Contexts
Split when:
- Two areas of the codebase change for different reasons
- You're tempted to build circular dependencies between schemas
- A context module exceeds ~300-400 lines
- Different areas need different data access patterns (e.g., one is read-heavy, another write-heavy)
Don't split when:
- It would force every operation to cross context boundaries
- The schemas are tightly coupled and always change together
- You'd just be creating pass-through functions
Service Modules vs Context Functions
# Keep simple CRUD in the context module:
defmodule MyApp.Devices do
def create_device(attrs), do: ...
def update_device(device, attrs), do: ...
def delete_device(device), do: ...
end
# Extract complex operations into dedicated service modules:
defmodule MyApp.Devices.Discovery do
@moduledoc "Handles SNMP-based device auto-discovery."
def discover(subnet, opts \\ []) do
# 50+ lines of complex discovery logic
end
end
defmodule MyApp.Devices.BulkImport do
@moduledoc "Imports devices from CSV/spreadsheet."
def import_csv(path, site_id) do
# Complex parsing, validation, deduplication
end
end
# The context still exposes them if needed:
defmodule MyApp.Devices do
defdelegate discover(subnet, opts \\ []), to: MyApp.Devices.Discovery
defdelegate import_csv(path, site_id), to: MyApp.Devices.BulkImport
end
Avoiding God Modules
Signs of a god module:
- 500+ lines
- Mix of CRUD, business logic, notifications, reporting
- Functions that don't share state or purpose
# ❌ Bad: God module
defmodule MyApp.Devices do
def create_device(attrs), do: ...
def update_device(device, attrs), do: ...
def poll_device(device), do: ... # Should be in Monitoring
def generate_report(site_id), do: ... # Should be in Reports
def send_alert(device, message), do: ... # Should be in Notifications
def calculate_uptime(device_id), do: ... # Should be in Monitoring/Analytics
def export_to_csv(devices), do: ... # Should be in Exports
# ... 800 more lines
end
# ✅ Good: Split by responsibility
defmodule MyApp.Devices do # CRUD + device-specific logic
defmodule MyApp.Monitoring do # Polling, metrics, uptime
defmodule MyApp.Notifications do # Alerts, emails
defmodule MyApp.Reports do # Report generation, exports
7. Deployment
Releases with mix release
# mix.exs
def project do
[
app: :my_app,
version: "1.0.0",
elixir: "~> 1.17",
releases: [
my_app: [
include_executables_for: [:unix],
steps: [:assemble, :tar],
cookie: "my-secure-cookie-#{Mix.env()}"
]
]
]
end
# Build a release
MIX_ENV=prod mix release
# Run it
_build/prod/rel/my_app/bin/my_app start
# Remote console
_build/prod/rel/my_app/bin/my_app remote
Runtime Config
# config/runtime.exs — evaluated at boot time, not compile time
import Config
config :my_app, MyApp.Repo,
url: System.get_env("DATABASE_URL") || raise("DATABASE_URL not set"),
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
ssl: System.get_env("DATABASE_SSL") == "true",
ssl_opts: [verify: :verify_none]
config :my_app, MyAppWeb.Endpoint,
url: [host: System.get_env("PHX_HOST") || "localhost", port: 443, scheme: "https"],
http: [
ip: {0, 0, 0, 0},
port: String.to_integer(System.get_env("PORT") || "4000")
],
secret_key_base: System.get_env("SECRET_KEY_BASE") || raise("SECRET_KEY_BASE not set")
# ❌ Bad: Using Application.get_env at compile time for runtime values
# config/config.exs
config :my_app, api_key: System.get_env("API_KEY") # Baked in at compile time!
# ✅ Good: Use runtime.exs or Application.compile_env only for truly static config
Migrations in Production
# rel/overlays/bin/migrate — migration script for releases
defmodule MyApp.Release do
@moduledoc "Release tasks (run without Mix)."
@app :my_app
def migrate do
load_app()
for repo <- repos() do
{:ok, _, _} =
Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
end
end
def rollback(repo, version) do
load_app()
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end
defp repos do
Application.fetch_env!(@app, :ecto_repos)
end
defp load_app do
Application.ensure_all_started(:ssl)
Application.load(@app)
end
end
# Run from release:
# bin/my_app eval "MyApp.Release.migrate()"
Safe migration practices
# ✅ Safe: Add columns as nullable first, backfill, then add constraint
def change do
alter table(:devices) do
add :firmware_version, :string # nullable, no default
end
end
# Later migration:
def change do
alter table(:devices) do
modify :firmware_version, :string, null: false, default: "unknown"
end
end
# ✅ Safe: Create index concurrently (no table lock)
@disable_ddl_transaction true
@disable_migration_lock true
def change do
create index(:devices, [:site_id, :status], concurrently: true)
end
# ❌ Dangerous: Renaming columns (breaks running code during deploy)
# Instead: add new column, backfill, deploy code using new column, drop old column
Health Checks
defmodule MyAppWeb.HealthController do
use MyAppWeb, :controller
def check(conn, _params) do
checks = %{
database: check_database(),
migrations: check_migrations(),
memory: check_memory()
}
status = if Enum.all?(Map.values(checks), &(&1 == :ok)), do: 200, else: 503
json(conn, %{
status: if(status == 200, do: "healthy", else: "unhealthy"),
checks: checks,
version: Application.spec(:my_app, :vsn) |> to_string(),
node: Node.self()
})
end
# Liveness: "is the process alive?" (for k8s liveness probe)
def liveness(conn, _params) do
send_resp(conn, 200, "ok")
end
# Readiness: "can it serve traffic?" (for k8s readiness probe / load balancer)
def readiness(conn, _params) do
if ready?() do
send_resp(conn, 200, "ready")
else
send_resp(conn, 503, "not ready")
end
end
defp check_database do
case Ecto.Adapters.SQL.query(MyApp.Repo, "SELECT 1", []) do
{:ok, _} -> :ok
{:error, _} -> :error
end
end
defp check_migrations do
# Ensure no pending migrations
case Ecto.Migrator.migrations(MyApp.Repo) do
migrations when is_list(migrations) ->
if Enum.any?(migrations, fn {status, _, _} -> status == :down end),
do: :pending,
else: :ok
_ ->
:error
end
end
defp check_memory do
mem = :erlang.memory(:total)
# Alert if using more than 1GB
if mem > 1_073_741_824, do: :warning, else: :ok
end
defp ready? do
# Check if all required services are up
check_database() == :ok
end
end
# Router
scope "/health", MyAppWeb do
pipe_through :api
get "/", HealthController, :check
get "/live", HealthController, :liveness
get "/ready", HealthController, :readiness
end
Graceful Shutdown
# BEAM handles SIGTERM gracefully by default in releases.
# Customize shutdown behavior:
defmodule MyApp.Application do
use Application
@impl true
def start(_type, _args) do
children = [
MyApp.Repo,
{Phoenix.PubSub, name: MyApp.PubSub},
# Workers that need graceful shutdown
{MyApp.Poller, shutdown: :timer.seconds(30)},
MyAppWeb.Endpoint
]
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
@impl true
def prep_stop(_state) do
# Called before supervision tree shuts down
# Deregister from load balancer, stop accepting new work
Logger.info("Application shutting down, draining connections...")
MyAppWeb.Endpoint.config_change([], [])
end
@impl true
def stop(_state) do
Logger.info("Application stopped")
end
end
# In GenServer, handle terminate for cleanup
defmodule MyApp.Poller do
use GenServer
@impl true
def terminate(reason, state) do
Logger.info("Poller terminating: #{inspect(reason)}")
# Flush pending results, close connections, etc.
flush_pending(state)
:ok
end
end
# vm.args.eex — configure shutdown timeout
# In rel/vm.args.eex:
# +sbwt none
# -shutdown_time 30000
Rolling Deploys
# Dockerfile for releases
# syntax=docker/dockerfile:1
FROM hexpm/elixir:1.17.3-erlang-27.1-debian-bookworm-20240904 AS build
RUN apt-get update && apt-get install -y git build-essential
WORKDIR /app
ENV MIX_ENV=prod
COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV
RUN mix deps.compile
COPY config/config.exs config/prod.exs config/
COPY lib lib
COPY priv priv
COPY assets assets
RUN mix assets.deploy
RUN mix compile
COPY config/runtime.exs config/
RUN mix release
# --- Runtime ---
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y libssl3 libncurses6 locales \
&& rm -rf /var/lib/apt/lists/*
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG=en_US.UTF-8
WORKDIR /app
COPY --from=build /app/_build/prod/rel/my_app ./
ENV PHX_SERVER=true
CMD ["bin/my_app", "eval", "MyApp.Release.migrate()"] # Run migrations
CMD ["bin/my_app", "start"]
Blue-green / rolling deploy checklist
- Database migrations first — run
migratebefore deploying new code - Backward-compatible migrations only — new code must work with old schema during transition
- Health check gates — don't route traffic until
/health/readyreturns 200 - Graceful drain — send SIGTERM, wait for in-flight requests to complete (30s timeout)
- Connection draining — WebSocket/LiveView connections reconnect automatically
- Feature flags — for risky changes, deploy code behind a flag, enable after verification
# Example k8s deployment spec
apiVersion: apps/v1
kind: Deployment
spec:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
spec:
terminationGracePeriodSeconds: 30
containers:
- name: app
livenessProbe:
httpGet:
path: /health/live
port: 4000
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /health/ready
port: 4000
initialDelaySeconds: 10
periodSeconds: 5
Quick Reference: Common Anti-Patterns
| Anti-Pattern | Better Approach |
|---|---|
| Fat GenServer callbacks | Extract logic into pure-function modules |
Application.get_env in module body |
Application.compile_env or runtime.exs |
Loading URL-dependent data in mount |
Use handle_params |
| Storing large lists in assigns | Use stream/3 |
| N+1 Ecto queries in loops | Preload or batch queries |
| God context module (500+ lines) | Split by responsibility |
Agent for complex state |
Use GenServer |
| Renaming DB columns in one migration | Add → backfill → migrate code → drop |
System.get_env in config.exs |
Move to runtime.exs |
String.concat in loops |
Use iolists |
Frequent :persistent_term writes |
Use ETS for mutable cache |
Blocking work in GenServer.init |
Use handle_continue |
Process.link for observing |
Use Process.monitor |
Repo.all then filter in Elixir |
Filter in the query |