This skill should be used when the user asks to "implement a feature in Elixir", "refactor this module", "should I use a GenServer here?", "how should I structure this?", "use the pipe operator", "add error handling", "make this concurrent", or mentions protocols, behaviours, pattern matching, with statements, comprehensions, structs, or coming from an OOP background. Contains paradigm-shifting insights.
Scanned 2/12/2026
Install via CLI
openskills install georgeguimaraes/claude-code-elixir---
name: elixir-thinking
description: This skill should be used when the user asks to "implement a feature in Elixir", "refactor this module", "should I use a GenServer here?", "how should I structure this?", "use the pipe operator", "add error handling", "make this concurrent", or mentions protocols, behaviours, pattern matching, with statements, comprehensions, structs, or coming from an OOP background. Contains paradigm-shifting insights.
---
# Elixir Thinking
Mental shifts required before writing Elixir. These contradict conventional OOP patterns.
## The Iron Law
```
NO PROCESS WITHOUT A RUNTIME REASON
```
Before creating a GenServer, Agent, or any process, answer YES to at least one:
1. Do I need mutable state persisting across calls?
2. Do I need concurrent execution?
3. Do I need fault isolation?
**All three are NO?** Use plain functions. Modules organize code; processes manage runtime.
## The Three Decoupled Dimensions
OOP couples behavior, state, and mutability together. Elixir decouples them:
| OOP Dimension | Elixir Equivalent |
|---------------|-------------------|
| Behavior | Modules (functions) |
| State | Data (structs, maps) |
| Mutability | Processes (GenServer) |
Pick only what you need. "I only need data and functions" = no process needed.
## "Let It Crash" = "Let It Heal"
The misconception: Write careless code.
The truth: Supervisors START processes.
- Handle expected errors explicitly (`{:ok, _}` / `{:error, _}`)
- Let unexpected errors crash → supervisor restarts
## Control Flow
**Pattern matching first:**
- Match on function heads instead of `if/else` or `case` in bodies
- `%{}` matches ANY map—use `map_size(map) == 0` guard for empty maps
- Avoid nested `case`—refactor to single `case`, `with`, or separate functions
**Error handling:**
- Use `{:ok, result}` / `{:error, reason}` for operations that can fail
- Avoid raising exceptions for control flow
- Use `with` for chaining `{:ok, _}` / `{:error, _}` operations
**Be explicit about expected cases:**
- Avoid `_ -> nil` catch-alls—they silently swallow unexpected cases
- Avoid `value && value.field` nil-punning—obscures actual return types
- When a case has `{:ok, nil} -> nil` alongside `{:ok, value} -> value.field`, use `with` instead:
```elixir
# Verbose
case get_run(id) do
{:ok, nil} -> nil
{:ok, run} -> run.recommendations
end
# Prefer
with {:ok, %{recommendations: recs}} <- get_run(id), do: recs
```
## Polymorphism
| For Polymorphism Over... | Use | Contract |
|--------------------------|-----|----------|
| Modules | Behaviors | Upfront callbacks |
| Data | Protocols | Upfront implementations |
| Processes | Message passing | Implicit (send/receive) |
**Behaviors** = default for module polymorphism (very cheap at runtime)
**Protocols** = only when composing data types, especially built-ins
**Message passing** = only when stateful by design (IO, file handles)
Use the simplest abstraction: pattern matching → anonymous functions → behaviors → protocols → message passing. Each step adds complexity.
**When justified:** Library extensibility, multiple implementations, test swapping.
**When to stay coupled:** Internal module, single implementation, pattern matching handles all cases.
## Data Modeling Replaces Class Hierarchies
OOP: Complex class hierarchy + visitor pattern.
Elixir: Model as data + pattern matching + recursion.
```elixir
{:sequence, {:literal, "rain"}, {:repeat, {:alternation, "dogs", "cats"}}}
def interpret({:literal, text}, input), do: ...
def interpret({:sequence, left, right}, input), do: ...
def interpret({:repeat, pattern}, input), do: ...
```
## Defaults and Options
Use `/3` variants (`Keyword.get/3`, `Map.get/3`) instead of case statements branching on `nil`:
```elixir
# WRONG
case Keyword.get(opts, :chunker) do
nil -> chunker()
config -> parse_chunker_config(config)
end
# RIGHT
Keyword.get(opts, :chunker, :default) |> parse_chunker_config()
```
Don't create helper functions to merge config defaults. Inline the fallback:
```elixir
# WRONG
defp merge_defaults(opts), do: Keyword.merge([repo: Application.get_env(:app, :repo)], opts)
# RIGHT
def some_function(opts) do
repo = opts[:repo] || Application.get_env(:app, :repo)
end
```
## Idioms
- Process dictionary is typically unidiomatic—pass state explicitly
- Reserve `is_thing` names for guards only
- Use structs over maps when shape is known: `defstruct [:name, :age]`
- Prepend to lists `[new | list]` not `list ++ [new]`
- Use `dbg/1` for debugging—prints formatted value with context
- Use built-in `JSON` module (Elixir 1.18+) instead of Jason
## Testing
**Test behavior, not implementation.** Test use cases / public API. Refactoring shouldn't break tests.
**Test your code, not the framework.** If deleting your code doesn't fail the test, it's tautological.
**Keep tests async.** `async: false` means you've coupled to global state. Fix the coupling:
| Problem | Solution |
|---------|----------|
| `Application.put_env` | Pass config as function argument |
| Feature flags | Inject via process dictionary or context |
| ETS tables | Create per-test tables with unique names |
| External APIs | Use Mox with explicit allowances |
| File system operations | Use `@tag :tmp_dir` (see below) |
**Use `tmp_dir` for file tests.** ExUnit creates unique temp directories per test, async-safe:
```elixir
@tag :tmp_dir
test "writes file", %{tmp_dir: tmp_dir} do
path = Path.join(tmp_dir, "test.txt")
File.write!(path, "content")
assert File.read!(path) == "content"
end
```
Directory is auto-cleaned before each run. Works with `@moduletag :tmp_dir` for all tests in module.
## Common Rationalizations
| Excuse | Reality |
|--------|---------|
| "I need a process to organize this code" | Modules organize code. Processes are for runtime. |
| "GenServer is the Elixir way" | Plain functions are also the Elixir way. |
| "I'll need state eventually" | YAGNI. Add process when you need it. |
| "It's just a simple wrapper process" | Simple wrappers become bottlenecks. |
| "This is how I'd structure it in OOP" | Rethink from data flow. |
## Red Flags - STOP and Reconsider
- Creating process without answering the three questions
- Using GenServer for stateless operations
- Wrapping a library in a process "for safety"
- One process per entity without runtime justification
- Reaching for protocols when pattern matching works
**Any of these? Re-read The Iron Law.**
No comments yet. Be the first to comment!