- name
- loom-elixir
- description
- Elixir language expertise for idiomatic, production-quality code.
- triggers
- ["elixir","ex","exs","mix","hex","otp","erlang","beam","genserver","supervisor","phoenix","liveview","ecto","exunit","credo","dialyzer","oban","plug","mox"]
# Elixir Language Expertise
## Overview
Idiomatic, production-grade Elixir on the BEAM: mix, pattern matching, processes and OTP (GenServer, supervisors), Phoenix and Ecto, and ExUnit. Assumes competence; the value is in what is easy to get wrong (atom exhaustion, a single GenServer serializing the hot path, unbounded mailboxes, compile-time config baked into releases, LiveView's double mount, Ecto's absent lazy loading) and in writing tests that loom's `mix-test` adapter can select one at a time.
## Tooling
**mix** builds, tests and manages dependencies; Hex is the package registry.
| Task | Command |
| --- | --- |
| New project | `mix new app --sup` (with a supervision tree), `mix phx.new app` (Phoenix) |
| Add a dependency | add it to `deps/0` in `mix.exs` (mix has no `add` command), then `mix deps.get` to resolve and write `mix.lock` |
| Update one dependency | `mix deps.update jason` |
| Remove unused lock entries | `mix deps.unlock --unused`; CI check: `mix deps.unlock --check-unused` |
| Outdated / retired packages | `mix hex.outdated`, `mix hex.audit`; `mix deps.audit` with `mix_audit` for CVEs |
| Compile strictly | `mix compile --warnings-as-errors` |
| Find recompilation chains | `mix xref graph --label compile-connected` |
| Format | `mix format` (config in `.formatter.exs`); CI: `mix format --check-formatted` |
| Lint | `mix credo --strict` |
| Types | `mix dialyzer` (dialyxir); recent compilers also emit gradual type-check warnings |
| Security (Phoenix) | `mix sobelow` |
Commit `mix.lock`. Dependencies with the `only: [:dev, :test]` and `runtime: false` options stay out of releases.
**The gate:**
```bash
mix format --check-formatted
mix compile --warnings-as-errors
mix credo --strict
mix test --warnings-as-errors
mix dialyzer # when the project uses dialyxir
```
## Pattern Matching and Control Flow
- `=` matches; it binds on success and raises `MatchError` on failure. Pin an existing value with `^`: `^expected = actual`.
- Prefer multiple function clauses with guards over `if`/`cond` chains inside one body; clause order is match order.
- Return tagged tuples (`{:ok, value}`, `{:error, reason}`) from functions that can fail; the bang variant (`File.read!`) raises instead. Offer both when callers need both.
- `with` chains the happy path. Without `else`, the first non-matching value is returned as is. With `else`, every non-matching shape must have a clause, or `with` raises `WithClauseError`.
- Map patterns match a subset of keys: `%{} = value` matches any map, so test emptiness with `map_size(m) == 0`. A struct pattern `%User{}` also asserts the struct type.
- Binary patterns parse protocols and file headers without manual slicing.
```elixir
defmodule Spool.Writer do
@spec write(Path.t(), String.t(), iodata()) :: :ok | {:error, :symlink | File.posix()}
def write(root, job_id, payload) when is_binary(job_id) do
with {:ok, %File.Stat{type: :directory}} <- File.lstat(root),
:ok <- File.write(Path.join(root, job_id), payload, [:exclusive]) do
:ok
else
{:ok, %File.Stat{type: :symlink}} -> {:error, :symlink}
{:ok, %File.Stat{}} -> {:error, :enotdir}
{:error, reason} -> {:error, reason}
end
end
end
<<magic::binary-size(4), version::unsigned-big-16, rest::binary>> = header
```
**Atoms are never garbage-collected** and the atom table has a fixed limit (1,048,576 by default); filling it crashes the VM. Never call `String.to_atom/1` or `:erlang.binary_to_term/1` on external input. Use `String.to_existing_atom/1` or an explicit map from strings to atoms.
**Structs and data:** `@enforce_keys` for required fields; `Keyword.validate!/2` for option lists; `get_in`/`put_in`/`update_in` for nested updates; `Map.update!/3` when the key must exist.
## Processes and OTP
- Processes share nothing and communicate by message; each is cheap (a few KB). Every message is copied into the receiver's heap, except binaries over 64 bytes, which are reference-counted.
- **GenServer** state lives in one process, so every call is serialized. Use a GenServer for state, concurrency control or lifecycle, and plain modules for code organization. A single GenServer on the hot path becomes the bottleneck; serve read-heavy shared state from ETS (`read_concurrency: true`) or `:persistent_term` (rarely changed: every update triggers a global GC pass).
- Do slow initialization in `handle_continue/2` so `start_link` returns and the supervisor is not blocked.
- `GenServer.call` times out after 5_000 ms by default and exits the caller. `cast` has no backpressure: a fast producer grows the receiver's mailbox without bound. Prefer `call` when the sender can outpace the receiver.
- Defining any `handle_info/2` clause replaces the default, which logs unexpected messages; add a catch-all clause, or a stray message crashes the process with `FunctionClauseError`.
- `terminate/2` runs on shutdown only if the process traps exits (`Process.flag(:trap_exit, true)`); never rely on it for durable cleanup.
- Links propagate crashes; monitors deliver a `:DOWN` message. Link what should die together; monitor what you only observe.
- Name dynamic processes through `Registry` (`{:via, Registry, {Spool.Registry, key}}`); never generate atoms for names.
```elixir
defmodule Spool.Queue do
use GenServer
require Logger
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
def push(job), do: GenServer.call(__MODULE__, {:push, job})
def depth, do: GenServer.call(__MODULE__, :depth)
@impl true
def init(opts), do: {:ok, %{jobs: :queue.new(), dir: Keyword.fetch!(opts, :dir)}, {:continue, :load}}
@impl true
def handle_continue(:load, state), do: {:noreply, %{state | jobs: load_pending(state.dir)}}
@impl true
def handle_call({:push, job}, _from, state), do: {:reply, :ok, %{state | jobs: :queue.in(job, state.jobs)}}
def handle_call(:depth, _from, state), do: {:reply, :queue.len(state.jobs), state}
@impl true
def handle_info(msg, state) do
Logger.warning("unexpected message: #{inspect(msg)}")
{:noreply, state}
end
end
```
**Supervisors.** Strategies: `:one_for_one` (restart the failed child), `:rest_for_one` (restart it and every child started after it), `:one_for_all`. Children start in list order and stop in reverse. The default restart intensity is 3 restarts in 5 seconds; past it the supervisor exits and its parent decides. Use `restart: :transient` for children that may finish normally, `DynamicSupervisor` for children started on demand.
```elixir
children = [
{Registry, keys: :unique, name: Spool.Registry},
{Task.Supervisor, name: Spool.TaskSupervisor},
{Spool.Queue, dir: Application.fetch_env!(:spool, :dir)}
]
Supervisor.start_link(children, strategy: :rest_for_one, name: Spool.Supervisor)
```
**Tasks.** `Task.async_stream(items, &fetch/1, max_concurrency: 8, timeout: 10_000)` bounds concurrency; `Task.Supervisor.async_nolink/2` runs work that may crash without taking the caller down. A bare `Task.async` links to the caller, so its crash kills the caller too.
## Phoenix Essentials
- **Contexts** (`Accounts`, `Billing`) own their schemas and `Repo` calls; controllers and LiveViews call contexts, never `Repo` directly.
- **Plug:** `conn` is immutable; each plug returns a new one. A plug that sends a response must `halt/1`, or later plugs keep running.
- **LiveView** `mount/3` runs twice: once for the disconnected HTTP render, once over the WebSocket. Subscribe to PubSub and start timers only when `connected?(socket)`. Use `stream/3` for large collections so the socket does not hold them, and `assign_async/3` or `start_async/3` for slow loads.
- **Verified routes** (`~p"/orders/#{order}"`) are checked at compile time.
- **Config:** `config/config.exs` and `config/prod.exs` are evaluated at build time and baked into the release; read secrets and environment in `config/runtime.exs` with `System.fetch_env!/1`. Inside modules, `Application.compile_env/3` for build-time values (mix tracks them), `Application.get_env/3` at runtime.
- **Security:** HEEx escapes output; `raw/1` bypasses it. Forms carry CSRF tokens by default; keep `:protect_from_forgery` and `:put_secure_browser_headers` in the browser pipeline.
```elixir
def mount(_params, _session, socket) do
if connected?(socket), do: Phoenix.PubSub.subscribe(Shop.PubSub, "orders")
{:ok, stream(socket, :orders, Orders.list_recent())}
end
def handle_info({:order_created, order}, socket) do
{:noreply, stream_insert(socket, :orders, order, at: 0)}
end
```
## Ecto Essentials
- **Changesets:** the field list passed to `cast/4` is the mass-assignment allowlist. `unique_constraint/2` turns the database's unique-index violation into a changeset error and needs that index to exist.
- **No lazy loading:** an association that was not preloaded is `%Ecto.Association.NotLoaded{}`. Preload in the query or with `Repo.preload/2`; preloading inside a loop is N+1.
- **Queries** compose, and `^` interpolates a bound parameter, so pinned values cannot inject SQL. `fragment/1` takes `?` placeholders and a literal string.
- **Transactions:** `Repo.transaction/2` or `Ecto.Multi` for named steps whose failure you want to inspect.
- **Bulk operations:** `Repo.insert_all/3` and `update_all/3` skip changesets, validations and automatic timestamps.
- **Migrations:** `create index(..., concurrently: true)` needs `@disable_ddl_transaction true` and `@disable_migration_lock true`.
```elixir
def changeset(user, attrs) do
user
|> cast(attrs, [:email, :name]) # role is never cast from user input
|> validate_required([:email])
|> update_change(:email, &String.downcase/1)
|> unique_constraint(:email)
end
Order
|> where([o], o.customer_id == ^customer_id)
|> preload(:lines)
|> Repo.all()
```
## Background Jobs and Releases
- **Oban** stores jobs in Postgres. Insert the job in the same transaction as the data it processes (`Ecto.Multi` plus `Oban.insert/3`), so a rollback never leaves an orphan job.
- `perform/1` returns `:ok`, `{:ok, value}`, `{:error, reason}` (retried up to `max_attempts`), `{:cancel, reason}` or `{:snooze, seconds}`. A raise also counts as a failed attempt.
- Job args round-trip through JSON: a worker receives string keys (`%{"order_id" => id}`), so match on strings, and pass ids instead of structs.
- Workers must be idempotent: every failure is retried. Add `unique: [period: 60]` where duplicates are possible.
- Test with `Oban.Testing` (`perform_job/2`, `assert_enqueued/1`) and `testing: :manual` in `config/test.exs`.
```elixir
defmodule Shop.Workers.SendConfirmation do
use Oban.Worker, queue: :mailers, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"order_id" => order_id}}) do
with {:ok, order} <- Shop.Orders.fetch(order_id) do
Shop.Mailer.deliver_confirmation(order)
end
end
end
```
**Releases:** `MIX_ENV=prod mix release` builds a self-contained release; `config/runtime.exs` runs at every boot. Run migrations from a release module (`bin/shop eval "Shop.Release.migrate()"`), since mix is absent in a release.
## Testing
```elixir
# test/spool/writer_test.exs
defmodule Spool.WriterTest do
use ExUnit.Case, async: true
@moduletag :tmp_dir
describe "write/3" do
test "writes the payload into the spool directory", %{tmp_dir: dir} do
assert :ok = Spool.Writer.write(dir, "job-1", "payload")
assert File.read!(Path.join(dir, "job-1")) == "payload"
end
test "rejects a symlinked spool directory", %{tmp_dir: dir} do
target = Path.join(dir, "target")
link = Path.join(dir, "spool")
File.mkdir_p!(target)
File.ln_s!(target, link)
assert {:error, :symlink} = Spool.Writer.write(link, "job-1", "payload")
refute File.exists?(Path.join(target, "job-1"))
end
end
end
```
- `test/test_helper.exs` calls `ExUnit.start()`; test files end in `_test.exs`.
- `async: true` runs the module concurrently with other async modules. Leave it off when tests touch global state: application env, named processes, fixed file-system paths, `:persistent_term`.
- `setup` returns a map or keyword list merged into the context; `setup_all` runs once per module; `on_exit/1` registers cleanup. The `:tmp_dir` tag gives each test a fresh directory in the context.
- `start_supervised!/1` starts a process under the test supervisor and stops it when the test ends; prefer it over `start_link` in tests.
- `assert_receive msg, timeout` (default 100 ms) and `refute_receive` replace `Process.sleep` for asynchronous assertions.
- `assert_raise/2`, `capture_log/1`, `capture_io/1`; `doctest Spool.Writer` runs the `iex>` examples in `@doc`.
- **Mox:** mocks only behaviours. `Mox.defmock(Spool.StoreMock, for: Spool.Store)`, `expect/4` in the test, `setup :verify_on_exit!`, and `Mox.allow/3` when another process calls the mock.
Ver en GitHub