Elixir Essentials
Use this skill before writing any .ex or .exs file.
Canonical standard: docs/fcis-engineering-rules.md.
Quick reference: FCIS checklist — 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 |
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
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
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
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)
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
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
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
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
params
|> case do
%{"id" => id} -> Repo.get(User, id)
_ -> nil
end
✅ Good: linear named steps
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
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
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.
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
# ✅ 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
list
|> Enum.map(&transform/1)
|> Enum.filter(&valid?/1)
|> Enum.map(&format/1)
✅ Good: one pass when clearer
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` |
| 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).