elixir-essentials
Use this skill before writing any .ex or .exs file.
Works with
Agent Skills format with YAML frontmatter. Claude Code reads it as-is.
---
name: "elixir-essentials"
description: "Use this skill before writing any .ex or .exs file."
license: "MIT"
---
# Elixir Essentials
Use this skill before writing **any** `.ex` or `.exs` file.
Canonical standard: [`docs/fcis-engineering-rules.md`](../../docs/fcis-engineering-rules.md).
**Quick reference:** [FCIS checklist](assets/fcis_checklist.md) — run before shipping `.ex` / `.exs` files.
## Quick Reference
| Concern | Do this |
|---------|---------|
| Business rules | `MyApp.<Context>.<Concept>` — no `Repo` / HTTP / process sends |
| Fallible flows | `{:ok, _} \| {:error, _}` + `with` |
| Control flow | Multi-clause + guards, not nested `if` |
| Transforms | Linear pipes; named steps |
| External input | Parse to struct/changeset at the edge |
| Callbacks | `@impl true`; keep thin |
| Checklist | [assets/fcis_checklist.md](assets/fcis_checklist.md) |
## RULES — Follow these with no exceptions
**1.** **Functional Core, Imperative Shell** — pure functions for rules/transforms; DB/HTTP/process/IO only at edges
**2.** **Pattern match and guards** over nested `if` / `unless` / deep `case`
**3.** **Tagged tuples** — fallible ops return `{:ok, result} | {:error, reason}`; chain with `with`
**4.** **Linear pipes** — subject first; named steps; no `|> case do`. `then/2` is fine for a one-off; extract a named function when the step is reused or is a domain concept
**5.** **Parse at the boundary** — coerce maps into structs/changesets before pure core
**6.** **Context is the shell** — `MyApp.Blog` fetches and persists; `MyApp.Blog.Publishing` / `MyApp.Orders.Pricing` hold rules
**7.** **`@impl true`** on every behaviour callback
**8.** **Immutability** — never mutate data in place
**9.** **Predicates end with `?`**, bang (`!`) only for dangerous/raising ops
**10.** **No `String.to_atom/1` on user input** — prefer strings; allowlist before any atom conversion
**11.** **No monads / category-theory libs** — idiomatic Elixir only (`with`, tagged tuples, structs)
**12.** **`@doc` / `@moduledoc`** on public APIs
**13.** **Prefer `for`** over chaining 3+ `Enum` passes when one pass is clearer
## 1. Functional Core, Imperative Shell
❌ **Bad:** business rules mixed with side effects
```elixir
def checkout(order_id) do
order = Repo.get!(Order, order_id)
total = Enum.reduce(order.lines, 0, &(&1.amount + &2))
Payments.charge(order, total)
end
```
✅ **Good:** pure core + thin shell
```elixir
defmodule MyApp.Orders.Pricing do
def total(%{lines: lines}), do: Enum.reduce(lines, 0, &(&1.amount + &2))
def discount(total, :vip) when total > 100, do: div(total, 10)
def discount(_total, _tier), do: 0
end
defmodule MyApp.Orders do
alias MyApp.Orders.Pricing
def checkout(order_id) do
with {:ok, order} <- fetch(order_id),
total <- Pricing.total(order),
{:ok, charge} <- Payments.charge(order, total) do
{:ok, charge}
end
end
end
```
## 2. Pattern matching & guards
❌ **Bad:** nested conditionals
```elixir
def handle_response(%{status: s} = r) do
if s == 200, do: {:ok, r.body}, else: {:error, :bad_status}
end
```
✅ **Good:** multi-clause dispatch (pure mapper — not a behaviour callback)
```elixir
def handle_response(%{status: 200, body: body}), do: {:ok, body}
def handle_response(%{status: 404}), do: {:error, :not_found}
def handle_response(%{status: status}), do: {:error, {:bad_status, status}}
def calculate(x) when is_integer(x) and x > 0, do: x * 2
def calculate(_), do: {:error, :invalid_input}
```
## 3. Railway flow: tagged tuples + `with`
❌ **Bad:** nested `case`
```elixir
def create_post(params) do
case validate(params) do
{:ok, attrs} ->
case Repo.insert(Post.changeset(%Post{}, attrs)) do
{:ok, post} -> {:ok, post}
error -> error
end
error -> error
end
end
```
✅ **Good:** `with` for sequential fallible steps
```elixir
def create_post(params) do
with {:ok, attrs} <- validate(params) # e.g. Blog.validate_post_attrs/1 (not shown),
{:ok, post} <- Repo.insert(Post.changeset(%Post{}, attrs)) do
{:ok, post}
end
end
```
✅ **Good:** optional `else` for normalized edge errors
```elixir
def transfer(from_id, to_id, amount) do
with {:ok, from} <- get_account(from_id),
{:ok, to} <- get_account(to_id),
:ok <- ensure_funds(from, amount),
{:ok, _} <- debit(from, amount),
{:ok, _} <- credit(to, amount) do
{:ok, :complete}
else
{:error, :insufficient_funds} -> {:error, :insufficient_funds}
{:error, :not_found} -> {:error, :not_found}
other -> {:error, {:transfer_failed, other}}
end
end
```
## 4. Pipe linearity
❌ **Bad:** pipe into `case`
```elixir
params
|> case do
%{"id" => id} -> Repo.get(User, id)
_ -> nil
end
```
✅ **Good:** linear named steps
```elixir
params
|> Map.fetch!("id")
|> Users.get()
```
Pipes should read as **one subject transformed**. `then/2` (and `tap/2` for debug/side-log) is idiomatic for a one-off. Extract a named function when the step is reused or names a domain concept. Never `|> case do`.
## 5. Explicit shapes at the boundary
❌ **Bad:** untyped maps deep in core logic
```elixir
def register(params) do
email = params["email"]
create_user(%{email: email, role: String.to_atom(params["role"])})
end
```
✅ **Good:** parse once, then use a known shape
```elixir
def register(params) when is_map(params) do
case Registration.changeset(params) |> Ecto.Changeset.apply_action(:insert) do
{:ok, data} -> create_user(data)
{:error, cs} -> {:error, cs}
end
end
```
## Naming
| Element | Convention | Example |
|---------|------------|---------|
| Modules | `PascalCase` | `MyApp.Accounts.User` |
| Functions / vars | `snake_case` | `create_user/1` |
| Predicates | end with `?` | `valid?/1` |
| Raising | end with `!` | `get_user!/1` — avoid in app logic |
### Context vs core
The context module is the **imperative shell**. Business rules live in a sibling core module — not in the LiveView, worker, or the context's `Repo` function.
```text
lib/my_app/blog.ex # shell: fetch, persist, enqueue
lib/my_app/blog/post.ex # schema
lib/my_app/blog/publishing.ex # pure core: can_publish?/1, apply/1
lib/my_app/orders.ex # shell
lib/my_app/orders/pricing.ex # pure core: total/1, with_discount/2
```
```elixir
# ✅ Shell
def publish_post(%Scope{} = scope, post) do
with :ok <- Publishing.ensure_publishable(post),
{:ok, post} <- persist_published(scope, post) do
{:ok, post}
end
end
# ✅ Core — no Repo
defmodule MyApp.Blog.Publishing do
def ensure_publishable(%{status: :draft, title: title}) when byte_size(title) > 0, do: :ok
def ensure_publishable(_post), do: {:error, :not_publishable}
end
```
## List work
❌ **Bad:** three passes over the same list
```elixir
list
|> Enum.map(&transform/1)
|> Enum.filter(&valid?/1)
|> Enum.map(&format/1)
```
✅ **Good:** one pass when clearer
```elixir
for item <- list,
transformed = transform(item),
valid?(transformed) do
format(transformed)
end
```
## Atoms & user input
| ❌ Don't | ✅ Do |
|----------|-------|
| `String.to_atom(user_input)` | Keep strings, or allowlist then convert |
| Blind `to_existing_atom/1` on arbitrary input | Allowlist first — unknown values raise `ArgumentError` |
## Let it crash
Do not write defensive code for impossible states after you have validated at the boundary. Supervisors recover process failures; pure code should not hide bugs with broad `rescue`.
## Common pitfalls
| ❌ Don't | ✅ Do |
|----------|-------|
| Business math mixed with `Repo` | `MyApp.Orders.Pricing` + thin `MyApp.Orders` shell |
| Nested `if` / `case` for sequential fallible steps | `with` + tagged tuples |
| `|> case do` | Named step or multi-clause function |
| Mutate maps/lists in place | Return new values |
| Skip `@impl true` on callbacks | Annotate every callback |
| Monads / custom FP frameworks | Idiomatic Elixir |
## Integration
| Predecessor | This Skill | Successor |
|-------------|------------|-----------|
| None (always first) | elixir-essentials | otp-essentials |
| None (always first) | elixir-essentials | testing-essentials |
| None (always first) | elixir-essentials | typespec-dialyzer |
**See also:** `docs/fcis-engineering-rules.md`, `otp-essentials`, domain skills (Ecto/LiveView/Oban keep edges thin).More General & Other skills
find-skills
vercel-labs/skills
Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.
grill-me
mattpocock/skills
A relentless interview to sharpen a plan or design.
grill-with-docs
mattpocock/skills
A relentless interview to sharpen a plan or design, which also creates docs (ADR's and glossary) as we go.

