Overview
Write Elixir as clear data transformations with explicit process and failure ownership. Research baseline: 2026-09-05, Elixir 1.20 stable, with gradual compiler type inference across language constructs. Inspect the project's Elixir/Erlang requirements and locked Phoenix/Ecto versions first. Elixir 1.20 requires OTP 27+; select a documented compatible pair rather than independently upgrading runtimes.
Mental model
Modules organize behavior; processes own concurrent activity and state. Supervision restores failed processes, not lost database transactions or external side effects. Validate untrusted input and represent expected failures explicitly; let unexpected defects fail where a deliberate supervision boundary can handle them.
Functions, data, and current typing
- Use functions and pattern matching for ordinary domain logic. A GenServer is not required to encapsulate a module or make it "OTP-first."
- Use clauses and guards when they clarify accepted shapes. Validate untrusted input before entering functions whose patterns assume internal invariants; don't add catch-all success defaults to hide malformed data.
- Keep
{:ok, value}/{:error, reason} contracts consistent. Use with for dependent fallible steps, but normalize ambiguous failure shapes in their owning functions. A large else reconstructing which step failed signals unclear contracts.
- Use
case for branching and pipes for linear transformations; do not force every condition into with. Keep error reasons useful to callers without leaking sensitive input.
- Elixir 1.20 infers types through expressions, guards, clauses, and dependency information. Treat verified-bug/dead-code warnings as evidence to investigate. This is not complete compile-time proof of program correctness.
- Keep
@spec and @type for documented contracts and existing analysis tools. Do not invent new set-theoretic annotation syntax: user-supplied signatures and typed structs for that system remain future work in the 1.20 release.
- Structs provide a known shape, not automatic field validation. Use changesets or explicit constructors for boundary checks;
@enforce_keys does not validate values.
- Keep external keys as strings or map them through an explicit allowlist. Unbounded
String.to_atom/1 can exhaust the atom table; to_existing_atom/1 still raises and does not prove an atom is allowed for this operation.
- Use
Stream for deferred traversal when needed; consume it deliberately. Avoid assuming laziness removes the memory cost of a later full materialization.
Process and task ownership
- Introduce a GenServer when serial access to state or a managed lifecycle is needed. Long callbacks block its mailbox; move independent work to supervised tasks with explicit result handling.
- Choose supervision strategy from dependency relationships:
:one_for_one for independent children, :rest_for_one when later children depend on earlier ones. Specify restart/shutdown policy; a Task normally has temporary restart behavior.
Task.async links caller and task, so task failure can terminate its caller. Use supervised async_nolink when that coupling is undesirable, and consume results, failures, and monitor messages.
- Bound fan-out with
Task.async_stream or equivalent limits. Set concurrency according to downstream capacity, with a deliberate timeout policy. Lazy input alone does not make unbounded spawning safe.
- Task closures copy captured data into another process. Extract only required values; avoid capturing an entire LiveView socket or large state object.
- A
GenServer.call timeout does not cancel work already accepted by the server. Account for uncertain completion before retrying a mutation; a cast provides no processing acknowledgement.
- Do not synchronously call a GenServer from itself; direct self-calls fail rather than becoming useful serialization. Keep internal computation in functions, or redesign the interaction.
- Supervision is not durable job storage. Work that must survive node loss needs the application's durable job mechanism and idempotent effects.
Persistence and Phoenix boundaries
- Changesets cast allowed external fields, validate domain rules, and translate declared database constraint failures. Back race-sensitive invariants with actual database constraints; preflight checks alone are insufficient.
- Use
Ecto.Multi for named dependent database operations when it improves clarity. Handle the failed operation and reason; returned changes_so_far contains prior operation results, while the transaction's database writes roll back. Transactions do not make external API calls reversible.
- Respect existing Phoenix context APIs so controllers and LiveViews share authorization and domain rules. Avoid blanket rules forbidding meaningful domain validation in changesets.
- Authorize protected operations on the server, including LiveView events. A hidden button and a successful initial mount do not establish ongoing authorization.
- In supported LiveView versions, use
assign_async/start_async for lifecycle-managed work, with loading/error rendering. Capture needed values before starting work; avoid blocking callbacks with Task.await.
- Use streams for large changing collections when their identity/update model fits. They reduce retained collection state; they do not remove the need for query limits or pagination.
Example
Accept only known external values without creating atoms or disguising errors:
defmodule Visibility do
@spec parse(term()) :: {:ok, :public | :private} | {:error, :invalid_visibility}
def parse("public"), do: {:ok, :public}
def parse("private"), do: {:ok, :private}
def parse(_), do: {:error, :invalid_visibility}
end
The final clause returns an explicit expected failure. Internal callers can pattern-match the result without rescuing exceptions or silently selecting a default.
Checklist
- Verify Elixir/OTP compatibility and supported library APIs.
- Keep process boundaries motivated by concurrency/lifecycle, not code organization.
- Check expected failures, compiler warnings, atom handling, and boundary validation.
- Check task links, bounded work, timeouts, duplicate effects, and transaction limits.
- Use installed formatter, compilation checks, ExUnit, and existing analysis tools when authorized. Format checks alone do not prove compilation or behavior; use async tests only when their shared resources are isolated.
References
1---2name: programming-elixir3description: OTP-first architecture, pattern matching, supervision design, and Phoenix conventions. Auto-activates in Elixir projects.4license: Apache-2.05---67## Overview89Write Elixir as clear data transformations with explicit process and failure ownership. Research baseline: 2026-09-05, Elixir 1.20 stable, with gradual compiler type inference across language constructs. Inspect the project's Elixir/Erlang requirements and locked Phoenix/Ecto versions first. Elixir 1.20 requires OTP 27+; select a documented compatible pair rather than independently upgrading runtimes.1011## Mental model1213Modules organize behavior; processes own concurrent activity and state. Supervision restores failed processes, not lost database transactions or external side effects. Validate untrusted input and represent expected failures explicitly; let unexpected defects fail where a deliberate supervision boundary can handle them.1415## Functions, data, and current typing1617- Use functions and pattern matching for ordinary domain logic. A GenServer is not required to encapsulate a module or make it "OTP-first."18- Use clauses and guards when they clarify accepted shapes. Validate untrusted input before entering functions whose patterns assume internal invariants; don't add catch-all success defaults to hide malformed data.19- Keep `{:ok, value}`/`{:error, reason}` contracts consistent. Use `with` for dependent fallible steps, but normalize ambiguous failure shapes in their owning functions. A large `else` reconstructing which step failed signals unclear contracts.20- Use `case` for branching and pipes for linear transformations; do not force every condition into `with`. Keep error reasons useful to callers without leaking sensitive input.21- Elixir 1.20 infers types through expressions, guards, clauses, and dependency information. Treat verified-bug/dead-code warnings as evidence to investigate. This is not complete compile-time proof of program correctness.22- Keep `@spec` and `@type` for documented contracts and existing analysis tools. Do not invent new set-theoretic annotation syntax: user-supplied signatures and typed structs for that system remain future work in the 1.20 release.23- Structs provide a known shape, not automatic field validation. Use changesets or explicit constructors for boundary checks; `@enforce_keys` does not validate values.24- Keep external keys as strings or map them through an explicit allowlist. Unbounded `String.to_atom/1` can exhaust the atom table; `to_existing_atom/1` still raises and does not prove an atom is allowed for this operation.25- Use `Stream` for deferred traversal when needed; consume it deliberately. Avoid assuming laziness removes the memory cost of a later full materialization.2627## Process and task ownership2829- Introduce a GenServer when serial access to state or a managed lifecycle is needed. Long callbacks block its mailbox; move independent work to supervised tasks with explicit result handling.30- Choose supervision strategy from dependency relationships: `:one_for_one` for independent children, `:rest_for_one` when later children depend on earlier ones. Specify restart/shutdown policy; a Task normally has temporary restart behavior.31- `Task.async` links caller and task, so task failure can terminate its caller. Use supervised `async_nolink` when that coupling is undesirable, and consume results, failures, and monitor messages.32- Bound fan-out with `Task.async_stream` or equivalent limits. Set concurrency according to downstream capacity, with a deliberate timeout policy. Lazy input alone does not make unbounded spawning safe.33- Task closures copy captured data into another process. Extract only required values; avoid capturing an entire LiveView socket or large state object.34- A `GenServer.call` timeout does not cancel work already accepted by the server. Account for uncertain completion before retrying a mutation; a cast provides no processing acknowledgement.35- Do not synchronously call a GenServer from itself; direct self-calls fail rather than becoming useful serialization. Keep internal computation in functions, or redesign the interaction.36- Supervision is not durable job storage. Work that must survive node loss needs the application's durable job mechanism and idempotent effects.3738## Persistence and Phoenix boundaries3940- Changesets cast allowed external fields, validate domain rules, and translate declared database constraint failures. Back race-sensitive invariants with actual database constraints; preflight checks alone are insufficient.41- Use `Ecto.Multi` for named dependent database operations when it improves clarity. Handle the failed operation and reason; returned `changes_so_far` contains prior operation results, while the transaction's database writes roll back. Transactions do not make external API calls reversible.42- Respect existing Phoenix context APIs so controllers and LiveViews share authorization and domain rules. Avoid blanket rules forbidding meaningful domain validation in changesets.43- Authorize protected operations on the server, including LiveView events. A hidden button and a successful initial mount do not establish ongoing authorization.44- In supported LiveView versions, use `assign_async`/`start_async` for lifecycle-managed work, with loading/error rendering. Capture needed values before starting work; avoid blocking callbacks with `Task.await`.45- Use streams for large changing collections when their identity/update model fits. They reduce retained collection state; they do not remove the need for query limits or pagination.4647## Example4849Accept only known external values without creating atoms or disguising errors:5051```elixir52defmodule Visibility do53 @spec parse(term()) :: {:ok, :public | :private} | {:error, :invalid_visibility}54 def parse("public"), do: {:ok, :public}55 def parse("private"), do: {:ok, :private}56 def parse(_), do: {:error, :invalid_visibility}57end58```5960The final clause returns an explicit expected failure. Internal callers can pattern-match the result without rescuing exceptions or silently selecting a default.6162## Checklist6364- Verify Elixir/OTP compatibility and supported library APIs.65- Keep process boundaries motivated by concurrency/lifecycle, not code organization.66- Check expected failures, compiler warnings, atom handling, and boundary validation.67- Check task links, bounded work, timeouts, duplicate effects, and transaction limits.68- Use installed formatter, compilation checks, ExUnit, and existing analysis tools when authorized. Format checks alone do not prove compilation or behavior; use async tests only when their shared resources are isolated.6970## References7172- [Elixir stable documentation](https://elixir-lang.org/docs/) and [1.20 typing release](https://elixir-lang.org/blog/2026/06/03/elixir-v1-20-0-released/)73- [Elixir 1.20 OTP requirements](https://elixir.hexdocs.pm/1.20.0/changelog.html)74- [Official code anti-patterns](https://elixir.hexdocs.pm/code-anti-patterns.html)75- [Task ownership and concurrency](https://elixir.hexdocs.pm/Task.html)76- [GenServer call semantics](https://elixir.hexdocs.pm/GenServer.html) and [supervision policies](https://elixir.hexdocs.pm/Supervisor.html)77- [Ecto changesets](https://ecto.hexdocs.pm/Ecto.Changeset.html) and [transactions with Multi](https://ecto.hexdocs.pm/Ecto.Multi.html)78- [LiveView async operations and streams](https://phoenix-live-view.hexdocs.pm/Phoenix.LiveView.html)