← Back to skills
extension
Category: Development & EngineeringAPI key requirement unconfirmed

elixir-thinking

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.

personAuthor: jakexiaohubgithub

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:
# 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.

{: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:

# 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:

# 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

Always prefix mix commands with unbuffer to get ANSI colors and prevent stdout block-buffering in non-TTY environments (e.g. unbuffer mix test). Install: brew install expect (macOS) or apt install expect (Linux).

Prefer pattern matching over imperative assertions. Never use assert length + Enum.at/List.last/hd. Pattern match checks length and content in one shot:

# Bad
assert length(students) == 2
assert Enum.at(students, 0).name == "Alice"
assert Enum.at(students, 1).name == "Bob"

# Good
assert [%{name: "Alice"}, %{name: "Bob"}] = students

Same goes for type-only predicates: assert is_map(user) / assert is_list(posts) pass for almost any non-error return. Pattern match the shape and content together: assert %User{email: "a@b.com"} = user. is_nil/1 is fine when nil-ness is the whole point.

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:

@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.