Claude
Skill
elixir-idioms
OTP/BEAM patterns and Elixir idioms — GenServer, Supervisor, Task, Registry, pattern matching, with chains, pipes. Use when designing processes or debugging BEAM issues.
Virus-scanned
Reviewed automatically before listing.
Download
oliver-kriska-claude-elixir-phoenix-plugins_elixir-phoenix_skills_elixir-idioms-9767a82.zip · 22 KB
Install
skills CLI
npx skills add https://github.com/oliver-kriska/claude-elixir-phoenix/tree/main/plugins/elixir-phoenix/skills/elixir-idioms
Claude Code
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install oliver-kriska-claude-elixir-phoenix@llmmart
Git
git clone https://github.com/oliver-kriska/claude-elixir-phoenix.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole oliver-kriska/claude-elixir-phoenix collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Elixir Idioms
Reference for writing idiomatic Elixir code with BEAM-aware patterns.
Iron Laws — Never Violate These
- NO PROCESS WITHOUT A RUNTIME REASON — Processes model concurrency, state, isolation—NOT code structure
- MESSAGES ARE COPIED — Keep messages small (except binaries >64 bytes)
- GUARDS USE
and/or/not— Never use short-circuit operators in guards (guards require boolean operands) - CHANGESETS FOR EXTERNAL DATA — Use
cast/4for user input,change/2for internal - RESCUE ONLY FOR EXTERNAL CODE — Never use rescue for control flow
- NO DYNAMIC ATOM CREATION —
String.to_atom(user_input)causes memory leak (atoms aren't GC'd) - @external_resource FOR COMPILE-TIME FILES — Modules reading files at compile time MUST declare
@external_resource - SUPERVISE ALL LONG-LIVED PROCESSES — Never bare
GenServer.start_link/Agent.start_linkin production. Use supervision trees - WRAP THIRD-PARTY LIBRARY APIs — Always facade external deps behind a project-owned module. Enables swapping without touching callers
- MIX TASKS START ONLY WHAT THEY NEED —
Mix.Task.run("app.config")+Application.ensure_all_started/1, neverMix.Task.run("app.start")(boots the FULL tree: endpoint port, Oban consuming) - CAPTURE LOCALE BEFORE SPAWNING — Gettext/CLDR locale is process-local. Read it in the caller and pass explicitly; a spawned Task/GenServer starts with the default locale
BEAM Architecture (Why Elixir Works This Way)
- Processes are cheap (2.6KB) — Spawn liberally for concurrency/isolation
- Complete memory isolation — No shared state, no locks needed
- Messages are copied (except binaries >64 bytes) — Keep messages small
- Per-process GC — No global GC pauses
- "Let it crash" — Supervisors restart to known-good state
Core Principles
- Pattern match over conditionals — Function heads first, then
case, thencond - Tagged tuples for expected failures —
{:ok, _}/{:error, _}for expected errors, raise for bugs - Pipe operator for data transformation — Start with data, never pipe single calls
- Let it crash — Handle expected errors, crash on unexpected ones
- Explicit over implicit — Be clear about intentions
Quick Decision Trees
Control Flow
Need patterns? → case (or function heads)
Multiple operations? → with
Boolean conditions? → cond (multiple) or if (single)
Error Handling
Expected failure? → {:ok, _}/{:error, _} tuples
Unexpected/bug? → raise exception (let supervisor handle)
External library? → rescue (only here!)
OTP
Need state?
├─ No → Plain functions
├─ Simple get/update → Agent or ETS
├─ Complex messages/timeouts → GenServer
└─ One-off async → Task
Quick Patterns
# Pattern match in function head
def process(%{status: :active} = user), do: activate(user)
def process(%{status: :inactive} = user), do: deactivate(user)
# with for happy path
with {:ok, user} <- get_user(id),
{:ok, order} <- create_order(user) do
{:ok, order}
end
# Task for async
Task.Supervisor.async_nolink(TaskSup, fn -> work() end)
|> Task.yield(5000) || Task.shutdown(task)
Common Pitfalls
| Wrong | Right |
|---|---|
length(list) == 0 |
list == [] or Enum.empty?(list) |
list ++ [item] |
[item \| list] \|> Enum.reverse() |
String.to_atom(input) |
String.to_existing_atom(input) |
spawn(fn -> log(conn) end) |
ip = conn.ip; spawn(fn -> log(ip) end) |
unless condition |
if !condition (unless deprecated in 1.18) |
References
For detailed patterns, see:
${CLAUDE_SKILL_DIR}/references/pattern-matching.md- Pattern matching, guards, binary matching${CLAUDE_SKILL_DIR}/references/otp-patterns.md- GenServer, Supervisor, Task, Registry${CLAUDE_SKILL_DIR}/references/error-handling.md- Tagged tuples, rescue, with${CLAUDE_SKILL_DIR}/references/with-and-pipes.md- When to usewithand|>(idiomatic patterns)${CLAUDE_SKILL_DIR}/references/troubleshooting.md- Production BEAM debugging (memory, performance, crashes)${CLAUDE_SKILL_DIR}/references/anti-patterns.md- Common mistakes and fixes${CLAUDE_SKILL_DIR}/references/mix-tasks.md- Mix task naming, option parsing, shell output${CLAUDE_SKILL_DIR}/references/elixir-118-features.md- Duration module, dbg improvements (1.18+)${CLAUDE_SKILL_DIR}/references/elixir-120-type-system.md- Gradual type checker,dynamic(), verified bugs as compile warnings (1.20+, OTP 27+)
Files (claude-elixir-phoenix)
-
references
-
anti-patterns.md 4.6 KB
# Anti-Patterns Reference ## Memory & Performance ```elixir # WRONG: length/1 for empty check (O(n)) length(list) == 0 # RIGHT: Pattern match or Enum.empty? list == [] Enum.empty?(list) # WRONG: ++ to append (O(n)) list ++ [item] # RIGHT: Prepend and reverse, or use different structure [item | list] |> Enum.reverse() # WRONG: Dynamic atom creation (memory leak - atoms aren't GC'd) String.to_atom(user_input) # RIGHT: Explicit mapping or existing atoms defp status_atom("ok"), do: :ok defp status_atom("error"), do: :error # Or: String.to_existing_atom(input) # WRONG: Sending unnecessary data (copies entire vars between processes) spawn(fn -> log_ip(conn.remote_ip) end) # Copies entire conn! GenServer.cast(pid, {:process, large_struct.id}) # Copies entire struct! # RIGHT: Extract minimal data before spawning or sending ip = conn.remote_ip spawn(fn -> log_ip(ip) end) id = large_struct.id GenServer.cast(pid, {:process, id}) ``` ## Message Handling ```elixir # WRONG: Selective receive without reference (O(n) mailbox scan) receive do {:response, data} -> data # Scans entire mailbox end # RIGHT: Reference-based (compiler optimizes) ref = make_ref() send(server, {self(), ref, :request}) receive do {^ref, response} -> response # Compiler uses receive marker end ``` ## Code Organization ```elixir # WRONG: String keys internally %{"name" => value} # RIGHT: Atom keys internally %{name: value} # WRONG: Macro when function works defmacro sum(a, b), do: quote do: unquote(a) + unquote(b) # RIGHT: Just use a function def sum(a, b), do: a + b ``` ## OTP Anti-Patterns ```elixir # ANTI-PATTERN: GenServer for stateless computation def add(a, b), do: GenServer.call(__MODULE__, {:add, a, b}) def handle_call({:add, a, b}, _from, state), do: {:reply, a + b, state} # CORRECT: Just use functions def add(a, b), do: a + b # ANTI-PATTERN: Single GenServer bottleneck # All requests serialize through one process # CORRECT: Use ETS for reads, GenServer for writes # Or partition into multiple processes ``` ## Assertiveness (from official Elixir anti-patterns) ```elixir # WRONG: Non-assertive map access — nil on missing required key user[:email] # Returns nil silently if :email missing # RIGHT: Assert required keys exist user.email # Raises KeyError — fail fast # Use [:key] ONLY for truly optional keys config[:timeout] || 5000 # WRONG: Catch-all hides bugs case fetch_user(id) do {:ok, user} -> process(user) _ -> :error # What failed? Why? end # RIGHT: Match known cases explicitly case fetch_user(id) do {:ok, user} -> process(user) {:error, :not_found} -> {:error, :not_found} end # WRONG: Boolean obsession — multiple related booleans %{is_admin: true, is_editor: false, is_viewer: false} # RIGHT: Single atom field %{role: :admin} # Or enum-like pattern in schema: # field :role, Ecto.Enum, values: [:admin, :editor, :viewer] ``` ## Stream vs Enum ```elixir # Stream processes lazily—only computes what's needed 1..1_000_000 |> Stream.map(&(&1 * 3)) |> Stream.filter(&(rem(&1, 2) != 0)) |> Enum.take(5) # Only processes ~5 elements # Enum processes eagerly—entire collection each step 1..1_000_000 |> Enum.map(&(&1 * 3)) # Creates 1M list |> Enum.filter(&(rem(&1, 2) != 0)) # Creates another list |> Enum.take(5) ``` **Use Enum** for small/medium collections, immediate results. **Use Stream** for large collections, multiple transformations, memory constraints. ## Pipe Operator Misuse ```elixir # AVOID: Pipe with single step user |> do_something() # Just: do_something(user) # AVOID: Start with function call String.upcase("hello") |> String.split() # Start with "hello" # DO: Use tap/1 for side effects (returns original value) user |> validate() |> tap(&Logger.info("Validated: #{&1.name}")) # Returns user |> persist() # DO: Use then/1 for transformations user |> validate() |> persist() |> then(&{:ok, &1}) # Transforms to tagged tuple ``` ## Binary Handling ```elixir # ANTI-PATTERN: Small sub-binary keeps large parent alive <<small::binary-size(100), _::binary>> = one_gb_binary # DO: Copy if keeping only the small part small = :binary.copy(small) ``` ## Tail Recursion For tail call optimization, recursive call must be the **last operation**: ```elixir # Tail recursive (optimized - constant stack) def sum(list), do: do_sum(list, 0) defp do_sum([], acc), do: acc defp do_sum([head | tail], acc), do: do_sum(tail, head + acc) # Not tail recursive (builds stack - O(n) memory) def factorial(0), do: 1 def factorial(n), do: n * factorial(n - 1) # Multiplication after recursion ``` **Rule of thumb**: Use Enum for 95% of cases—cleaner and well-tested. -
elixir-118-features.md 3.6 KB
# Elixir 1.18 Features Reference > **Official changelog**: <https://github.com/elixir-lang/elixir/blob/main/CHANGELOG.md> > **HexDocs**: <https://hexdocs.pm/elixir/> — Use `hexdocs-fetcher` for latest API docs. ## Duration Module (Elixir 1.18+) Native duration representation without external dependencies. ### Creating Durations ```elixir # From keyword list Duration.new!(hour: 2, minute: 30) #=> %Duration{hour: 2, minute: 30} # Common units Duration.new!(day: 7) Duration.new!(week: 1) Duration.new!(second: 3600) # Negative durations Duration.new!(hour: -1) ``` ### Duration Arithmetic ```elixir # Adding durations Duration.add(Duration.new!(hour: 1), Duration.new!(minute: 30)) #=> %Duration{hour: 1, minute: 30} # Adding to DateTime/NaiveDateTime DateTime.add(DateTime.utc_now(), Duration.new!(hour: 24)) # Subtracting DateTime.add(DateTime.utc_now(), Duration.negate(Duration.new!(day: 7))) ``` ### Duration in Oban Jobs ```elixir # Schedule job for specific duration from now defmodule MyApp.Workers.ReminderWorker do use Oban.Worker def schedule_reminder(user_id, delay_duration) do scheduled_at = DateTime.add(DateTime.utc_now(), delay_duration) %{user_id: user_id} |> new(scheduled_at: scheduled_at) |> Oban.insert() end end # Usage ReminderWorker.schedule_reminder(user.id, Duration.new!(day: 3)) ``` ### Duration in Cache TTLs ```elixir # With Cachex or similar def get_user_with_cache(user_id) do Cachex.fetch(:users_cache, user_id, ttl: Duration.to_milliseconds(Duration.new!(hour: 1)) ) end # Convert to different units Duration.to_seconds(Duration.new!(hour: 2)) #=> 7200 Duration.to_milliseconds(Duration.new!(minute: 5)) #=> 300_000 ``` ### Anti-patterns ```elixir # AVOID: Magic numbers for time Process.send_after(self(), :timeout, 3600_000) # What unit? Confusing! # PREFER: Duration makes intent clear Process.send_after(self(), :timeout, Duration.to_milliseconds(Duration.new!(hour: 1))) # AVOID: Manual arithmetic scheduled_at = DateTime.add(now, 7 * 24 * 60 * 60, :second) # PREFER: Readable duration scheduled_at = DateTime.add(now, Duration.new!(week: 1)) ``` ## Enhanced dbg/2 (Elixir 1.18+) ### Pipeline Debugging ```elixir # dbg shows each pipeline step users |> Enum.filter(&(&1.active)) |> dbg() # Shows filter result |> Enum.map(&(&1.name)) |> dbg() # Shows map result |> Enum.sort() ``` ### Customizing Output ```elixir # In IEx or tests, customize dbg behavior # config/dev.exs config :elixir, :dbg_callback, {MyApp.Debug, :custom_dbg, []} # Custom module defmodule MyApp.Debug do def custom_dbg(code, options, env) do # Custom formatting, logging, etc. Macro.dbg(code, options, env) end end ``` ## Calendar.strftime/2 Improvements ```elixir # ISO week numbers Calendar.strftime(~D[2026-02-05], "%G-W%V") #=> "2026-W06" # 12-hour format with AM/PM Calendar.strftime(~T[14:30:00], "%I:%M %p") #=> "02:30 PM" # Full datetime formatting Calendar.strftime(DateTime.utc_now(), "%Y-%m-%d %H:%M:%S %Z") #=> "2026-02-05 10:30:45 UTC" ``` ## Deprecations in 1.18 ### `unless/2` Deprecated `unless` is soft-deprecated in Elixir 1.18. The formatter automatically rewrites `unless condition` to `if !condition`. ```elixir # Deprecated — formatter will rewrite unless valid?(input) do {:error, :invalid} end # Current — what the formatter produces if !valid?(input) do {:error, :invalid} end ``` **Rule**: Never write `unless` — always use `if !` or pattern match instead. This avoids unnecessary formatter churn. ## Compatibility Notes - Duration requires Elixir 1.18+ - For projects on 1.17 or earlier, use Timex or manual arithmetic - Check version in mix.exs: `{:elixir, "~> 1.18"}` -
elixir-120-type-system.md 5.9 KB
# Elixir 1.20 Type System Reference > **Blog**: <https://elixir-lang.org/blog/2026/06/03/elixir-v1-20-0-released/> > **Changelog**: <https://elixir.hexdocs.pm/1.20.0/changelog.html#type-system-improvements> > **HexDocs**: use `hexdocs-fetcher` for the latest API docs. ## TL;DR Elixir 1.20 (released 2026-06-03) completed its **first type-system milestone**: the compiler now infers types and gradually type-checks **every** program **without any annotations**. It reports **dead/redundant code** and **verified bugs** — typing violations guaranteed to fail at runtime. Low false positives by design. **Requires OTP 27+.** No new struct/typespec syntax — that is a *future* milestone. There is **nothing new to write**; this release is about *interpreting new compiler diagnostics*. ## The one thing that changes day-to-day Type violations are emitted as **`mix compile` warnings** — built into the compiler. **Not** Dialyzer, **not** a separate tool, **no PLT**. > On Elixir 1.20+, `mix compile --warnings-as-errors` **now fails the build on > type violations.** This is what `/phx:verify`, `/phx:work` checkpoints, and > the "fix CI" pattern run everywhere — so a verified bug becomes a hard > failure, not a silent warning. When a `--warnings-as-errors` build that previously passed starts failing after a toolchain bump to 1.20, **suspect a newly-detected type violation before assuming the code regressed** — the code didn't change, the checker got smarter. ## The `dynamic()` mental model Unlike `any()` in most gradual languages ("anything goes, never flag"), Elixir's `dynamic()` is a **refinable range**: - **Compatibility** — a call is flagged **only when the supplied and accepted types are disjoint**. `dynamic(integer() or binary())` passed to `/` (wants a number) is fine — `integer()` overlaps. Passed to `Map.fetch!/2` (wants a map) it is flagged — disjoint. - **Narrowing** — usage refines the type. `data.a + data.b` narrows `data` to `%{..., a: number(), b: number()}` (the `...` = "may have other keys"). ```elixir # value_or_error :: dynamic(integer() or binary()) at runtime Map.fetch!(value_or_error, :some_key) # ⚠ violation: map ⟂ int|binary def add_a_and_b(data) do data.a + data # ⚠ data narrowed to %{..., a: number()}, then used AS a number end ``` ## What the checker catches | Kind | Example that warns | |------|--------------------| | **Verified bug** | calling `User.name(%{})` when it needs `%{..., name: term()}` | | **Disjoint call** | `String.upcase/1` on a value the checker proved is an integer | | **Dead clause** | a `case` clause that can never match given prior clauses | | **Bad field access** | `x.foo` after a guard proved `x` has **no** `:foo` key | | **Out-of-bounds** | `elem(x, 3)` after `tuple_size(x) < 3` | ## Inference you get for free **Guards** infer unions/intersections/negations: ```elixir def f(x, y) when is_list(x) and is_integer(y) # x :: list, y :: integer def f({:ok, x} = y) when is_binary(x) or is_integer(x) def f(x) when is_map_key(x, :foo) # x :: %{..., foo: dynamic()} def f(x) when not is_map_key(x, :foo) # x :: %{..., foo: not_set()} → x.foo warns def f(x) when tuple_size(x) < 3 # elem(x, 3) warns ``` **Clauses / occurrence typing** (`case`, `cond`, `with`) — earlier clauses refine later ones: ```elixir case System.get_env("SOME_VAR") do nil -> :not_found value -> {:ok, String.upcase(value)} # value :: binary() (nil already excluded) end ``` **Maps** track non-atom keys by domain and typed stdlib ops: ```elixir %{123 => "hello", 456.0 => :ok} # %{integer() => binary(), float() => :ok} Map.put(map, :key, 123) # %{..., key: integer()} Map.delete(map, :key) # %{..., key: not_set()} Map.replace(map, :key, 123) # %{..., key: if_set(integer())} ``` ## How to read & fix a type violation 1. **Read the message literally** — it states the *accepted* vs *supplied* type. The fix is to make them overlap, not to silence the warning. 2. **Check the narrowing chain** — the warning usually points at where the variable was *refined* (a guard, a prior clause, a field access), not just where it blew up. 3. **It is almost always a real bug.** False positives are rare by design (disjoint-only). Prefer fixing the code over restructuring to dodge the checker. 4. **Genuinely dynamic boundary?** If a value really is runtime-typed (external input), the disjoint-only rule already tolerates it — only a provably-impossible call warns. ## Compiler type checker vs Dialyzer They are **different tools** — keep both in mind: | | Compiler type checker (1.20+) | Dialyzer / dialyxir | |--|------------------------------|----------------------| | Runs in | `mix compile` (built-in) | `mix dialyzer` (separate, needs PLT) | | Setup | none | PLT build (slow first run) | | Basis | set-theoretic types, `dynamic()` | success typing | | Annotations | none needed | reads `@spec` | | Caught by `--warnings-as-errors` | **yes** | no | | Best at | disjoint calls, dead clauses, map/guard narrowing | `@spec` mismatches, contract violations, opaque misuse | Guidance: the compiler checker is now the **first line** of type safety and is always on. Dialyzer remains valuable for `@spec`/contract checking and opaque types — it is **complementary, not redundant**. ## Bonus: faster compiles New compiler option `:module_definition` (`:compiled` default, or `:interpreted`) can speed up large-project builds. Set in `mix.exs`: ```elixir elixirc_options: [module_definition: :interpreted] ``` Does not change emitted `.beam` files — only how `defmodule` bodies execute at compile time (slight stacktrace-precision tradeoff). ## Compatibility notes - Type checking ships in Elixir **1.20+** on **OTP 27+** (compatible to OTP 29). - No code changes required to benefit — recompile and read the warnings. - No annotation syntax yet; typed structs and signatures are the next milestone. - Check version in `mix.exs`: `{:elixir, "~> 1.20"}`. -
error-handling.md 2.7 KB
# Error Handling Reference ## Decision Tree ``` Is failure expected in normal operation? ├─ Yes → {:ok, _}/{:error, _} tuples │ ├─ Chaining operations? → with │ └─ Need both variants? → Provide foo/foo! └─ No (unexpected/bug) → raise exception └─ Supervision handles recovery ``` ## When to Use Each | Use Exceptions | Use Error Tuples | |----------------|------------------| | Programming bugs | Expected failures | | Truly unexpected errors | User input validation | | Can't recover gracefully | Caller should handle | | "Let it crash" scenarios | Control flow decisions | ## Tagged Tuple Pattern ```elixir # Return error tuples for expected failures def divide(a, b) when b != 0, do: {:ok, a / b} def divide(_, 0), do: {:error, :division_by_zero} # Bang variant raises for callers who want to crash def divide!(a, b) do case divide(a, b) do {:ok, result} -> result {:error, reason} -> raise ArgumentError, "Cannot divide: #{reason}" end end ``` ## With for Happy Path ```elixir # PREFER: with for multi-step operations def create_order(params) do with {:ok, user} <- get_user(params.user_id), {:ok, product} <- get_product(params.product_id), {:ok, order} <- Orders.create(user, product) do {:ok, order} end end # AVOID: with for single operation with {:ok, user} <- get_user(id), do: user # Just use case! # AVOID: Complex else clauses - normalize errors in helpers with {:ok, a} <- normalize_step1(), {:ok, b} <- normalize_step2(a) do {:ok, b} end # Non-matching tuples pass through unchanged ``` ## Assertive Map Access ```elixir # DON'T: Silent nil for missing required keys {point[:x], point[:y]} # Returns {nil, nil} if keys missing! # DO: Use .key for required keys (raises KeyError if missing) {point.x, point.y} # DO: Pattern match def plot(%{x: x, y: y}), do: {x, y} # Match fails if keys missing ``` ## Rescue Only for External Code ```elixir # DO: Rescue external library exceptions def safe_parse(json) do {:ok, Jason.decode!(json)} rescue e in Jason.DecodeError -> {:error, e.message} end # DON'T: Rescue for control flow or catch all try do risky_operation() rescue _ -> :error # Never do this - masks programming errors end ``` ## Control Flow Decision Tree ``` Need to match against patterns? ├─ Yes → case │ └─ Multiple dependent operations? → with └─ No (boolean conditions) ├─ Single condition? → if └─ Multiple conditions? → cond ``` | Construct | Use When | |-----------|----------| | Function heads | First choice - most idiomatic | | `case` | Pattern matching single value | | `cond` | Multiple boolean conditions | | `with` | Chaining operations returning tagged tuples | | `if` | Single boolean check | -
mix-tasks.md 2.9 KB
# Mix Task Patterns > **Official docs**: <https://hexdocs.pm/mix/Mix.Task.html> > **Mix guides**: <https://github.com/elixir-lang/elixir/tree/main/lib/mix/lib/mix> ## Module Naming Convention Mix task module names map directly to the CLI command: ```elixir # mix my_app.validate → Mix.Tasks.MyApp.Validate defmodule Mix.Tasks.MyApp.Validate do @shortdoc "Validate configuration" @moduledoc "Detailed description..." use Mix.Task @impl Mix.Task def run(args) do # Parse args, do work end end ``` **Rules:** - Module name segments map to `.`-separated CLI words - `CamelCase` in module → `snake_case` in CLI - `@shortdoc` is REQUIRED (shows in `mix help`) - `@moduledoc` for detailed `mix help my_app.validate` ## Option Parsing ```elixir @impl Mix.Task def run(args) do {opts, _rest, _invalid} = OptionParser.parse(args, strict: [ dry_run: :boolean, type: :string, format: :string, verbose: :boolean ], aliases: [d: :dry_run, t: :type, f: :format, v: :verbose] ) # Access with Keyword.get dry_run? = Keyword.get(opts, :dry_run, false) format = Keyword.get(opts, :format, "text") end ``` ## Shell Output ```elixir # Prefer Mix.shell() for testability Mix.shell().info("Processing #{count} items...") Mix.shell().error("Failed: #{reason}") # For colored output Mix.shell().info([:green, "✓ ", :reset, "All checks passed"]) # Progress reporting Enum.each(items, fn item -> Mix.shell().info(" #{item.name}... #{status}") end) ``` ## App Startup (Iron Law #10) NEVER `Mix.Task.run("app.start")` — it boots the FULL supervision tree: the endpoint binds its port and Oban starts consuming jobs, inside what should be a one-off task. Start only what the task needs: ```elixir def run(args) do # Load config without starting the app Mix.Task.run("app.config") # Start only the dependency apps + repo this task uses {:ok, _} = Application.ensure_all_started(:ecto_sql) {:ok, _} = MyApp.Repo.start_link() # Your logic here end ``` ## Chaining Tasks ```elixir # Run another task (after the startup above) Mix.Task.run("ecto.migrate") # Re-run a task that already ran this VM session Mix.Task.rerun("ecto.migrate") ``` ## Credo Complexity Mix tasks often trigger Credo complexity warnings because the `run/1` function handles arg parsing + logic. Split into: ```elixir def run(args) do args |> parse_opts() |> validate_opts() |> execute() end defp parse_opts(args), do: ... defp validate_opts(opts), do: ... defp execute(opts), do: ... ``` ## Testing Mix Tasks ```elixir defmodule Mix.Tasks.MyApp.ValidateTest do use ExUnit.Case, async: true test "runs successfully with valid args" do Mix.Tasks.MyApp.Validate.run(["--type", "full"]) end test "handles missing args gracefully" do assert_raise Mix.Error, fn -> Mix.Tasks.MyApp.Validate.run(["--invalid"]) end end end ``` -
otp-patterns.md 10.6 KB
# OTP Patterns Reference > **Official docs**: <https://hexdocs.pm/elixir/GenServer.html> | <https://hexdocs.pm/elixir/Supervisor.html> > **Elixir guides**: <https://hexdocs.pm/elixir/introduction.html> (see OTP section) ## Contents - [Core Rule](#core-rule-no-process-without-a-runtime-reason) - [BEAM Architecture Context](#beam-architecture-context) - [Decision Tree](#decision-tree) - [Quick Reference Table](#quick-reference-table) - [Plain Functions](#plain-functions-default-choice) - [Agent: Simple State](#agent-simple-state) - [ETS](#ets-shared-state-without-serialization) - [GenServer](#genserver-complex-state-management) - [Task](#task-one-off-async-work) - [Supervisor](#supervisor-fault-tolerance) - [DynamicSupervisor](#dynamicsupervisor-on-demand-children) - [Registry](#registry-dynamic-process-naming) - [Common Scenarios](#common-scenarios) - [Resource Cleanup](#resource-cleanup-tryafter-not-tryrescue) - [Anti-Patterns](#anti-patterns) ## Core Rule: NO PROCESS WITHOUT A RUNTIME REASON Processes model **runtime properties**, not code organization: - ✓ Concurrency needs - ✓ Shared resources requiring serialized access - ✓ Error isolation domains - ✓ State that survives between operations - ✗ Code organization (MAJOR ANTI-PATTERN) - ✗ Stateless computation - ✗ Namespacing ## BEAM Architecture Context Understanding these fundamentals explains WHY patterns exist: - **Processes are cheap**: 2.6KB each, ~134M possible per VM - **Complete isolation**: Each has own stack/heap/mailbox - **Messages are copied**: Keep messages small (except binaries >64 bytes) - **Per-process GC**: No global GC pauses - **Preemptive scheduling**: Fair CPU time via reductions - **"Let it crash"**: Focus on happy path, supervisors restart to known-good state ## Decision Tree ``` Need to maintain state? ├─ No → Use plain functions └─ Yes ├─ Simple get/update only? → Agent or ETS ├─ Complex message handling? → GenServer │ ├─ Need timeouts/monitors? → GenServer │ └─ Children started dynamically? → DynamicSupervisor └─ One-off async work? → Task ``` ## Quick Reference Table | Need | Solution | Notes | |------|----------|-------| | Stateless computation | Functions | Default choice | | Simple get/set state | Agent | No monitors/timers | | Fast key-value lookups | ETS | Many readers, no serialization | | Complex state/coordination | GenServer | Monitors, timers, handle_info | | One-off async work | Task | Task.Supervisor for production | | Dynamic worker pool | DynamicSupervisor + Registry | Per-user/session processes | | Connection pool | GenServer | Checkout/checkin with monitors | | Fault tolerance | Supervisor | Always supervise! | --- ## Plain Functions (Default Choice) ```elixir # DO: Stateless computation defmodule Calculator do def add(a, b), do: a + b def multiply(a, b), do: a * b end # DON'T: GenServer for stateless work defmodule Calculator do use GenServer def add(a, b), do: GenServer.call(__MODULE__, {:add, a, b}) def handle_call({:add, a, b}, _from, state), do: {:reply, a + b, state} end ``` ## Agent: Simple State **Use when**: Only get/update operations, no monitors/timers **Don't use when**: Need handle_info, monitors, distributed system ```elixir defmodule Counter do use Agent def start_link(initial) do Agent.start_link(fn -> initial end, name: __MODULE__) end def value, do: Agent.get(__MODULE__, & &1) def increment, do: Agent.update(__MODULE__, &(&1 + 1)) def reset, do: Agent.update(__MODULE__, fn _ -> 0 end) end ``` ## ETS: Shared State Without Serialization **Use when**: Many concurrent readers/writers, key-value pairs, performance critical **Don't use when**: Need complex coordination, complex relationships ```elixir # Create table (usually in Application.start/2) :ets.new(:my_cache, [:named_table, :public, read_concurrency: true]) # Use from anywhere :ets.insert(:my_cache, {:key, value}) [{:key, value}] = :ets.lookup(:my_cache, :key) :ets.delete(:my_cache, :key) ``` ## GenServer: Complex State Management **Use for**: Complex coordination, serializing access, managing external resources, monitors/timers **Don't use for**: Code organization, stateless computation, simple get/update ```elixir defmodule ConnectionPool do use GenServer # Client API def start_link(opts) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end def checkout, do: GenServer.call(__MODULE__, :checkout) def checkin(conn), do: GenServer.cast(__MODULE__, {:checkin, conn}) # Server callbacks @impl GenServer def init(opts) do {:ok, %{available: [], checked_out: %{}, max: opts[:max] || 10}} end @impl GenServer def handle_call(:checkout, {pid, _ref}, state) do ref = Process.monitor(pid) # Clean up if caller crashes # ... checkout logic {:reply, conn, updated_state} end @impl GenServer def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do # Clean up crashed process's connections {:noreply, cleanup_for_pid(state, pid)} end end ``` ### Expensive Initialization ```elixir # DO: Use handle_continue (OTP 21+) @impl GenServer def init(args) do {:ok, initial_state, {:continue, :load_data}} end @impl GenServer def handle_continue(:load_data, state) do # GUARANTEED to run before any messages {:noreply, %{state | data: load_expensive_data()}} end # DON'T: send(self(), :init) - not guaranteed order ``` ## Task: One-Off Async Work ```elixir # Fire-and-forget (won't crash caller) Task.Supervisor.start_child(MyApp.TaskSupervisor, fn -> do_background_work() end) # Async with timeout and error handling task = Task.Supervisor.async_nolink(MyApp.TaskSupervisor, fn -> might_fail() end) case Task.yield(task, 5000) || Task.shutdown(task) do {:ok, result} -> result {:exit, reason} -> handle_error(reason) nil -> handle_timeout() end # Concurrent collection processing urls |> Task.async_stream(&fetch_url/1, max_concurrency: 10, timeout: 30_000) |> Enum.map(fn {:ok, result} -> result end) ``` ## Supervisor: Fault Tolerance **Always supervise processes**. Never use `{:ok, pid} = GenServer.start_link(...)` in production. ```elixir children = [ {Registry, keys: :unique, name: MyApp.Registry}, {MyApp.CacheServer, []}, {DynamicSupervisor, name: MyApp.WorkerSupervisor, strategy: :one_for_one}, {Task.Supervisor, name: MyApp.TaskSupervisor} ] Supervisor.start_link(children, strategy: :one_for_one, max_restarts: 3, max_seconds: 5 ) ``` ### Restart Strategies | Strategy | Behavior | Use When | |----------|----------|----------| | `:one_for_one` | Restart only crashed child | Children are independent | | `:one_for_all` | Restart all children | Children are tightly coupled | | `:rest_for_one` | Restart crashed + those started after | Later depend on earlier | ### Restart Types - `:permanent` - Always restart (default for most workers) - `:temporary` - Never restart (one-off tasks) - `:transient` - Restart only on abnormal exit ## DynamicSupervisor: On-Demand Children ```elixir # Combined with Registry for named access def start_worker(user_id) do name = {:via, Registry, {MyApp.WorkerRegistry, user_id}} spec = {Worker, name: name, user_id: user_id} DynamicSupervisor.start_child(MyApp.WorkerSupervisor, spec) end def find_worker(user_id) do case Registry.lookup(MyApp.WorkerRegistry, user_id) do [{pid, _}] -> {:ok, pid} [] -> {:error, :not_found} end end ``` ## Registry: Dynamic Process Naming ```elixir # Unique registration (no atom explosion) name = {:via, Registry, {MyApp.Registry, "user:#{user_id}"}} GenServer.start_link(UserWorker, args, name: name) # Lookup case Registry.lookup(MyApp.Registry, "user:#{user_id}") do [{pid, _}] -> {:ok, pid} [] -> {:error, :not_found} end # Pub/sub broadcast (duplicate keys registry) Registry.dispatch(PubSubRegistry, "topic", fn entries -> for {pid, _} <- entries, do: send(pid, {:broadcast, message}) end) ``` --- ## Common Scenarios ### Cache **Pattern**: ETS (not GenServer) **Why**: Many concurrent readers/writers, no coordination needed ### Rate Limiting **Pattern**: ETS + optional GenServer for cleanup **Why**: ETS for fast lookups, GenServer only if need periodic cleanup ### Background Job **Pattern**: Task.Supervisor (or Oban for persistence) **Why**: Don't need long-lived state, just async execution ### Connection Pool **Pattern**: GenServer **Why**: Need serialization, monitors, complex coordination ### User Session State **Pattern**: DynamicSupervisor + Registry + GenServer **Why**: Dynamic per-user processes, fault isolation, named access --- ## Resource Cleanup: try/after (Not try/rescue) When wrapping code in instrumentation spans, telemetry, or any resource that needs guaranteed cleanup: ```elixir # CORRECT: try/after — runs ALWAYS def instrumented_call(args) do span = Tracer.start_span("operation") try do do_work(args) after Tracer.finish_span(span) end end # WRONG: try/rescue — only runs on exception def instrumented_call(args) do span = Tracer.start_span("operation") try do do_work(args) rescue e -> Tracer.finish_span(span); reraise e, __STACKTRACE__ end Tracer.finish_span(span) # Missed if do_work throws non-exception exit end ``` **Rule**: `try/rescue` only catches exceptions. `try/after` runs unconditionally — use it for spans, file handles, locks, and any cleanup that must happen regardless of outcome. ## Anti-Patterns ### 1. GenServer for Code Organization ```elixir # ANTI-PATTERN defmodule Calculator do use GenServer def add(a, b), do: GenServer.call(__MODULE__, {:add, a, b}) end # CORRECT defmodule Calculator do def add(a, b), do: a + b end ``` ### 2. Scattered Process Interfaces ```elixir # ANTI-PATTERN: Calling Agent from multiple modules Agent.get(MyApp.Cache, &Map.get(&1, key)) # In various modules # CORRECT: Encapsulate in single module defmodule MyApp.Cache do use Agent def get(key), do: Agent.get(__MODULE__, &Map.get(&1, key)) end ``` ### 3. Sending Unnecessary Data in Closures ```elixir # ANTI-PATTERN: Captures entire struct spawn(fn -> log_ip(conn.remote_ip) end) # Copies entire conn! # CORRECT: Extract before spawning ip = conn.remote_ip spawn(fn -> log_ip(ip) end) ``` ### 4. Unsupervised Processes ```elixir # ANTI-PATTERN {:ok, pid} = GenServer.start_link(MyServer, []) # CORRECT children = [{MyServer, []}] Supervisor.start_link(children, strategy: :one_for_one) ``` ### 5. Global Singleton Bottleneck ```elixir # ANTI-PATTERN: Global singleton for per-user data GenServer.call(UserStateServer, {:get_user, user_id}) # CORRECT: DynamicSupervisor + Registry for per-user processes name = {:via, Registry, {MyApp.Registry, "user:#{user_id}"}} ``` -
pattern-matching.md 1.9 KB
# Pattern Matching Reference ## Function Heads (Preferred) ```elixir def process(%{status: :active} = user), do: activate(user) def process(%{status: :inactive} = user), do: deactivate(user) def process(_user), do: {:error, :unknown_status} ``` ## Pin Operator Match against existing values: ```elixir expected = :ok ^expected = get_result() # Only matches if returns :ok # Essential for dynamic map keys key = :user_id %{^key => value} = data ``` ## is_non_struct_map/1 Guard (Elixir 1.17+) Structs ARE maps, so `is_map/1` matches both: ```elixir # DO: Distinguish plain maps from structs def process(data) when is_non_struct_map(data), do: handle_map(data) def process(%User{} = user), do: handle_user(user) # DON'T: This matches structs too! def process(%{} = data), do: handle_data(data) # Matches User struct! ``` ## Custom Guards ```elixir defguard is_positive_integer(n) when is_integer(n) and n > 0 def process(n) when is_positive_integer(n), do: n * 2 ``` ## Binary Pattern Matching ```elixir # UTF-8 prefix matching "hello " <> rest = "hello world" # rest = "world" # Protocol parsing <<header::8, length::32-big, payload::binary-size(length), rest::binary>> = data # ANTI-PATTERN: Small sub-binary keeps large parent alive <<small::binary-size(100), _::binary>> = one_gb_binary # DO: Copy if keeping only the small part small = :binary.copy(small) ``` ## Guards: Allowed Operations Guards must be pure, deterministic: - Type checks: `is_atom/1`, `is_binary/1`, `is_integer/1`, `is_list/1`, `is_map/1` - Comparisons: `==`, `!=`, `<`, `>`, `<=`, `>=` - Arithmetic: `+`, `-`, `*`, `/`, `abs/1`, `div/2`, `rem/2` - Value access: `hd/1`, `tl/1`, `elem/2`, `tuple_size/1`, `map_size/1`, `length/1` Guards use `and`/`or`/`not`, never short-circuit operators (they require boolean operands) ```elixir # CORRECT def process(n) when is_integer(n) and n > 0, do: n * 2 # WRONG - compile error def process(n) when is_integer(n) && n > 0, do: n * 2 ``` -
troubleshooting.md 4.5 KB
# BEAM Troubleshooting Playbook Production debugging for Phoenix/BEAM applications. For code bugs, see `deep-bug-investigator` agent. ## Quick Diagnosis | Symptom | Likely Cause | Section | |---------|--------------|---------| | High memory, growing | Process leak, ETS bloat | Memory Issues | | Slow responses | N+1, GenServer bottleneck | Performance | | Random crashes | Unhandled errors, supervisor | Crashes | | Timeouts | DB pool, GenServer call | Timeouts | | Node unresponsive | Scheduler block, long GC | BEAM Issues | ## Memory Issues ### With Tidewave ```elixir # Top memory processes mcp__tidewave__project_eval """ Process.list() |> Enum.map(fn pid -> {pid, Process.info(pid, :memory)} end) |> Enum.filter(fn {_, mem} -> mem != nil end) |> Enum.map(fn {pid, {:memory, mem}} -> {pid, mem} end) |> Enum.sort_by(&elem(&1, 1), :desc) |> Enum.take(10) |> Enum.map(fn {pid, mem} -> info = Process.info(pid, [:registered_name, :current_function]) {info[:registered_name] || pid, div(mem, 1024), info[:current_function]} end) """ # ETS table sizes mcp__tidewave__project_eval """ :ets.all() |> Enum.map(fn t -> {t, :ets.info(t, :memory) * :erlang.system_info(:wordsize)} end) |> Enum.sort_by(&elem(&1, 1), :desc) |> Enum.take(10) |> Enum.map(fn {t, mem} -> {t, div(mem, 1024)} end) """ ``` ### Without Tidewave ```bash # Attach to running node iex --sname debug --remsh myapp@hostname # In IEx :observer.start() ``` ### Common Causes 1. **LiveView socket assigns** - Use `temporary_assigns` or streams 2. **ETS table growth** - Check for missing cleanup 3. **Process mailbox** - GenServer not keeping up 4. **Binary memory** - Large binaries not GC'd (use `:binary.copy/1`) ## Performance Issues ### N+1 Query Detection ```elixir # config/dev.exs - log slow queries config :my_app, MyApp.Repo, log: :debug, stacktrace: true ``` ### GenServer Bottleneck ```elixir mcp__tidewave__project_eval """ pid = Process.whereis(MyApp.SomeServer) {:message_queue_len, len} = Process.info(pid, :message_queue_len) IO.puts("Queue length: \#{len}") # >100 = problem """ ``` **Signs:** - Message queue growing - Call timeouts - Single process high CPU **Solutions:** - Pool of workers (Poolboy) - ETS for read-heavy (GenServer writes, ETS reads) - Partition by key (Registry + DynamicSupervisor) ### Slow Ecto Queries ```sql -- With Tidewave mcp__tidewave__execute_sql_query """ SELECT query, calls, mean_time, total_time FROM pg_stat_statements ORDER BY mean_time DESC LIMIT 10 """ -- Missing indexes mcp__tidewave__execute_sql_query """ SELECT relname, seq_scan, idx_scan FROM pg_stat_user_tables WHERE seq_scan > idx_scan ORDER BY seq_scan DESC """ ``` ## Crashes ### Common Crash Patterns | Error | Cause | Fix | |-------|-------|-----| | `noproc` | Process died/not started | Check supervisor, handle `{:error, _}` | | `timeout` | GenServer.call timeout | Increase timeout or use cast | | `badmatch` | Pattern match failed | Add catch-all clause | | `function_clause` | No matching function | Check guards, add fallback | | `badarg` | Wrong argument type | Validate input | ### Supervisor Tree Analysis ```elixir mcp__tidewave__project_eval """ Supervisor.which_children(MyApp.Supervisor) |> Enum.map(fn {id, pid, type, _} -> %{id: id, pid: pid, type: type} end) """ ``` ## Timeouts ### DB Pool Exhaustion **Symptoms:** Random timeouts, "connection not available" **Check:** ```elixir # Current pool size Application.get_env(:my_app, MyApp.Repo)[:pool_size] ``` **Solutions:** - Increase `pool_size` - Add `queue_target` and `queue_interval` - Find long-running queries - Use `Repo.checkout/1` properly for transactions ### GenServer Call Timeout ```elixir # Default is 5000ms GenServer.call(pid, :request, 10_000) # Increase if needed # Or use cast + handle_info for async GenServer.cast(pid, {:request, self()}) ``` ## BEAM Issues ### Scheduler Utilization ```elixir mcp__tidewave__project_eval """ :scheduler.utilization(1000) # Sample for 1 second """ ``` ### Long GC Pauses ```elixir # Enable GC logging :erlang.system_flag(:long_gc, 50) # Log GC > 50ms ``` ### Dirty Schedulers Blocked ```elixir mcp__tidewave__project_eval """ :erlang.statistics(:dirty_cpu_run_queue_lengths) """ ``` ## Quick Checklist ```markdown ## Troubleshooting: [Issue] ### Symptoms - [ ] High memory? - [ ] Slow responses? - [ ] Timeouts? - [ ] Crashes? ### Checked - [ ] Logs: `mcp__tidewave__get_logs level: :error` - [ ] Memory: Top processes - [ ] Queries: Slow query log - [ ] Processes: GenServer queue lengths ### Root Cause [...] ### Fix [...] ``` -
with-and-pipes.md 7.2 KB
# With Statement and Pipe Operator Guide ## Contents - [Why These Are Idiomatic](#why-these-are-idiomatic) - [Pipe Operator](#pipe-operator-) - [With Statement](#with-statement) - [Real-World Examples from Production Code](#real-world-examples-from-production-code) - [Summary](#summary) - [Anti-Pattern: Avoiding Pipes and With](#anti-pattern-avoiding-pipes-and-with) ## Why These Are Idiomatic Both `with` and `|>` are core Elixir idioms that experienced developers expect. Avoiding them leads to less readable, non-idiomatic code. ## Pipe Operator `|>` ### When to Use Pipes ```elixir # ✅ Data transformation chains params |> Map.get("user") |> normalize_email() |> String.downcase() |> String.trim() # ✅ Query building User |> where(active: true) |> where([u], u.role in ^roles) |> order_by(desc: :inserted_at) |> limit(10) |> Repo.all() # ✅ Changeset chains %User{} |> User.changeset(params) |> put_change(:status, :pending) |> validate_required([:email]) |> Repo.insert() # ✅ Stream processing 1..1000 |> Stream.map(&expensive_operation/1) |> Stream.filter(&valid?/1) |> Enum.take(10) ``` ### When NOT to Use Pipes ```elixir # ❌ Single function call - no pipe needed name |> String.upcase() # ✅ Just call the function String.upcase(name) # ❌ When data isn't the first argument Enum.map(list, &String.upcase/1) |> Enum.join(", ") # ✅ Use variable or reorder list |> Enum.map(&String.upcase/1) |> Enum.join(", ") # ❌ Complex branching mid-pipe data |> transform() |> (fn x -> if condition, do: a(x), else: b(x) end).() # Ugly! # ✅ Use case or separate function data |> transform() |> handle_condition(condition) defp handle_condition(x, true), do: a(x) defp handle_condition(x, false), do: b(x) ``` ### Pipe Style Rules ```elixir # Start with data, not a function call # ❌ Wrong get_user(id) |> process() # ✅ Right id |> get_user() |> process() # Or just use variables for clarity user = get_user(id) process(user) ``` ## With Statement ### When to Use With ```elixir # ✅ Multiple dependent operations that can fail def create_order(params) do with {:ok, user} <- get_user(params.user_id), {:ok, product} <- get_product(params.product_id), :ok <- check_inventory(product), {:ok, order} <- Orders.create(user, product, params) do {:ok, order} end end # ✅ Authorization chains def update_post(user, post_id, params) do with {:ok, post} <- get_post(post_id), :ok <- authorize(user, :update, post), {:ok, updated} <- Posts.update(post, params) do {:ok, updated} end end # ✅ Validations that depend on each other def process_upload(params) do with {:ok, file} <- validate_file_exists(params), {:ok, metadata} <- extract_metadata(file), :ok <- validate_file_type(metadata), {:ok, processed} <- process_file(file, metadata) do {:ok, processed} end end ``` ### When NOT to Use With ```elixir # ❌ Single operation - use case instead with {:ok, user} <- get_user(id) do {:ok, user} end # ✅ Just use case case get_user(id) do {:ok, user} -> {:ok, user} {:error, _} = error -> error end # ❌ No failure handling needed - use pipes with data <- fetch_data(), processed <- process(data), formatted <- format(processed) do formatted end # ✅ Use pipes fetch_data() |> process() |> format() ``` ### With Patterns ```elixir # Pattern match in with clauses with %User{active: true} = user <- get_user(id), %Subscription{status: :active} <- get_subscription(user) do {:ok, user} else %User{active: false} -> {:error, :user_inactive} %Subscription{} -> {:error, :subscription_inactive} nil -> {:error, :not_found} end # Bare expressions (always match) with {:ok, user} <- get_user(id), # This always succeeds, just assigns email = user.email, {:ok, _} <- send_notification(email) do :ok end ``` ### Handling Else Clauses **Official anti-pattern**: Complex `else` in `with` — keep else to 1-2 clauses max. Normalize errors in helpers instead. ```elixir # ✅ Simple: let non-matches pass through def create_user(params) do with {:ok, validated} <- validate(params), {:ok, user} <- Repo.insert(validated) do {:ok, user} end # {:error, changeset} passes through unchanged end # ✅ When you need to transform errors def create_user(params) do with {:ok, validated} <- validate(params), {:ok, user} <- Repo.insert(validated) do {:ok, user} else {:error, %Ecto.Changeset{} = changeset} -> {:error, :validation_failed, changeset} {:error, reason} -> {:error, :creation_failed, reason} end end # ❌ Avoid complex else clauses - normalize in helpers # Instead of many else branches, make helpers return consistent errors defp validate(params) do case Validator.validate(params) do :ok -> {:ok, params} {:error, reasons} -> {:error, {:validation, reasons}} end end ``` ## Real-World Examples from Production Code ### Context Function with Authorization ```elixir def update_deal(broker, deal_id, params) do with {:ok, deal} <- get_deal(deal_id), :ok <- Bodyguard.permit(Deal, :update_deal, broker, deal), {:ok, updated} <- do_update_deal(deal, params) do broadcast_update(updated) {:ok, updated} end end ``` ### Query Building ```elixir def list_articles(profile_id, opts \\ []) do Article |> where(profile_id: ^profile_id) |> filter_by_search(opts[:search]) |> filter_by_status(opts[:status]) |> filter_by_rating(opts[:rating]) |> apply_sort(opts[:sort_by], opts[:sort_order]) |> maybe_limit(opts[:limit]) |> Repo.all() end ``` ### Changeset Operations ```elixir def confirm_guest(guest, params) do guest |> Guest.confirm_changeset(params) |> maybe_update_attendees(params) |> Repo.update() end ``` ### Multi-Step Billing Check ```elixir def check_feature_access(user, feature) do with true <- billing_enforced?(), {:ok, subscription} <- get_active_subscription(user), true <- feature_included?(subscription, feature) do :ok else false -> {:error, :feature_not_available} {:error, :no_subscription} -> {:error, :subscription_required} end end ``` ## Summary | Pattern | Use When | Avoid When | |---------|----------|------------| | `\|>` pipe | Data transformation, query building, changesets | Single calls, complex branching | | `with` | Multiple dependent fallible operations | Single operation, no failures possible | | `case` | Pattern matching single value | Chaining multiple operations | | Nested `case` | Never | Always - use `with` instead | ## Anti-Pattern: Avoiding Pipes and With ```elixir # ❌ This is NOT more readable result1 = function1(data) result2 = function2(result1) result3 = function3(result2) final = function4(result3) # ✅ Pipes are clearer for transformations final = data |> function1() |> function2() |> function3() |> function4() # ❌ Nested case is hard to follow case get_user(id) do {:ok, user} -> case get_subscription(user) do {:ok, sub} -> case check_feature(sub, feature) do :ok -> {:ok, user} error -> error end error -> error end error -> error end # ✅ With flattens the happy path with {:ok, user} <- get_user(id), {:ok, sub} <- get_subscription(user), :ok <- check_feature(sub, feature) do {:ok, user} end ```
-
-
SKILL.md 4.8 KB
--- name: elixir-idioms description: "OTP/BEAM patterns and Elixir idioms — GenServer, Supervisor, Task, Registry, pattern matching, with chains, pipes. Use when designing processes or debugging BEAM issues." effort: medium user-invocable: false --- # Elixir Idioms Reference for writing idiomatic Elixir code with BEAM-aware patterns. ## Iron Laws — Never Violate These 1. **NO PROCESS WITHOUT A RUNTIME REASON** — Processes model concurrency, state, isolation—NOT code structure 2. **MESSAGES ARE COPIED** — Keep messages small (except binaries >64 bytes) 3. **GUARDS USE `and`/`or`/`not`** — Never use short-circuit operators in guards (guards require boolean operands) 4. **CHANGESETS FOR EXTERNAL DATA** — Use `cast/4` for user input, `change/2` for internal 5. **RESCUE ONLY FOR EXTERNAL CODE** — Never use rescue for control flow 6. **NO DYNAMIC ATOM CREATION** — `String.to_atom(user_input)` causes memory leak (atoms aren't GC'd) 7. **@external_resource FOR COMPILE-TIME FILES** — Modules reading files at compile time MUST declare `@external_resource` 8. **SUPERVISE ALL LONG-LIVED PROCESSES** — Never bare `GenServer.start_link`/`Agent.start_link` in production. Use supervision trees 9. **WRAP THIRD-PARTY LIBRARY APIs** — Always facade external deps behind a project-owned module. Enables swapping without touching callers 10. **MIX TASKS START ONLY WHAT THEY NEED** — `Mix.Task.run("app.config")` + `Application.ensure_all_started/1`, never `Mix.Task.run("app.start")` (boots the FULL tree: endpoint port, Oban consuming) 11. **CAPTURE LOCALE BEFORE SPAWNING** — Gettext/CLDR locale is process-local. Read it in the caller and pass explicitly; a spawned Task/GenServer starts with the default locale ## BEAM Architecture (Why Elixir Works This Way) - **Processes are cheap (2.6KB)** — Spawn liberally for concurrency/isolation - **Complete memory isolation** — No shared state, no locks needed - **Messages are copied** (except binaries >64 bytes) — Keep messages small - **Per-process GC** — No global GC pauses - **"Let it crash"** — Supervisors restart to known-good state ## Core Principles 1. **Pattern match over conditionals** — Function heads first, then `case`, then `cond` 2. **Tagged tuples for expected failures** — `{:ok, _}`/`{:error, _}` for expected errors, raise for bugs 3. **Pipe operator for data transformation** — Start with data, never pipe single calls 4. **Let it crash** — Handle expected errors, crash on unexpected ones 5. **Explicit over implicit** — Be clear about intentions ## Quick Decision Trees ### Control Flow ``` Need patterns? → case (or function heads) Multiple operations? → with Boolean conditions? → cond (multiple) or if (single) ``` ### Error Handling ``` Expected failure? → {:ok, _}/{:error, _} tuples Unexpected/bug? → raise exception (let supervisor handle) External library? → rescue (only here!) ``` ### OTP ``` Need state? ├─ No → Plain functions ├─ Simple get/update → Agent or ETS ├─ Complex messages/timeouts → GenServer └─ One-off async → Task ``` ## Quick Patterns ```elixir # Pattern match in function head def process(%{status: :active} = user), do: activate(user) def process(%{status: :inactive} = user), do: deactivate(user) # with for happy path with {:ok, user} <- get_user(id), {:ok, order} <- create_order(user) do {:ok, order} end # Task for async Task.Supervisor.async_nolink(TaskSup, fn -> work() end) |> Task.yield(5000) || Task.shutdown(task) ``` ## Common Pitfalls | Wrong | Right | |-------|-------| | `length(list) == 0` | `list == []` or `Enum.empty?(list)` | | `list ++ [item]` | `[item \| list] \|> Enum.reverse()` | | `String.to_atom(input)` | `String.to_existing_atom(input)` | | `spawn(fn -> log(conn) end)` | `ip = conn.ip; spawn(fn -> log(ip) end)` | | `unless condition` | `if !condition` (unless deprecated in 1.18) | ## References For detailed patterns, see: - `${CLAUDE_SKILL_DIR}/references/pattern-matching.md` - Pattern matching, guards, binary matching - `${CLAUDE_SKILL_DIR}/references/otp-patterns.md` - GenServer, Supervisor, Task, Registry - `${CLAUDE_SKILL_DIR}/references/error-handling.md` - Tagged tuples, rescue, with - `${CLAUDE_SKILL_DIR}/references/with-and-pipes.md` - When to use `with` and `|>` (idiomatic patterns) - `${CLAUDE_SKILL_DIR}/references/troubleshooting.md` - Production BEAM debugging (memory, performance, crashes) - `${CLAUDE_SKILL_DIR}/references/anti-patterns.md` - Common mistakes and fixes - `${CLAUDE_SKILL_DIR}/references/mix-tasks.md` - Mix task naming, option parsing, shell output - `${CLAUDE_SKILL_DIR}/references/elixir-118-features.md` - Duration module, dbg improvements (1.18+) - `${CLAUDE_SKILL_DIR}/references/elixir-120-type-system.md` - Gradual type checker, `dynamic()`, verified bugs as compile warnings (1.20+, OTP 27+)
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.