| name | elixir |
| version | 0.3.0 |
| description | Elixir functional programming, OTP, and Ecto โ pattern matching, pipelines, Enum/Stream, with-chains, ok/error tuples, multi-clause functions, GenServer, gen_statem, supervision, ETS, Ecto schemas/changesets/queries/migrations, configuration, telemetry, HTTP clients, and type system. ALWAYS use this skill when writing Elixir to avoid imperative anti-patterns. ALWAYS use when designing OTP supervision trees, state machines, or distributed systems. ALWAYS consult the Architecture and OTP sections when planning new Elixir projects, refactoring existing ones, or making structural decisions. |
Elixir Programming Skill
Navigation โ Supporting Files
| File | Contents |
|---|
| networking.md | TCP/UDP socket programming: gen_tcp/gen_udp API, active vs passive mode decision guide, listener/acceptor pattern, protocol framing (length-prefix, delimiter, TLV), buffer management, connection supervision, Thousand Island/Ranch, UDP broadcast/multicast, BAD/GOOD pairs |
| data-structures.md | Performance table, lists, maps, tuples, keywords, MapSet, ranges, :queue/:digraph/:ordsets, structs (constructors, pipelines, protocols, nesting), embedded schemas, binary matching + construction, binary protocol patterns (variable-length parsing, encode/decode round-trips, streaming buffer) |
| quick-references.md | LLM rules (graphemes, atom safety, Enum.at, strftime) + Elixir stdlib: Enum, Map, Keyword, List, String, Regex, File/Path/System, URI/Base, Date/Time, IO/Inspect, Access, Process, Macro/Module, Range, Agent + Erlang stdlib (21 modules): :queue, :persistent_term, :atomics, :counters, :ets, :dets, :ordsets, :digraph, :gb_trees, :array, :math, :rand, :binary, :erlang, :lists, :timer, :crypto (ECDH, AEAD, signatures, hashing, HMAC, key derivation, decision table), :io_lib, :calendar, :unicode, :zlib, :os, :telemetry, :sys, :file + JSON |
| language-patterns.md | Extended pattern matching, guards, case/cond, with, pipelines (tap/then/dbg), @enforce_keys, comprehensions (reduce:, into:, uniq:, binary), function captures, behaviours, protocols, streams/Enumerable/Collectable, error handling, advanced reduce (multi-accumulator, map_reduce, flat_map_reduce, reduce_while, scan), functional state module pattern, advanced patterns (pipeline, option registration, AST traversal, backoff) |
| code-style.md | .formatter.exs config, migration options, Credo checks catalog, module organization order, function ordering, multi-clause formatting, string sigil selection, defdelegate guidance, idiomatic formatter readability, readable code patterns (pipelines, guards, naming, conditionals), 12 BAD/GOOD pairs |
| documentation.md | @moduledoc/@doc patterns, @spec/@type/@typedoc, @since/@deprecated, doctests (multi-line, exceptions, ellipsis), ExDoc config, cross-references |
| type-system.md | Set-theoretic types (1.17-1.20), binary/String.t/iodata decision table, @spec when clause, common @spec patterns (GenServer, Phoenix, Plug, LiveView), @type best practices, defguard types, Dialyzer setup, compiler warnings with fixes, dynamic(), inference, roadmap |
| architecture-reference.md | Architecture layouts, Phoenix contexts, layered/pipeline architecture, production patterns, anti-patterns catalog |
| debugging-profiling.md | IO.inspect, dbg, IEx.pry, break!, Rexbug, system introspection, Logger, fprof/eprof/cprof/tprof, Benchee, memory/VM/scheduler profiling |
| ecto-reference.md | Ecto field types, changeset API, Query.API functions (fragment, type, coalesce, dynamic, selected_as, parent_as, exists, window functions), Repo API, Multi, migrations, custom type callbacks, association helpers (assoc/build_assoc), schemaless changesets |
| ecto-examples.md | Complete Ecto examples: schemas, multi-step changesets, composable queries, dynamic filters, preloading strategies, migrations, Multi patterns, custom types, soft delete, multi-tenancy, streaming, optimistic locking |
| otp-reference.md | OTP callback signatures, ETS operations, process debugging, release management |
| otp-examples.md | Complete OTP examples: rate limiter, state machine, worker pool, cache, circuit breaker, distribution |
| otp-advanced.md | GenStage, Flow, Broadway, hot code upgrades, production debugging |
| testing-reference.md | Testing quick refs: assertions, Mox API, LiveView/Channel helpers, StreamData |
| testing-examples.md | Complete testing examples: infrastructure, Mox, channels, LiveView, property testing, Oban |
| eventsourcing-reference.md | Commanded API, router DSL, dispatch options (returning:), Aggregate.Multi, Phoenix integration (controller/LiveView dispatch), aggregate use macro, middleware, projections, process managers |
| eventsourcing-examples.md | Complete event sourcing examples: aggregates, projectors, process managers, testing |
| production.md | Production Phoenix patterns, Edge/IoT patterns, Oban, telemetry, HTTP clients |
The Elixir Way
Rules for Writing Elixir (LLM)
- NEVER use if/else for structural dispatch. Use multi-clause functions with pattern matching. Use
if only for simple boolean guards with no else branch (side-effect or early return). When both branches return values, prefer case bool_fn?() do true -> ...; false -> ... end โ this matches the literal boolean (strict), unlike if which tests truthiness. Elixir's own stdlib (Keyword, Macro) and Credo-endorsed projects use this pattern. When the function signature is free (not a behaviour callback), prefer multi-clause dispatch instead.
- NEVER use try/rescue for expected failures. Use
{:ok, _}/{:error, _} tuples with case or with. Reserve try/rescue for truly unexpected exceptions at system boundaries. Exception: raise ArgumentError is appropriate for invalid configuration at startup โ these are programmer errors, not runtime failures (NimblePool, NimbleOptions pattern).
- NEVER write imperative loops. Elixir has no for/while loops with mutable state. Use
Enum.map/2, Enum.filter/2, Enum.reduce/3, for comprehensions, or recursion (for early termination, tree traversal, or streaming).
- ALWAYS design functions for pipe-ability. The subject (primary data) goes as the first argument. Return the transformed data. For mutation/configuration APIs, return the subject itself to enable chaining (Mox pattern:
MockMod |> expect(:fun, &impl/1) |> allow(self(), pid)).
- PREFER pattern matching in function heads over
case in the body when dispatching on argument shape or type.
- PREFER Enum functions over manual recursion for collection processing. Use recursion only for early termination, tree/graph traversal, or complex multi-accumulator state. Use
Stream for infinite/lazy sequences.
- NEVER reassign to accumulate. Rebinding a variable inside
Enum.each/2 does NOT mutate the outer variable. Use Enum.reduce/3 or Enum.map/2 to collect results.
- USE
with for chaining 2+ operations that return {:ok, _}/{:error, _}. Don't nest case statements.
- USE guard clauses to constrain function heads rather than validating inside the body with if/else.
- BUILD strings with IO lists, not repeated
<> concatenation. Collect [part1, ", ", part2] and call IO.iodata_to_binary/1 once, or pass IO lists directly to I/O functions.
- NEVER check for nil with if. Pattern match on the value's presence: use separate clauses for
nil and non-nil, or match %{key: value} to assert key exists.
- PREFER atoms over strings for internal identifiers. Atoms are interned (fast comparison). Use strings only for user/external data.
- PREFER
Map.new/2 and Enum.into/2 over Enum.reduce/3 when building maps or other collectables from lists.
- USE
Enum.reduce_while/3 for early-exit accumulation instead of throwing or using flags. Return {:cont, acc} or {:halt, acc}.
- USE
map, reduce, filter, or for to collect results. Choose the right function for the transformation needed.
- ALWAYS use
@impl on every behaviour callback implementation. Both @impl true and @impl ModuleName are idiomatic โ NimblePool and NimbleOptions use @impl true, Quantum uses @impl GenStage. Use the module name form when implementing multiple behaviours to disambiguate. It catches typos and missing callbacks at compile time.
- ALWAYS use
%{struct | key: val} for struct updates, not Map.put(struct, key, value). The update syntax raises on unknown keys, providing compile-time safety. Exception: Map.put is acceptable when the key is dynamic/computed at runtime.
- ALWAYS distinguish between in-process validation and deferred external checks. Validate data shape and rules immediately; defer uniqueness and referential checks to the database or external system. (In Ecto:
validate_* runs immediately, *_constraint runs after DB write.)
- PREFER
Task.async_stream for parallel independent work. Use ordered: false only when result ordering doesn't matter (side-effect-heavy work like compilation, formatting). The default ordered: true is correct for most use cases. Use Stream.run() when consuming only for side effects.
- ALWAYS put
@derive before defstruct/schema. NEVER implement a protocol for: Map expecting it to match structs โ structs dispatch separately.
Which Construct? โ Decision Guide
Check this table BEFORE writing control flow or collection operations:
| When you need to... | Use this | NOT this |
|---|
| Branch on data shape/type | Multi-clause function | if/case |
| Branch on ok/error from 1 operation | case | with, if |
| Chain 2+ ok/error operations | with | nested case |
| Boolean guard, side-effect only | if (no else) | if/else returning values |
| Boolean branch, both paths return | case bool do true/false | if/else (truthy, not strict) |
| Boolean dispatch, free signature | Multi-clause function | if/case in body |
| Dispatch on struct type | Multi-clause function | if is_struct(x, Mod) |
| Handle expected failure from call | {:ok,_}/{:error,_} tuples | try/rescue |
| Handle exits from GenServer.call | catch :exit (boundary only) | try/rescue |
| Handle malformed untrusted data | rescue (boundary only) | case/pattern match |
| Process every element | Enum.map(&fun/1) | Enum.map(fn x -> fun(x) end) |
| Filter a map by value | for {k, v} <- map, pred | Map.values |> Enum.filter |
| Build a map from enumerable | Map.new/2 or for ... into: %{} | Enum.reduce into %{} |
| Find in a list of tuples | List.keyfind/keymember? | Enum.find(fn {x,_} -> ... end) |
| Check list non-empty | [_ | _] = list or match? | length(list) > 0 |
| Accumulate with early stop | Enum.reduce_while | Enum.reduce with flag |
| Iterate with index | Enum.with_index | for i <- 0..length-1 |
| Build string from parts | IO list or interpolation | <> in a loop |
| Update nested map | put_in / update_in | manual get + put |
| Check if key exists in map | Map.has_key? or match? %{k: _} | map[:k] != nil |
| Swap implementation for test/prod | @callback behaviour | if Mix.env() == :test |
| Expose module's function unchanged | defdelegate | copy-paste wrapper |
| Check if map key exists (nil valid) | Map.fetch/2 | map[:key] != nil |
| Single transformation on a value | Direct call: Enum.map(list, &f/1) | list |> Enum.map(&f/1) |
| 2+ transformations on same data | Pipeline: data |> step1() |> step2() | Nested calls: step2(step1(data)) |
| try/rescue needed in a callback | Extract to named safe_x/1 function | Inline try do ... rescue in lambda |
try / catch / rescue Decision
| Situation | Use |
|---|
| Can you check the condition BEFORE the call? | Check first (Process.whereis, Map.fetch) |
| Calling a process you don't control? | catch :exit (GenServer.call to unknown PID) |
| Input from an untrusted/external source? | rescue (e.g., :erlang.binary_to_term on network data) |
| Error is an expected business case? | Return {:ok,_}/{:error,_} from the function |
| Everything else? | Let it crash โ supervisor handles it |
Top Anti-Patterns (BAD/GOOD)
1. if for structural dispatch โ multi-clause function:
# BAD
def handle(event) do
if is_struct(event, Click), do: handle_click(event), else: handle_other(event)
end
# GOOD
def handle(%Click{} = event), do: handle_click(event)
def handle(event), do: handle_other(event)
2. try/rescue for GenServer.call โ catch :exit (+ optional whereis):
# BAD โ rescue doesn't catch exits (GenServer.call raises exits, not exceptions)
try do
GenServer.call(pid, :status)
rescue
_ -> {:error, :down}
end
# GOOD โ catch :exit handles process death (as LiveView, Oban, db_connection do)
try do
GenServer.call(pid, :status)
catch
:exit, _ -> {:error, :down}
end
# GOOD โ whereis to skip optional calls + catch :exit for TOCTOU race
# (Oban pattern: whereis AND catch, because process can die between check and call)
case GenServer.whereis(name) do
nil -> {:error, :not_running}
pid ->
try do
GenServer.call(pid, :status)
catch
:exit, _ -> {:error, :down}
end
end
3. Map.values |> Enum.filter โ for comprehension:
# BAD โ builds intermediate list, then filters
active =
map
|> Map.values()
|> Enum.filter(& &1.active?)
# GOOD โ single pass with pattern match
active = for {_k, %{active?: true} = v} <- map, do: v
4. Enum.map(fn x -> M.fun(x) end) โ capture:
# BAD โ unnecessary anonymous function wrapper
Enum.map(users, fn user -> User.name(user) end)
# GOOD โ function capture
Enum.map(users, &User.name/1)
5. length(list) > 0 โ pattern match:
# BAD โ traverses entire list to count (O(n))
if length(list) > 0, do: process(list)
# GOOD โ constant time check (O(1))
case list do
[_ | _] -> process(list)
[] -> :empty
end
# Or with match? guard
if match?([_ | _], list), do: process(list)
6. map[:key] with nil check โ Map.fetch/2:
# BAD โ nil could mean "key absent" OR "value is nil"
value = config[:timeout]
if value != nil, do: use_timeout(value), else: use_default()
# GOOD โ distinguishes missing key from nil value
case Map.fetch(config, :timeout) do
{:ok, timeout} -> use_timeout(timeout)
:error -> use_default()
end
7. Single-step pipe โ direct function call:
# BAD โ pipe adds nothing for a single step
items |> Enum.map(&process/1)
result |> IO.write()
# GOOD โ direct call is clearer for one step
Enum.map(items, &process/1)
IO.write(result)
# GOOD โ 2+ steps justify a pipeline
items
|> Enum.map(&process/1)
|> Enum.sum()
8. try/rescue buried in lambda โ extract to named function:
# BAD โ rescue hidden inside Enum callback, silently swallows errors
Enum.map(items, fn item ->
try do
process(item)
rescue
_ -> nil
end
end)
# GOOD โ named function makes fault isolation visible and testable
Enum.map(items, &safe_process/1)
defp safe_process(item) do
process(item)
rescue
e ->
Logger.warning("process failed: #{Exception.message(e)}")
nil
end
Thinking Functionally
Data In, Data Out โ Every function takes data and returns new data. No side effects, no mutation. Design each function as a pure transformation:
# Imperative thinking: "modify the order"
# Functional thinking: "create a new order with these changes"
def apply_discount(%Order{} = order, percentage) do
discounted = order.total * (1 - percentage / 100)
%{order | total: discounted, discount_applied: true}
end
Pipelines as Data Flow โ Read left-to-right, top-to-bottom. Each step transforms data further:
raw_input # Start: raw string
|> String.trim() # Remove whitespace
|> String.split("\n") # Split into lines
|> Enum.reject(&(&1 == "")) # Remove blanks
|> Enum.map(&parse_line/1) # Parse each line
|> Enum.group_by(& &1.category) # Group by category
|> Map.new(fn {k, v} -> {k, length(v)} end) # Count per category
Compose Small Functions โ Each function does one job, is testable alone, and is reusable:
# BAD - one big function doing everything
def process(data) do
# 50 lines of mixed concerns
end
# GOOD - small, focused, composable functions
def process(data) do
data
|> validate()
|> normalize()
|> enrich()
|> persist()
end
Tagged Tuples as Types โ Elixir uses tagged tuples where other languages use enums/unions:
{:ok, value} # Success
{:error, :not_found} # Typed failure
{:error, %Changeset{}} # Rich failure
# Match exhaustively
case result do
{:ok, value} -> use(value)
{:error, :not_found} -> default()
{:error, reason} -> log_and_fail(reason)
end
Multi-Clause Functions Over Conditionals
# BAD
def process_user(user) do
if user != nil do
if user.active, do: {:ok, user}, else: {:error, "inactive"}
else
{:error, "not found"}
end
end
# GOOD
def process_user(%{active: true} = user), do: {:ok, user}
def process_user(%{active: false}), do: {:error, "inactive"}
def process_user(nil), do: {:error, "not found"}
Pattern Matching & Guards (Key Patterns)
# Function arguments โ struct + field extraction
def current_path(%Plug.Conn{query_string: ""} = conn), do: conn.request_path
def current_path(%Plug.Conn{query_string: q} = conn), do: "#{conn.request_path}?#{q}"
# Tagged tuples
def handle({:ok, value}), do: process(value)
def handle({:error, reason}), do: log_error(reason)
# Lists โ head/tail
def sum([head | tail], acc), do: sum(tail, acc + head)
def sum([], acc), do: acc
# Pin operator โ match against existing variable
target_id = 42
Enum.find(users, fn %{id: ^target_id} -> true; _ -> false end)
# Guards โ type dispatch
def process(x) when is_integer(x), do: x * 2
def process(x) when is_binary(x), do: String.length(x)
def process(x) when is_list(x), do: length(x)
# Guard with in (ranges, lists)
def weekday?(day) when day in [:mon, :tue, :wed, :thu, :fri], do: true
def weekday?(_), do: false
# Multiple when clauses = OR (more readable than `or`)
defp escape_char(char)
when char in 0x2061..0x2064
when char in [0x061C, 0x200E, 0x200F]
when char in 0x202A..0x202E do
# matches if ANY when clause is true
end
# Guard on module attribute (inlined at compile time)
@unsent [:unset, :set]
def send_resp(%Conn{state: state}, _, _) when state not in @unsent, do: raise AlreadySentError
# Custom guards โ reusable guard macros
defguard is_positive(n) when is_number(n) and n > 0
defguard is_non_empty_string(s) when is_binary(s) and byte_size(s) > 0
defguardp is_valid_age(age) when is_integer(age) and age in 0..150
# Use custom guards in function heads
import MyApp.Guards
def create(%{age: age, name: name})
when is_valid_age(age) and is_non_empty_string(name) do
{:ok, %User{age: age, name: name}}
end
Guards on struct fields โ structs are maps, so dot-access works in guards (AshAuthentication pattern):
# Check a single field without destructuring the whole struct
def tokens_required?(strategy) when strategy.sign_in_tokens_enabled?, do: true
def tokens_required?(strategy) when is_map(strategy.resettable), do: true
def tokens_required?(_), do: false
# Combine with is_map_key for optional fields
def has_feature?(config) when is_map_key(config, :feature) and config.feature, do: true
def has_feature?(_), do: false
Use this when you care about one field of a large struct โ cleaner than pattern matching %MyStruct{field: value} when you don't need to bind other fields.
Allowed in guards: ==, !=, ===, !==, <, >, <=, >=, and, or, not, in, +, -, *, /, abs, div, rem, round, trunc, is_atom, is_binary, is_integer, is_float, is_list, is_map, is_tuple, is_nil, is_boolean, is_number, is_pid, is_struct, is_function, byte_size, elem, hd, tl, length, map_size, tuple_size, is_map_key
NOT allowed: Custom function calls, String.length/1, Enum.* โ only the built-in list above. Also allowed: Bitwise.&&&, Bitwise.|||, Bitwise.bsl, Bitwise.bsr (import Bitwise first).
# Guards in case (not just function heads)
case value do
n when is_integer(n) and n > 0 -> :positive
n when is_integer(n) -> :non_positive
f when is_float(f) -> :float
s when is_binary(s) -> :string
_ -> :other
end
# Nested destructuring โ extract from nested maps/structs
def get_user_city(%{address: %{city: city}}), do: {:ok, city}
def get_user_city(_), do: {:error, :no_city}
# match?/2 โ boolean pattern check without extracting values
Enum.filter(items, &match?({:ok, _}, &1))
if match?({:error, _}, result), do: log_error(result)
match?({in_type, _} when in_type in [:in, :one_of], config[:type])
# case โ pattern match on single value
case fetch_user(id) do
{:ok, user} -> process(user)
{:error, :not_found} -> create_user(id)
{:error, reason} -> log_error(reason)
end
# cond โ multiple boolean conditions (no else-if chains!)
cond do
x > 10 -> :large
x > 5 -> :medium
x > 0 -> :small
true -> :zero_or_negative # Always end with true ->
end
Assertive Pattern Matching
Let functions crash on unexpected input โ makes bugs visible immediately:
# BAD - hides malformed input, returns wrong value
def get_value(string, key) do
parts = String.split(string, "&")
Enum.find_value(parts, fn pair ->
key_value = String.split(pair, "=")
Enum.at(key_value, 0) == key && Enum.at(key_value, 1)
end)
end
# GOOD - pattern match asserts structure, crashes on malformed input
def get_value(string, key) do
Enum.find_value(String.split(string, "&"), fn pair ->
[k, value] = String.split(pair, "=")
k == key && value
end)
end
# GOOD - extract only what guard needs, destructure in body
def drive(%User{age: age} = user) when age >= 18 do
%User{name: name, license: license} = user
"#{name} with license #{license} can drive"
end
Imperative to Elixir Translation
Collection Operations:
| Imperative | Elixir |
|---|
for (x of list) result.push(f(x)) | Enum.map(list, &f/1) |
for (x of list) if (p(x)) result.push(x) | Enum.filter(list, &p/1) |
let acc = init; for (...) acc = f(acc, x) | Enum.reduce(list, init, fn x, acc -> ... end) |
list.find(x => p(x)) | Enum.find(list, &p/1) |
list.some(x => p(x)) | Enum.any?(list, &p/1) |
list.flatMap(x => f(x)) | Enum.flat_map(list, &f/1) |
[...set] (deduplicate) | Enum.uniq(list) or Enum.uniq_by(list, &key/1) |
list.sort((a,b) => a.name - b.name) | Enum.sort_by(list, & &1.name) |
Object.groupBy(list, x => x.type) | Enum.group_by(list, & &1.type) |
_.countBy(list, f) | Enum.frequencies_by(list, &f/1) |
_.chunk(list, 3) | Enum.chunk_every(list, 3) |
_.partition(list, pred) | Enum.split_with(list, &pred/1) |
list.join(", ") | Enum.join(list, ", ") |
Math.max(...list) | Enum.max(list) |
list.reduce((a, b) => a + b, 0) | Enum.sum(list) |
Control Flow:
| Imperative | Elixir |
|---|
if/else if/else | Multi-clause function with pattern matching |
switch (x.type) | case x.type do ... end or multi-clause function |
if (x != null && x.active) | def f(%{active: true} = x) (pattern match) |
try { risky() } catch(e) { ... } | case risky() do {:ok, v} -> v; {:error, _} -> fallback end |
for (...) { if (done) break } | Enum.reduce_while(list, acc, fn x, acc -> {:cont/:halt, acc} end) |
while (cond) { ... } | Recursive function with guard or Stream.iterate/2 |
early return | Pattern match + multiple function clauses |
Data Mutation:
| Imperative | Elixir |
|---|
obj.key = value | %{map | key: value} or Map.put(map, key, value) |
obj.a.b.c = value | put_in(obj, [:a, :b, :c], value) |
obj.count++ | update_in(obj, [:count], & &1 + 1) |
delete obj.key | Map.delete(map, key) |
list.push(item) | [item | list] (prepend โ O(1)) |
list.pop() | [head | tail] = list (pattern match) |
set.add(item) | MapSet.put(set, item) |
str += chunk in loop | IO list: [chunk | acc], then IO.iodata_to_binary/1 |
"Hello " + name + "!" | "Hello #{name}!" (interpolation) |
result = ""; for (x) result += f(x) | Enum.map_join(items, ", ", &f/1) |
x ?? default | x || default (beware: also catches false) |
x?.y?.z | get_in(x, [:y, :z]) |
The With Statement
def create_order(user_id, product_id, qty) do
with {:ok, user} <- Users.get(user_id),
{:ok, product} <- Products.get(product_id),
:ok <- validate_stock(product, qty),
{:ok, order} <- insert_order(user, product, qty) do
{:ok, order}
else
{:error, :not_found} -> {:error, "Resource not found"}
{:error, :insufficient_stock} -> {:error, "Insufficient stock"}
error -> error
end
end
For Comprehensions (Key Patterns)
# Pattern matching in generators โ non-matching silently skipped
for {:ok, val} <- results, do: val
# Collect into different types
for {k, v} <- [a: 1, b: 2], into: %{}, do: {k, v * 2}
# Binary comprehensions
for <<byte <- string>>, into: "", do: process_byte(byte)
# Accumulator control with reduce:
for line <- lines, reduce: %{totals: 0, count: 0} do
acc -> %{acc | totals: acc.totals + parse(line), count: acc.count + 1}
end
When to Use Each Construct
| Construct | Use When |
|---|
| Multiple clauses | Different behaviors for different input patterns |
| Pattern matching | Extracting/matching data structure shapes |
| Guards | Constraints beyond pattern matching |
with | Chaining operations that may fail |
for | Iteration with filtering, multiple sources, or custom collection |
case | Pattern matching on single value |
cond | Multiple boolean conditions |
if | Simple true/false check |
Pipeline Best Practices
# GOOD: Multi-step transformation โ each step adds meaning
order
|> calculate_subtotal()
|> apply_discount(coupon)
|> add_tax(state)
|> round_to_cents()
# BAD: Single step โ just call the function
name |> String.upcase()
# GOOD:
String.upcase(name)
# Design functions data-first so they compose in pipelines
defmodule StringHelpers do
def normalize(string) do
string
|> String.trim()
|> String.downcase()
|> String.replace(~r/\s+/, " ")
end
def truncate(string, max) when byte_size(string) <= max, do: string
def truncate(string, max), do: String.slice(string, 0, max - 3) <> "..."
end
input
|> StringHelpers.normalize()
|> StringHelpers.truncate(100)
# Break long pipelines into named private functions
def process_orders(orders) do
orders
|> filter_valid()
|> calculate_totals()
|> apply_discounts()
|> generate_invoices()
end
defp filter_valid(orders) do
orders
|> Enum.filter(&valid_order?/1)
|> Enum.reject(&cancelled?/1)
end
defp calculate_totals(orders), do: Enum.map(orders, &%{&1 | total: calculate_order_total(&1)})
# Conditional steps โ use maybe_ helpers to keep pipeline flat
data
|> transform()
|> maybe_validate(opts[:validate])
|> finalize()
defp maybe_validate(data, true), do: validate(data)
defp maybe_validate(data, _), do: data
# Or with then/1 for inline conditionals
data
|> transform()
|> then(fn d -> if opts[:validate], do: validate(d), else: d end)
# tap/1 โ inspect without breaking the pipeline (returns input unchanged)
order
|> calculate_total()
|> tap(&Logger.debug("Total: #{&1}"))
|> apply_tax()
# Pipe into case โ natural end of a pipeline when you need to branch
# Common in Ash, Phoenix, and library code (AshStateMachine, Oban)
resource
|> lookup_transitions(action_name)
|> Enum.find(&match_transition?(&1, old_state, target))
|> case do
nil -> {:error, :no_matching_transition}
transition -> {:ok, apply_transition(transition)}
end
# Also works with with โ pipe builds the value, case branches on it
conn
|> fetch_session("user_token")
|> case do
nil -> assign(conn, :current_user, nil)
token -> assign(conn, :current_user, Accounts.get_user_by_token(token))
end
# Error pipeline with with โ short-circuit on first error
with {:ok, user} <- fetch_user(id),
{:ok, account} <- fetch_account(user),
{:ok, balance} <- check_balance(account, amount) do
{:ok, transfer(balance, amount)}
else
{:error, :not_found} -> {:error, "User not found"}
{:error, :insufficient} -> {:error, "Insufficient funds"}
end
Multi-Clause Anonymous Functions
# Different clauses in Enum callbacks โ pattern match + guards
Enum.reduce(events, %{}, fn
%{type: :credit, amount: amt}, acc -> Map.update(acc, :total, amt, &(&1 + amt))
%{type: :debit, amount: amt}, acc -> Map.update(acc, :total, -amt, &(&1 - amt))
_, acc -> acc
end)
# Task result handling
|> Enum.map(fn
{:ok, result} -> result
{:exit, :timeout} -> :timed_out
end)
cond with Variable Binding
Assign in condition, use in body โ useful for priority resolution:
cond do
val = Map.get(overrides, key) -> val
val = Map.get(config, key) -> val
true -> default
end
# Range/threshold branching
cond do
time > 1_000_000 -> "#{div(time, 1_000_000)}s"
time > 1_000 -> "#{div(time, 1_000)}ms"
true -> "#{time}ฮผs"
end
Pin Operator in Maps & Comprehensions
# Pin in map pattern โ match key from variable
key = :name
%{^key => value} = %{name: "Alice"} # value = "Alice"
# Pin in comprehension generators
target = 42
for %{id: ^target, data: data} <- records, do: data
# Pin in with clauses
expected = "admin"
with %{role: ^expected} <- get_user(id) do
:authorized
end
Deep dive: language-patterns.md โ nested destructuring, match?/2 edge cases,
multi-clause anonymous functions, binary comprehensions, pin operator advanced uses, cond with variable binding.
code-style.md โ .formatter.exs configuration, Credo checks catalog, readable code patterns,
pipeline readability, guard ordering, with-chain formatting, naming conventions.
Error Handling
ok/error Tuples
The standard Elixir convention for results. Use atoms for error types, structs/maps for rich errors.
# Return conventions โ be consistent within a context
{:ok, value} # Success with data
:ok # Success, no data (side-effect confirmation)
{:error, :not_found} # Typed failure (atom)
{:error, %Changeset{}} # Rich failure (struct with details)
{:error, {reason, details}} # Compound failure
# Pattern match with case
case Repo.fetch(User, id) do
{:ok, user} -> process(user)
{:error, :not_found} -> create_default(id)
{:error, reason} -> log_and_fail(reason)
end
# Bang (!) variants โ raise on error, used when failure is unexpected
user = Repo.get!(User, id) # Raises Ecto.NoResultsError
file = File.read!(path) # Raises File.Error
# Writing bang/non-bang pairs
def fetch_config(key) do
case lookup(key) do
nil -> {:error, :not_found}
val -> {:ok, val}
end
end
def fetch_config!(key) do
case fetch_config(key) do
{:ok, val} -> val
{:error, reason} -> raise "Config #{key} failed: #{reason}"
end
end
# Wrapping external results
with {:ok, resp} <- HTTPClient.get(url),
{:ok, body} <- Jason.decode(resp.body) do
{:ok, body}
end
# Returns the first {:error, _} from the chain automatically
# Multi-clause functions โ match directly on ok/error
def handle_result({:ok, user}), do: send_welcome(user)
def handle_result({:error, :not_found}), do: redirect_to_signup()
def handle_result({:error, _reason}), do: show_generic_error()
# Tagged tuples in with โ label each step for targeted error handling
with {:user, {:ok, user}} <- {:user, fetch_user(id)},
{:auth, :ok} <- {:auth, authorize(user, action)},
{:save, {:ok, result}} <- {:save, save(user)} do
{:ok, result}
else
{:user, {:error, _}} -> {:error, :user_not_found}
{:auth, {:error, _}} -> {:error, :unauthorized}
{:save, {:error, changeset}} -> {:error, changeset}
end
When to use which:
- Non-bang (
fetch/1) โ caller decides how to handle failure
- Bang (
fetch!/1) โ failure is a bug, crash early (scripts, seeds, known-good paths)
:ok atom โ fire-and-forget side effects (logging, cache writes, sending messages)
Let It Crash
Don't rescue unknown errors โ let supervision handle it. Reserve try/rescue for system boundaries only.
Error Kernel Design
Keep critical state in stable processes, volatile work in expendable ones:
children = [
MyApp.ConfigStore, # Stable kernel โ rarely crashes
MyApp.Repo,
{DynamicSupervisor, name: MyApp.WorkerSupervisor} # Volatile workers
]
Supervisor.start_link(children, strategy: :rest_for_one)
defexception Patterns
defmodule MyApp.NotFoundError do
defexception [:message, :resource, :id]
@impl true
def exception(opts) do
resource = Keyword.fetch!(opts, :resource)
id = Keyword.fetch!(opts, :id)
%__MODULE__{message: "#{resource} #{id} not found", resource: resource, id: id}
end
end
Deep dive: language-patterns.md โ defexception patterns (message/1, custom fields),
ok/error tuple conventions, let it crash philosophy, error kernel design (separate error-prone from critical
state), exit reason classification (:normal, :shutdown, {:shutdown, term}), with-chain error handling,
rescue vs catch, reraise/3.
Anti-Patterns to Avoid
Imperative Habits (Most Common LLM Mistakes)
# BAD: Enum.each to build result (returns :ok, not accumulated value)
result = []
Enum.each(items, fn item -> result = [process(item) | result] end)
# result is still [] โ rebinding doesn't work!
# GOOD: Use Enum.map
result = Enum.map(items, &process/1)
# BAD: if/else chain for structural dispatch
def handle(msg) do
if is_map(msg) and Map.has_key?(msg, :type) do
if msg.type == :error, do: handle_error(msg), else: handle_ok(msg)
end
end
# GOOD: Multi-clause functions
def handle(%{type: :error} = msg), do: handle_error(msg)
def handle(%{type: _} = msg), do: handle_ok(msg)
# BAD: Mutable accumulator thinking
count = 0
Enum.each(items, fn _ -> count = count + 1 end)
# count is still 0!
# GOOD: Enum.count or Enum.reduce
count = Enum.count(items)
count = Enum.reduce(items, 0, fn _, acc -> acc + 1 end)
# BAD: String concatenation in loops
Enum.reduce(rows, "", fn row, acc -> acc <> format(row) <> "\n" end)
# GOOD: IO lists
rows
|> Enum.map(fn row -> [format(row), ?\n] end)
|> IO.iodata_to_binary()
# BAD: try/rescue for expected failures
try do
user = Repo.get!(User, id)
rescue
Ecto.NoResultsError -> nil
end
# GOOD: ok/error pattern
case Repo.get(User, id) do
nil -> {:error, :not_found}
user -> {:ok, user}
end
Process & OTP Anti-Patterns
# BAD: GenServer as bottleneck for reads
def get(key), do: GenServer.call(__MODULE__, {:get, key})
# GOOD: Direct ETS access
def get(key), do: :ets.lookup(__MODULE__, key)
# BAD: Partial state update (crash between steps corrupts state)
def handle_call(:transfer, _from, state) do
state = update_in(state.account_a, &(&1 - 100))
external_api_call() # May crash here!
state = update_in(state.account_b, &(&1 + 100))
{:reply, :ok, state}
end
# GOOD: Atomic state update
def handle_call(:transfer, _from, state) do
:ok = external_api_call()
new_state =
state
|> update_in([:account_a], &(&1 - 100))
|> update_in([:account_b], &(&1 + 100))
{:reply, :ok, new_state}
end
Control Flow Anti-Patterns
# BAD: if/else instead of pattern matching
def status(user), do: if user.active, do: :active, else: :inactive
# GOOD
def status(%{active: true}), do: :active
def status(%{active: false}), do: :inactive
# BAD: Boolean parameters obscure intent
fetch_users(true)
# GOOD: Separate functions with clear names
fetch_active_users()
Pattern Matching Gotchas
# BAD: %{} matches ANY map, not just empty maps
def handle(%{}), do: :empty # Matches %{a: 1} too!
# GOOD: Guard for empty map
def handle(map) when map_size(map) == 0, do: :empty
def handle(map), do: :has_keys
# BAD: Atom keys don't match string keys (common with JSON/params)
%{name: name} = %{"name" => "Jo"} # MatchError!
# GOOD: Match with the correct key type
%{"name" => name} = params # External data uses string keys
%{name: name} = internal_map # Internal data uses atom keys
# BAD: Forgot pin โ variable rebinds instead of matching
expected = :ok
case result do
expected -> :matched # ALWAYS matches! expected rebinds to result
end
# GOOD: Pin to match against existing value
case result do
^expected -> :matched # Only matches if result == :ok
end
Library & API Design Anti-Patterns
# BAD: Non-bang function raises instead of returning error tuple
# Users expect deliver_now/1 to return {:ok, _} | {:error, _}
def deliver_now(email) do
if email.to == [] do
raise "no recipients" # Surprise! Non-bang function raises
end
# ...
end
# GOOD: Non-bang returns tuples, bang raises
def deliver_now(email) do
case validate_and_send(email) do
{:ok, result} -> {:ok, result}
{:error, _} = err -> err
end
end
def deliver_now!(email) do
case deliver_now(email) do
{:ok, result} -> result
{:error, reason} -> raise "Delivery failed: #{inspect(reason)}"
end
end
# BAD: Application.get_env in module body of a LIBRARY
# Captures value at compile time โ consumers can't configure after compilation
defmodule MyLib.Client do
@api_key Application.get_env(:my_lib, :api_key) # Baked in at compile time!
def call, do: request(@api_key)
end
# GOOD: Read at runtime for libraries
defmodule MyLib.Client do
def call do
api_key = Application.get_env(:my_lib, :api_key)
request(api_key)
end
end
# GOOD: For application code (not libraries), compile_env is fine
defmodule MyApp.Client do
@api_key Application.compile_env!(:my_app, :api_key) # OK โ you control the build
end
Rule of thumb: Libraries use Application.get_env at runtime. Applications can use Application.compile_env at compile time. The difference: library consumers configure after the library is compiled; application config is set before compilation.
Data Structure Anti-Patterns
# DANGEROUS: Atoms from user input (exhausts atom table ~1M limit)
String.to_atom(user_input)
Jason.decode!(json, keys: :atoms)
# SAFE: to_existing_atom or explicit mapping
String.to_existing_atom(user_input)
Jason.decode!(json, keys: :strings) # Default, safe
# BAD: String concatenation in loops (O(n^2) โ copies on every <>)
Enum.reduce(items, "", fn i, acc -> acc <> "#{i}\n" end)
# GOOD: IO lists (zero-copy accumulation)
items
|> Enum.map(&["Item: ", &1, "\n"])
|> IO.iodata_to_binary()
Deep dive: architecture-reference.md โ full anti-patterns catalog with BAD/GOOD pairs for control flow (if/else chains, boolean params), pattern matching gotchas (empty maps, atom/string keys, keyword list order, integer/float, pin operator, IEEE 754 -0.0), cross-type comparisons (term ordering surprises), data structures (atom exhaustion, string concat), processes & OTP (GenServer bottleneck, blocking callbacks, unbounded mailbox, unsupervised processes, Task.async in GenServer), performance (N+1 queries, list as lookup table)
Code Organization
Module Structure
defmodule MyApp.User do
@moduledoc "User management"
use Ecto.Schema
import Ecto.Changeset
alias MyApp.Repo
@derive {Jason.Encoder, only: [:id, :email]}
@type t :: %__MODULE__{}
schema "users" do
field :email, :string
end
@doc "Creates a user"
@spec create(map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
def create(attrs), do: # ...
defp validate(changeset), do: # ...
end
Order: @moduledoc, use/import/alias/require, module attributes, types, schema/struct, public functions with @doc/@spec, private functions.
Import Guidelines
# BAD: Broad import pulls entire module into namespace
import Bamboo.ApiError # Which functions come from here vs local?
# GOOD: alias for qualified calls (default choice)
alias Bamboo.ApiError
ApiError.build(response)
# GOOD: import with :only for specific functions
import Ecto.Changeset, only: [cast: 3, validate_required: 2]
# EXCEPTION: DSL/macro modules designed for full import are fine
import Ecto.Query # Provides from/2, where/3, select/3 etc. โ intended usage
import Ecto.Changeset # Provides cast/3, validate_*/2 etc. โ intended usage
import MyApp.Guards # Custom guard macros โ must be imported for guard clauses
When to use each:
| Strategy | When |
|---|
alias + qualified calls | Default โ always prefer this |
import ... only: | Need unqualified calls for readability (small set) |
Full import | DSL/macro modules designed for it (Ecto.Query, guards, test helpers) |
use | Module provides __using__ macro (Phoenix.Component, GenServer) |
Public vs Private Functions
Default to defp โ only promote to def when external callers need it.
| def (public) | defp (private) |
|---|
| Visibility | Callable from other modules | Only within defining module |
| Contract | Part of module API โ add @doc + @spec | Implementation detail โ refactor freely |
| Testing | Test directly | Test through public API only |
| Stability | Changing breaks callers | Changing is safe |
# Public API โ small, stable surface
defmodule MyApp.Accounts do
@doc "Registers a new user, sends welcome email."
@spec register(map()) :: {:ok, User.t()} | {:error, Changeset.t()}
def register(attrs) do
attrs
|> build_user()
|> validate_uniqueness()
|> insert_and_notify()
end
# Private โ all implementation details
defp build_user(attrs), do: User.changeset(%User{}, attrs)
defp validate_uniqueness(changeset), do: unique_constraint(changeset, :email)
defp insert_and_notify(changeset) do
with {:ok, user} <- Repo.insert(changeset) do
Mailer.send_welcome(user)
{:ok, user}
end
end
end
Guidelines:
- A module's public API should be as small as possible โ fewer public functions = easier to understand, test, and maintain
- Extract private helpers when logic is reused within the module or when a function does more than one thing
do_ prefix for recursive private helpers of a public function: def transform(list) โ defp do_transform(list, acc)
maybe_ prefix for conditional operations: defp maybe_notify(user, true), defp maybe_notify(_user, false)
- Functions called from other modules must be
def โ if you find yourself wanting to call defp from outside, rethink the module boundary
- Context modules (Phoenix contexts) are the public API for a domain โ keep controller-facing functions
def, keep query-building and validation helpers defp
Naming
- Modules: PascalCase (
MyApp.UserController)
- Functions/Variables: snake_case (
find_user_by_email)
- Predicates: End with
? (valid?, empty?)
- Dangerous functions: End with exclamation mark (
delete๏ผ, fetch๏ผ)
- Atoms: snake_case (
:user_not_found)
Application Architecture
Rules for Application Architecture (LLM)
- ALWAYS consult this section, architecture-reference.md, and OTP Patterns when planning new Elixir projects, major features, or refactoring architecture.
- ALWAYS organize domain logic into context modules โ public API functions that encapsulate queries, validations, and side effects.
- ALWAYS start with a single Mix app โ only split into umbrella/poncho when you have clear, independent deployment or team boundaries
- NEVER create circular dependencies between contexts
- ALWAYS define behaviours for integration boundaries โ external APIs, payment gateways, notification services
- ALWAYS order supervision tree children by dependency โ infrastructure first, domain next, endpoints last
- NEVER expose internal data structures outside their context
- PREFER protocols when you need polymorphism across types from different contexts
- ALWAYS use
:one_for_all strategy when grouping tightly-coupled processes (Registry + DynamicSupervisor)
- NEVER put business logic in GenServer callbacks โ delegate domain logic to pure functions
- ALWAYS design dependency direction inward โ outer layers depend on inner, never reverse
- PREFER pure functions over processes โ use GenServer only when you need shared mutable state, serialized access, or scheduled work
- ALWAYS use
in_umbrella: true for sibling dependencies in umbrella apps
- ALWAYS design for replaceability. Use behaviours at integration points.
- PREFER small, focused contexts over large ones. ~500 lines max per context.
Configuration
# config/runtime.exs โ secrets and env vars
import Config
if config_env() == :prod do
config :my_app, MyAppWeb.Endpoint,
url: [host: System.get_env("PHX_HOST", "example.com"), port: 443, scheme: "https"],
secret_key_base: System.fetch_env!("SECRET_KEY_BASE")
config :my_app, MyApp.Repo,
url: System.fetch_env!("DATABASE_URL"),
pool_size: String.to_integer(System.get_env("POOL_SIZE", "10"))
end
Rule: Never call System.get_env/1 in compile-time config files for values that vary per deployment. Use runtime.exs.
Layout Decision Guide
| Signal | Layout |
|---|
| Single team, one deployable | Single app with contexts |
| Need hard compile-time boundaries | Umbrella |
| Different teams, shared config OK | Umbrella |
| Different dependency versions needed | Poncho |
| "Should I split?" uncertainty | Don't split โ use contexts |
Application Supervision Tree
defmodule MyApp.Application do
use Application
@impl true
def start(_type, _args) do
children = [
MyAppWeb.Telemetry, # 1. Instrumentation
MyApp.Repo, # 2. Database
{Phoenix.PubSub, name: MyApp.PubSub}, # 3. PubSub
{Task.Supervisor, name: MyApp.TaskSupervisor},
MyApp.WorkerRegistry, # 5. Registry before DynamicSupervisor
MyApp.WorkerSupervisor, # 6. Dynamic workers
MyAppWeb.Endpoint # 7. HTTP last
]
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end
end
When to refactor from flat to nested: The flat :one_for_one tree above is fine initially (it's the Phoenix default). Once you have processes that depend on other processes' state, group them under sub-supervisors with the right strategy. Real-world example โ Postgrex uses :rest_for_one to ensure Registry starts before DynamicSupervisor:
# AFTER: Dependencies between children โ extract sub-supervisor modules
def start(_type, _args) do
children = [
MyAppWeb.Telemetry,
MyApp.InfrastructureSupervisor, # :rest_for_one โ Repo before PubSub
MyApp.DomainSupervisor, # :rest_for_one โ ETS owner before listeners
MyApp.WorkerPoolSupervisor, # :one_for_all โ Registry + DynamicSupervisor
MyAppWeb.Endpoint
]
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end
# Each sub-supervisor is a standard Supervisor module with its own strategy:
defmodule MyApp.WorkerPoolSupervisor do
use Supervisor
def start_link(arg), do: Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
def init(_arg) do
children = [
{Registry, keys: :unique, name: MyApp.WorkerRegistry},
{DynamicSupervisor, name: MyApp.WorkerSupervisor}
]
Supervisor.init(children, strategy: :one_for_all)
end
end
Rule of thumb: If child B's state becomes invalid when child A restarts, group A and B under :rest_for_one (A before B). If A and B are tightly coupled peers, use :one_for_all. Keep :one_for_one at the top level for genuinely independent sub-trees.
Full supervision patterns: otp-reference.md and otp-examples.md
Context Modules โ Domain Boundaries
Group related functionality into context modules โ each is a public API boundary (DDD bounded context). This applies to any Elixir application, not just Phoenix.
defmodule MyApp.Catalog do
@moduledoc "Product catalog โ public API for all product operations."
alias MyApp.Catalog.{Product, PriceCalculator}
# --- Public API (the only functions other modules should call) ---
# defdelegate โ pure pass-through, zero overhead, keeps context as clean facade
defdelegate get_product!(id), to: Product, as: :fetch!
defdelegate create_product(attrs), to: Product, as: :create
# Wrapper function โ when you need added logic (logging, auth, transformation)
def calculate_price(product, qty) do
PriceCalculator.total(product, qty)
|> tap(&Logger.debug("Price calculated: #{&1}"))
end
end
defdelegate vs wrapper function:
# BAD โ wrapper that just calls through (unnecessary indirection)
def get_product!(id), do: Product.fetch!(id)
# GOOD โ defdelegate for pure pass-through
defdelegate get_product!(id), to: Product, as: :fetch!
# GOOD โ wrapper when you need to add logic
def create_product(attrs) do
attrs
|> Product.create()
|> tap(fn {:ok, p} -> broadcast({:product_created, p}); _ -> :ok end)
end
Internal modules are private โ never call them from outside the context:
defmodule MyApp.Catalog.PriceCalculator do
@moduledoc false # Internal to Catalog context
def total(product, qty), do: Decimal.mult(product.price, qty)
end
Cross-context communication โ always go through public API:
defmodule MyApp.Ordering do
alias MyApp.Catalog # Depend on context, not internals
def place_order(product_id, qty) do
product = Catalog.get_product!(product_id) # GOOD
# MyApp.Catalog.PriceCalculator.total(product, qty) # BAD โ bypasses boundary
total = Catalog.calculate_price(product, qty)
do_place_order(product, qty, total)
end
end
When to split vs merge: Split when different domain/team/data lifecycle. Merge when shared aggregate root or constant cross-calls. When unsure, keep separate โ merging is easier than splitting.
Behaviours as context contracts โ swap implementations without changing callers:
defmodule MyApp.PaymentProvider do
@callback charge(Decimal.t(), String.t()) :: {:ok, map()} | {:error, term()}
@callback refund(String.t()) :: :ok | {:error, term()}
end
defmodule MyApp.Payments.Stripe do
@behaviour MyApp.PaymentProvider
@impl true
def charge(amount, token), do: # Stripe API...
@impl true
def refund(charge_id), do: # Stripe refund...
end
# Context facade โ callers use MyApp.Payments, never know which provider
defmodule MyApp.Payments do
@provider Application.compile_env(:my_app, :payment_provider, MyApp.Payments.Stripe)
defdelegate charge(amount, token), to: @provider
defdelegate refund(charge_id), to: @provider
end
Internal organization โ the context module is the boundary, internals are free-form:
lib/my_app/catalog/
โโโ product.ex # Data + persistence (internal)
โโโ category.ex # Data + persistence (internal)
โโโ price_calculator.ex # Pure business logic (internal)
โโโ import_worker.ex # Background processing (internal)
Deep dive โ ALWAYS read for architecture planning and refactoring:
architecture-reference.md โ project layouts (single app, umbrella, poncho
with decision guide), Phoenix contexts (public API design, cross-context communication), layered architecture
(domain/service/web), pipeline architecture, behaviours as layer contracts, protocols as boundaries,
configuration (compile-time vs runtime, when to use compile_env vs fetch_env!), refactoring guide, component reuse.
Behaviours, Callbacks & @impl
Rules for Behaviours (LLM)
- ALWAYS use
@impl true on every callback implementation โ catches typos and missing callbacks at compile time
- Once you use
@impl on ANY callback, you MUST use it on ALL callbacks โ the compiler warns about inconsistency
- Use
@impl BehaviourModule (not @impl true) when implementing multiple behaviours with overlapping callback names
- ALWAYS name parameters in
@callback specs โ e.g., key :: String.t() not just String.t() โ serves as documentation
- NEVER use behaviour inheritance โ compose multiple small behaviours instead (Ecto adapter pattern)
- ALWAYS pair
defoverridable with @behaviour when providing defaults in __using__
- Use behaviours for module-level polymorphism (adapters, strategies); use protocols for data-level polymorphism (dispatch on first argument's type)
- Handle
@optional_callbacks at call sites with function_exported?/3 โ optional means the function may not exist
Key Patterns
# Defining a behaviour
defmodule MyApp.Storage do
@callback fetch(key :: String.t()) :: {:ok, term()} | {:error, :not_found}
@callback store(key :: String.t(), value :: term()) :: :ok | {:error, term()}
@optional_callbacks [store: 2]
end
# Implementing with @impl
defmodule MyApp.Storage.ETS do
@behaviour MyApp.Storage
@impl true
def fetch(key), do: ...
@impl true
def store(key, value), do: ...
end
# Adapter pattern โ runtime dispatch via config
defmodule MyApp.Mailer.Dispatcher do
def send(to, subject, body) do
impl = Application.get_env(:my_app, :mailer, MyApp.Mailer.SMTP)
impl.send_email(to, subject, body)
end
end
Behaviour vs Protocol Decision
| Behaviour | Protocol |
|---|
| Dispatch on | Module identity (passed as config) | Data type of first argument |
| Testing | Works with Mox | Does not work with Mox |
| When to use | External services, adapters, strategies | Type-specific formatting, encoding, iteration |
| Example | HTTPClient, Mailer, Storage | Jason.Encoder, Enumerable, Inspect |
Deep dive: language-patterns.md โ multi-behaviour composition (Ecto adapter pattern),
dynamic dispatch patterns, DSL recipe (using + accumulated attributes + @before_compile),
behaviour introspection (module_info, info), @optional_callbacks with function_exported?/3,
defoverridable patterns, testing behaviours with Mox.
Protocols
Rules for Protocols (LLM)
- PREFER single-function protocols โ the vast majority of stdlib/library protocols define exactly 1 function
- ALWAYS put
@derive BEFORE defstruct (or schema) โ the compiler warns if it comes after
- NEVER implement
for: Map expecting it to match structs โ structs dispatch through struct_impl_for/1, not the Map implementation
- Use
@fallback_to_any true only when there IS a sensible default
- ALWAYS implement all 3 commands in Collectable:
{:cont, elem}, :done, :halt
- For Enumerable, return
{:error, __MODULE__} from count/1, member?/2, slice/1 when O(1) isn't possible
- Use
Protocol.derive/3 for structs you don't own
- Guard
for: BitString implementations with is_binary/1
Key Patterns
# Define protocol
defprotocol MyApp.Renderable do
@spec render(t()) :: iodata()
def render(term)
end
# Implement for struct
defimpl MyApp.Renderable, for: MyApp.Widget do
def render(%{html: html}), do: html
end
# @derive for common protocols
defmodule User do
@derive {Jason.Encoder, only: [:id, :name, :email]}
@derive {Inspect, only: [:id, :name]}
defstruct [:id, :name, :email, :password_hash]
end
# @fallback_to_any โ sensible default for all types
defprotocol MyApp.Blank do
@fallback_to_any true
def blank?(term)
end
defimpl MyApp.Blank, for: Any do
def blank?(_), do: false
end
Deep dive: language-patterns.md โ making derivable protocols, Enumerable/Collectable
implementation, struct dispatch precedence (struct impl beats Map impl), protocol introspection
(Protocol.consolidated?/1, impl_for/1), consolidation behavior differences in dev vs prod,
Protocol.derive/3 for structs you don't own, multi-type implementation syntax.
OTP Patterns
Rules for OTP Code (LLM)
- ALWAYS consult this section and Application Architecture when adding processes or restructuring supervision.
- ALWAYS supervise processes. Never use bare
spawn/spawn_link for long-running work.
- ALWAYS provide a client API wrapping GenServer calls/casts.
- PREFER call over cast. Use cast only for fire-and-forget where losing messages is acceptable.
- NEVER block GenServer callbacks with I/O, HTTP, or DB queries. Offload to
Task or use handle_continue.
- PREFER
{:continue, _} for post-init work over crashing init/1. Exception: use send(self(), :init_work) when client messages should interleave with initialization (pool/cache pattern โ NimblePool does this so the pool stays responsive during worker creation).
- NEVER store large data (>100KB) in process state. Use ETS for large/shared data.
- ALWAYS set explicit timeouts on
GenServer.call.
- PREFER Registry over
:global for process discovery within a single node.
- PREFER letting supervision handle GenServer failures over catching
:exit in business logic. Use catch :exit only at system boundaries โ calling processes you don't own, optional services, or network dispatch (as LiveView, Oban, and db_connection do). See the catch :exit pattern in section 2 above.
- PREFER DynamicSupervisor + Registry over named GenServers for per-entity processes.
- ALWAYS do atomic state updates. Compute new state fully, then return.
- ALWAYS implement
format_status/1 on GenServers that hold sensitive data.
- ALWAYS use the same naming mechanism to call a process as was used to register it. A process registered via
{:via, Registry, {MyReg, id}} will NOT be found by GenServer.call(:id, msg) โ the error says "no process" even though the process is alive. Use the library's via() helper or the full via tuple.
- ALWAYS use explicit timeouts for cross-GenServer calls during
handle_continue โ the default 5000ms GenServer.call timeout is often too short for initialization chains. Ensure called processes are started earlier in the supervision tree.
When to Use Which OTP Construct
| Need | Use |
|---|
| Stateful request/response | GenServer |
| Explicit state machine with transitions | :gen_statem |
| One-off parallel work | Task / Task.async_stream |
| Dynamic per-entity processes | DynamicSupervisor + Registry |
| Reduce single-process bottleneck | PartitionSupervisor |
| Pub/sub within node | Registry with :duplicate keys |
| High-read shared data | ETS (:public, read_concurrency: true) |
| Rarely-changing global config | :persistent_term |
| Atomic counters/gauges | :counters / :atomics |
| Backpressure data pipeline | GenStage / Broadway |
| Periodic scheduled work | Process.send_after loop or Oban |
GenServer
defmodule Counter do
use GenServer
def start_link(initial \\ 0), do: GenServer.start_link(__MODULE__, initial, name: __MODULE__)
def increment, do: GenServer.call(__MODULE__, :increment)
def get, do: GenServer.call(__MODULE__, :get)
@impl true
def init(initial), do: {:ok, initial}
@impl true
def handle_call(:increment, _from, state), do: {:reply, state + 1, state + 1}
def handle_call(:get, _from, state), do: {:reply, state, state}
end
Supervisor Strategies
| Strategy | Restarts | Use when |
|---|
:one_for_one | Only failed child | Children are independent (most common) |
:rest_for_one | Failed + all started after it | Later children depend on earlier ones |
:one_for_all | All children | Children are tightly coupled |
# :one_for_one โ independent workers (web endpoints, job processors)
children = [
MyApp.Repo,
MyApp.Cache,
{MyApp.Worker, name: :worker_a},
{MyApp.Worker, name: :worker_b}
]
Supervisor.start_link(children, strategy: :one_for_one)
# :rest_for_one โ ordered dependencies (Registry before DynamicSupervisor)
children = [
{Registry, keys: :unique, name: MyApp.Registry}, # 1st: must exist
{DynamicSupervisor, name: MyApp.WorkerSup}, # 2nd: uses Registry
{MyApp.WorkerStarter, registry: MyApp.Registry} # 3rd: uses both
]
Supervisor.start_link(children, strategy: :rest_for_one)
# :one_for_all โ tightly coupled (producer + consumer, writer + reader)
children = [
{MyApp.EventBus, name: :event_bus},
{MyApp.EventLogger, bus: :event_bus},
{MyApp.EventNotifier, bus: :event_bus}
]
Supervisor.start_link(children, strategy: :one_for_all)
# Restart intensity โ max 3 restarts in 5 seconds before supervisor gives up
Supervisor.start_link(children,
strategy: :one_for_one,
max_restarts: 3,
max_seconds: 5
)
DynamicSupervisor + Registry
defmodule MyApp.WorkerRegistry do
def child_spec(_), do: Registry.child_spec(keys: :unique, name: __MODULE__)
def via(id), do: {:via, Registry, {__MODULE__, id}}
end
defmodule MyApp.Worker do
use GenServer
def start_link({id, opts}), do: GenServer.start_link(__MODULE__, opts, name: MyApp.WorkerRegistry.via(id))
def call(id, msg), do: GenServer.call(MyApp.WorkerRegistry.via(id), msg)
end
# Registry MUST start before DynamicSupervisor
children = [MyApp.WorkerRegistry, MyApp.WorkerSupervisor]
Registry naming trap โ BAD/GOOD:
# BAD: Calling a Registry-registered process by raw atom name
# The process IS alive, but :my_worker doesn't resolve to it
GenServer.call(:my_worker, :ping)
# => ** (EXIT) no process associated with the given name
# GOOD: Use the same via tuple the process registered with
GenServer.call({:via, Registry, {MyApp.Registry, :my_worker}}, :ping)
# BEST: Library provides a via() helper โ always use it
GenServer.call(MyApp.WorkerRegistry.via(:my_worker), :ping)
Initialization Chains (handle_continue)
When handle_continue calls other GenServers, consider ordering and timeouts:
# BAD: handle_continue calls another GenServer with default 5s timeout
def handle_continue(:setup, state) do
# If IndexServer is slow to start or in its own handle_continue, this times out
:ok = IndexServer.build(state.name)
{:noreply, %{state | ready: true}}
end
# GOOD: Explicit timeout + supervision ordering ensures the target is ready
# 1. Ensure IndexServer starts BEFORE this process in the supervision tree
# 2. Use explicit timeout for potentially slow cross-process operations
def handle_continue(:setup, state) do
:ok = IndexServer.build(state.name, _timeout = 30_000)
{:noreply, %{state | ready: true}}
end
Key: If process A's handle_continue depends on process B, use :rest_for_one strategy
with B listed before A. Both processes may be in their own handle_continue simultaneously โ
the call will block until B's callback completes.
Task.Supervisor Patterns
# async: LINKED โ task crash kills caller
Task.Supervisor.async(MyApp.TaskSupervisor, fn -> work() end)
# async_nolink: NOT linked โ handle :DOWN in GenServer
task = Task.Supervisor.async_nolink(MyApp.TaskSupervisor, fn -> work() end)
# Handle task result in GenServer
def handle_info({ref, result}, %{task_ref: ref} = state) do
Process.demonitor(ref, [:flush])
{:noreply, %{state | task_ref: nil, result: result}}
end
def handle_info({:DOWN, ref, :process, _pid, reason}, %{task_ref: ref} = state) do
{:noreply, %{state | task_ref: nil}}
end
ETS for Fast Reads
:ets.new(:cache, [:named_table, :public, read_concurrency: true])
:ets.insert(:cache, {key, value})
:ets.lookup(:cache, key) # [{key, value}] or []
# Atomic counters โ no race conditions
:ets.new(:stats, [:named_table, :public, write_concurrency: true])
:ets.update_counter(:stats, :requests, {2, 1}, {:requests, 0}) # increment by 1, default 0
Use ETS instead of GenServer for read-heavy workloads to avoid bottlenecks. Use write_concurrency: true when multiple processes write to different keys.
Call vs Cast Decision
| Use Call when... | Use Cast when... |
|---|
| Need response or confirmation | Fire-and-forget (logging, metrics) |
| Data consistency critical | Notifications, broadcasts |
| Want natural backpressure (caller blocks) | High throughput needed |
| Failures should propagate to caller | Failures can be handled internally |
Deep dive: otp-reference.md โ GenServer/gen_statem callback signatures, child specs,
supervisor strategies, ETS match specs, process registry patterns, :persistent_term, :counters/:atomics,
:sys debugging, node operations, RPC/ERPC, release commands.
otp-examples.md โ rate limiter, connection state machine, worker pool, cache, circuit breaker,
graceful shutdown, distribution patterns. otp-advanced.md โ GenStage, Flow, Broadway,
hot code upgrades.
Code Style, Formatter & Readability
Rules for Code Style (LLM)
- ALWAYS run
mix format โ never hand-format code the formatter handles (spacing, indentation, parens, line breaks)
- ALWAYS configure
.formatter.exs with import_deps for libraries you use โ this pulls their locals_without_parens settings
- NEVER fight the formatter โ if it reformats your code unexpectedly, restructure your code, don't add workarounds
- ALWAYS use pipes for 2+ transformation steps โ single function calls don't need pipes
- NEVER pipe into anonymous functions โ use
then/1 or extract a named function
- ALWAYS order pattern match clauses specific โ general โ guards and literal matches before wildcards
- ALWAYS place the success/happy path first in case/with clauses โ error handling after
- PREFER guards over if/cond for type and value validation at function boundaries
- ALWAYS use
@doc false (not omitting @doc) to explicitly mark internal public functions
- NEVER use semicolons to separate expressions โ use line breaks
- ALWAYS write one pipe per line in multi-step pipelines โ never multiple pipes on one line
- PREFER direct function calls over single-element pipes:
String.upcase(name) not name |> String.upcase()
Key BAD/GOOD
# BAD: Single step pipe
name |> String.upcase()
# GOOD: Direct call
String.upcase(name)
# BAD: Multi-step pipeline on one line
list |> Enum.map(&process/1) |> Enum.sum()
# GOOD: One pipe per line, always
list
|> Enum.map(&process/1)
|> Enum.sum()
# BAD: Piping to anonymous function
data |> (fn x -> x * 2 end).()
# GOOD: Use then/1
data |> then(&(&1 * 2))
# BAD: Missing import_deps
locals_without_parens: [get: 3, post: 3, field: 2]
# GOOD: Let libraries provide their own config
import_deps: [:phoenix, :ecto, :ecto_sql, :plug]
# BAD: import/alias scattered and ungrouped
import Ecto.Query
alias MyApp.Repo
import Ecto.Changeset
alias MyApp.User
# GOOD: Grouped and alphabetized within groups
import Ecto.Changeset
import Ecto.Query
alias MyApp.{Repo, User}
# BAD: Deeply nested conditionals
if valid?(data) do
if authorized?(user) do
process(data)
else
{:error, :unauthorized}
end
else
{:error, :invalid}
end
# GOOD: with-chain flattens the nesting
with :ok <- validate(data),
:ok <- authorize(user) do
process(data)
end
# BAD: case that just passes errors through unchanged
case Native.some_call(args) do
{:ok, result} -> {:ok, transform(result)}
{:error, _} = err -> err
end
# GOOD: with handles error passthrough implicitly
with {:ok, result} <- Native.some_call(args) do
{:ok, transform(result)}
end
# BAD: identity case โ returns its own input unchanged
mode = case config.mode do
:async -> :async
:sync -> :sync
end
# GOOD: assign directly (validation happened earlier or use guard)
mode = config.mode
# BAD: magic number defaults scattered in struct and constructor
defstruct [timeout: 5_000, max_retries: 3]
config = %__MODULE__{timeout: Keyword.get(opts, :timeout, 5_000)}
# GOOD: module attribute = single source of truth
@default_timeout 5_000
defstruct [timeout: @default_timeout]
config = %__MODULE__{timeout: Keyword.get(opts, :timeout, @default_timeout)}
Deep dive: code-style.md โ .formatter.exs configuration (line_length, locals_without_parens,
plugins, migration options since 1.18+), Credo check catalog (high-priority, readability, consistency),
module organization order (useโbehaviourโimportโaliasโrequireโattributesโtypesโfunctions), function
ordering (public with helpers vs all-public-first), multi-clause formatting (when to extract),
string sigil selection table (~s, ~S, ~r, ~w, ~c, heredoc), defdelegate vs wrapper guidance,
idiomatic formatter readability (intermediate variables, natural break points, then/1, whitespace
paragraphs), pipeline readability, guard clause ordering, with-chain formatting, variable naming.
Stream, Enum, and the Enumerable Protocol
When to Use Stream vs Enum
Use Stream when | Use Enum when |
|---|
| Large/infinite data | Small collections (< 10K) |
| File processing line-by-line | Result needed immediately |
| Multiple transformations on large data | Simple map/filter/reduce |
| Need to limit (take first N) | Need all results |
# Stream for large files โ lazy, processes one line at a time
File.stream!("huge.csv")
|> Stream.map(&String.trim/1)
|> Stream.reject(&(&1 == ""))
|> Stream.take(1000)
|> Enum.to_list()
# Stream.iterate โ infinite sequence from seed
Stream.iterate(1, &(&1 * 2)) |> Enum.take(10) # [1, 2, 4, 8, 16, ...]
# Stream.unfold โ generate from state, stop with nil
Stream.unfold(10, fn
0 -> nil # Stop
n -> {n, n - 1} # {emit, next_state}
end) |> Enum.to_list() # [10, 9, 8, ..., 1]
# Stream.resource โ acquire/generate/cleanup (DB cursors, API pagination)
Stream.resource(
fn -> fetch_page(1) end, # init: first page
fn
{[], _page} -> {:halt, nil} # no more items โ stop
{[h | t], page} -> {[h], {t, page}} # emit one item
{_, page} -> {[], fetch_page(page + 1)} # fetch next page (not shown as practical)
end,
fn _ -> :ok end # cleanup
)
# Endless generators โ infinite streams consumed lazily
random_floats = Stream.repeatedly(fn -> :rand.uniform() end)
Enum.take(random_floats, 5) # [0.234, 0.891, 0.112, ...]
ids = Stream.iterate(1, &(&1 + 1)) # 1, 2, 3, 4, ... forever
timestamps = Stream.repeatedly(fn -> DateTime.utc_now() end)
# Combine infinite streams with data
orders
|> Stream.zip(ids) # {order, id} pairs
|> Enum.take(100) # materialize only what you need
# Pipeline: chain Stream, terminate with Enum
orders
|> Stream.filter(&(&1.status == :pending))
|> Stream.map(&calculate_total/1)
|> Stream.reject(&(&1.total == 0))
|> Enum.sum() # Enum call triggers the lazy pipeline
Deep dive: language-patterns.md โ Enumerable protocol implementation (reduce/3,
count/1, member?/2, slice/1), stream creators (iterate, unfold, resource), stream transforms (chunk_while,
transform), consuming streams safely, practical stream patterns (file processing, pagination, rate limiting),
Collectable protocol, lazy evaluation gotchas.
Recursion Patterns
Rule: Prefer Enum functions. Use recursion only when you need early termination with complex conditions, multiple accumulators, or tree/graph traversal. Use Stream for infinite/generative sequences.
Tail call optimization (TCO): When a function's last expression is a call to itself, the BEAM reuses the stack frame โ constant memory, no stack overflow regardless of depth.
- Operations after the call break TCO โ
[h | func(t)] is NOT tail-recursive (cons happens after return). Accumulate and reverse instead.
try/rescue/catch blocks prevent TCO โ the BEAM keeps the frame for exception handling.
- Stack traces lose intermediate frames โ reused frames mean you won't see every recursion step in crash traces.
case, if, with around the call are fine โ TCO applies as long as the recursive call is last in whichever branch executes.
# Accumulator pattern (tail-recursive)
def sum(list), do: sum(list, 0)
defp sum([], acc), do: acc
defp sum([head | tail], acc), do: sum(tail, acc + head)
# Build-and-reverse (building a list)
# Prepending [new | list] is O(1). Appending list ++ [new] is O(n).
# Build reversed, reverse once at the end.
def transform(list), do: do_transform(list, [])
defp do_transform([], acc), do: Enum.reverse(acc)
defp do_transform([h | t], acc), do: do_transform(t, [process(h) | acc])
# Tree traversal (recursion is the right tool)
def flatten_tree(%{children: children, value: value}) do
[value | Enum.flat_map(children, &flatten_tree/1)]
end
def flatten_tree(%{value: value}), do: [value]
# Infinite generation - use Stream, not raw recursion
def fibonacci do
Stream.unfold({0, 1}, fn {a, b} -> {a, {b, a + b}} end)
end
# Enum.take(fibonacci(), 10) => [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
Data Structures & Access Patterns
Data Structure Selection Guide
| Need | Use | Why |
|---|
| Key-value, known keys | Struct (%Mod{}) | Compile-time checks, dot access |
| Key-value, dynamic keys | Map (%{}) | O(log n) lookup, any key type |
| Ordered collection | List ([]) | Linked list, O(1) prepend, pattern match head |
| Fixed-size grouping | Tuple ({}) | O(1) element access, commonly 2-4 elements |
| Function options | Keyword list ([k: v]) | Ordered, duplicate keys allowed, last-arg sugar |
| Unique values | MapSet | Set operations (union, intersection, difference) |
| FIFO queue | :queue | O(1) amortized enqueue/dequeue |
| Fast concurrent reads | ETS | In-memory table, cross-process access |
Structs โ Key Pattern
defmodule User do
@enforce_keys [:email]
defstruct [:name, :email, age: 18, role: :user]
@type t :: %__MODULE__{name: String.t() | nil, email: String.t(), age: non_neg_integer(), role: atom()}
# Constructor โ validate and set defaults in one place
def new(attrs) when is_map(attrs) do
struct!(__MODULE__, attrs) # Raises on unknown keys
end
# Named update functions โ express intent, hide field names
def promote(%__MODULE__{} = user), do: %{user | role: :admin}
def rename(%__MODULE__{} = user, name), do: %{user | name: name}
end
# Update syntax โ raises on unknown keys (compile-time safety)
%{user | name: "New Name"}
# Pattern match on struct type โ dispatch or validate
def greet(%User{name: name}), do: "Hello, #{name}"
def greet(%Admin{name: name}), do: "Welcome back, #{name}"
# Pipeline of struct transforms
order
|> Order.validate_inventory()
|> Order.apply_discounts()
|> Order.calculate_tax()
|> Order.confirm()
# Struct as behaviour contract โ all fields documented in one place
# Functions that take/return the struct live in the same module
# Nested structs โ compose domain models
defmodule Customer do
@enforce_keys [:name, :email]
defstruct [:name, :email, address: %Address{}]
# Deep update with put_in (structs support Access via Map)
def update_city(%__MODULE__{} = c, city), do: put_in(c.address.city, city)
end
# Deriving protocols โ declare at struct definition
defmodule Event do
@derive {Jason.Encoder, only: [:type, :data, :timestamp]}
@derive {Inspect, only: [:type, :timestamp]} # Hide sensitive data from logs
defstruct [:type, :data, :timestamp, :internal_id]
end
# Constructor with ok/error โ validate before creating
def new(attrs) do
case Map.fetch(attrs, :email) do
{:ok, email} when is_binary(email) -> {:ok, struct!(__MODULE__, attrs)}
_ -> {:error, :email_required}
end
end
Lists โ Singly Linked Lists
# O(1) prepend โ ALWAYS build lists by prepending
list = [new_item | existing_list]
# Head/tail pattern matching
[first | rest] = [1, 2, 3] # first=1, rest=[2,3]
[a, b | rest] = [1, 2, 3] # a=1, b=2, rest=[3]
# Empty vs non-empty dispatch in function heads
def process([]), do: :empty
def process([_ | _] = list), do: Enum.map(list, &transform/1)
# BAD: length/1 is O(n) โ never use to check non-empty
def process(list) when length(list) > 0, do: ...
# GOOD: [_ | _] matches any non-empty list in O(1)
def process([_ | _] = list), do: ...
# BAD: O(n) append in loops = O(n^2)
Enum.reduce(items, [], fn item, acc -> acc ++ [item] end)
# GOOD: prepend then reverse = O(n)
items
|> Enum.reduce([], fn item, acc -> [item | acc] end)
|> Enum.reverse()
# IO lists โ fastest way to build output (zero-copy accumulation)
rows
|> Enum.map(fn row -> [format(row), ?\n] end)
|> IO.iodata_to_binary()
File.write!("out.txt", Enum.map(rows, &[&1, ?\n])) # IO functions accept IO lists directly
Maps โ Hash Maps
# Creation
%{key: "value"} # Atom keys (internal data)
%{"key" => "value"} # String keys (external/JSON data)
Map.new(users, &{&1.id, &1}) # From enumerable with transform
# Access โ choose based on intent
map.key # Raises KeyError if missing (assertive)
map[:key] # Returns nil if missing (lenient)
Map.get(map, key, default) # With explicit default
Map.fetch(map, key) # {:ok, val} or :error (pattern matchable)
# Update (returns new map โ never mutates)
%{map | key: new_value} # Update EXISTING key only (raises if missing!)
Map.put(map, key, value) # Set (create or overwrite)
Map.merge(map1, map2) # Merge (map2 wins on conflict)
Map.merge(m1, m2, fn _k, v1, v2 -> v1 + v2 end) # Custom conflict resolution
Keyword Lists
# Lists of {atom, value} tuples โ used for function options
[a: 1, a: 2] # Duplicate keys allowed (unlike maps)
# Last-arg sugar: brackets dropped
start(host: "localhost", port: 4000) # Same as start([host: "localhost", ...])
Keyword.get(opts, :key, default)
Keyword.validate!(opts, [:name, :timeout, pool_size: 10])
Tuples โ Fixed-Size Containers
# O(1) element access, but copying on update โ use for 2-4 elements
{:ok, value} # Tagged result tuples
{:error, :not_found} # Typed failure
elem(tuple, 0) # O(1) access by index
put_elem(tuple, 1, :new_val) # Returns new tuple (copies all)
Binary Pattern Matching
# Parse binary protocols/headers
<<header::16, payload_len::32, payload::binary-size(payload_len), rest::binary>> = data
# Fixed-width fields
<<id::binary-size(8), _sep::8, name::binary-size(20), _::binary>> = record
# UTF-8 character extraction
<<char::utf8, rest::binary>> = "hello" # char=104, rest="ello"
# Integer extraction with endianness
<<port::16-big>> = <<0x1F, 0x90>> # port=8080
<<value::32-little-signed>> = <<0xFF, 0xFF, 0xFF, 0xFF>> # value=-1
Deep dive: data-structures.md โ performance characteristics table, list operations
(flatten, zip, foldl/foldr, charlists, IO lists), maps (access, update, nested, patterns), tuples (tagged, ETS),
keywords, MapSet, ranges, Erlang data structures (:queue, :digraph, :ordsets), structs (constructors,