Skip to content

Instantly share code, notes, and snippets.

@ZachParsons
Created January 22, 2026 19:22
Show Gist options
  • Select an option

  • Save ZachParsons/8e073348e70552439ed7a52c3c8f6a21 to your computer and use it in GitHub Desktop.

Select an option

Save ZachParsons/8e073348e70552439ed7a52c3c8f6a21 to your computer and use it in GitHub Desktop.

Clean Elixir

(13) Rules for Writing Idiomatic Code

A practical guide aligned with Credo and the Elixir Community Style Guide.


1. Naming

Use conventional Elixir naming to express intent clearly.

  • Modules: CamelCase as nouns describing the domain concept. Keep acronyms uppercase (MyApp.HTTPClient, not MyApp.HttpClient).
  • Functions: snake_case as verbs describing the action (fetch_user, validate_input).
  • Variables/parameters: snake_case as nouns describing the data (user_params, connection).
  • Predicates: Use trailing ? for boolean-returning functions (valid?, active?). Use is_ prefix only for guard-safe functions (defguard is_admin(user)).
  • Exceptions: End with Error suffix (InvalidTokenError).

Prefer pronounceable, unabbreviated names. Let names reveal purpose within the call graph.

# Preferred
defmodule MyApp.OrderProcessor do
  def process_order(order_params), do: ...
  def valid?(order), do: ...
  defguard is_complete(order) when order.status == :complete
end

# Avoid
defmodule MyApp.OrdProc do
  def proc(params), do: ...
  def isValid(ord), do: ...
end

Credo checks: Readability.FunctionNames, Readability.ModuleNames, Readability.PredicateFunctionNames, Consistency.ExceptionNames


2. Function Signatures

Favor clear, minimal parameter lists with pattern matching.

  • Tend toward one or two parameters; avoid "telephone passing" long argument lists.
  • Use guards for value constraints: nil, length/size, type checks, comparisons, membership (in), boolean, arithmetic.
  • Match on structs in function heads to enforce expected shapes.
  • Bind variables by pattern matching in the function head, not in the body.
  • Pass functions using the capture operator (&Module.fun/arity).
# Preferred: pattern match and guard in head
def process(%Order{status: status} = order) when status in [:pending, :confirmed] do
  # order is bound and validated
end

def notify(user, message_fn) when is_function(message_fn, 1) do
  message_fn.(user)
end

# Avoid: extracting values in body
def process(order) do
  status = order.status
  if status in [:pending, :confirmed], do: ...
end

Credo checks: Refactor.FunctionArity, Readability.Specs


3. Control Flow and Composition

Prefer multi-clause functions over conditionals; use pipes for composition.

  • Use multiple function clauses with pattern matching instead of if, case, or cond where practical.
  • Use the pipe operator (|>) to chain transformations. Start pipes with a bare variable.
  • Avoid single-pipe expressions; just call the function directly.
  • Use parentheses in piped function calls.
# Preferred: multi-clause with pipes
def handle_response({:ok, body}), do: body |> decode() |> transform()
def handle_response({:error, reason}), do: {:error, reason}

# Preferred: bare variable starts the pipe
user_input
|> String.trim()
|> String.downcase()
|> validate()

# Avoid: single pipe
input |> String.trim()

# Prefer instead
String.trim(input)

# Avoid: conditional when clauses suffice
def handle_response(response) do
  case response do
    {:ok, body} -> ...
    {:error, reason} -> ...
  end
end

Credo checks: Refactor.CondStatements, Readability.SinglePipe, Refactor.PipeChainStart


4. Data Structures

Use semantic types; prefer structs over raw maps.

  • Define custom structs with defstruct to enforce required keys and default values.
  • Use @type to define custom types for clarity and Dialyzer support.
  • Conventionally: lists for ordered/mutable collections (commands, writes); tuples for fixed-size groupings (queries, reads, return values).
defmodule MyApp.User do
  @type t :: %__MODULE__{
    name: String.t(),
    email: String.t(),
    role: :admin | :member
  }

  defstruct [:name, :email, role: :member]
end

# Preferred: struct with explicit shape
%User{name: "Alice", email: "[email protected]"}

# Avoid: bare map when structure is known
%{name: "Alice", email: "[email protected]", role: :member}

Credo checks: Readability.Specs (for @type documentation)


5. Recursion and Enumeration

Use multi-clause recursion for clarity; tail-call optimize for scale.

  • Prefer multi-clause recursive functions over Enum.reduce with complex accumulators when the logic is clearer.
  • Use tail-recursive functions (accumulator in parameters) for very large collections (millions of elements).
  • Use Enum conveniences (map, filter, reduce) for typical collection operations.
  • Consider Stream for lazy evaluation of large or infinite sequences.
# Multi-clause recursion for clarity
def sum([]), do: 0
def sum([head | tail]), do: head + sum(tail)

# Tail-recursive for large collections
def sum_large(list), do: do_sum(list, 0)
defp do_sum([], acc), do: acc
defp do_sum([head | tail], acc), do: do_sum(tail, acc + head)

# Enum for typical cases
Enum.sum(list)
Enum.map(users, &User.activate/1)

Credo checks: Refactor.Nesting (deep nesting suggests refactoring)


6. Error Handling

Bubble up context with tagged tuples and with; avoid defensive coding.

  • Return tagged tuples ({:ok, value} or {:error, reason}) to propagate results.
  • Use with to chain operations that may fail, unwrapping as you go.
  • Define custom error structs for domain-specific failures.
  • Reserve try/catch/rescue for genuinely exceptional, unguaranteed operations (external APIs, file I/O with uncertain state).
  • Let processes crash and rely on supervisors for fault tolerance—this is "let it crash" philosophy.
# Preferred: with for happy-path chaining
def create_order(params) do
  with {:ok, validated} <- validate(params),
       {:ok, order} <- persist(validated),
       {:ok, _} <- notify_warehouse(order) do
    {:ok, order}
  else
    {:error, :validation_failed} -> {:error, "Invalid order data"}
    {:error, :db_error} -> {:error, "Could not save order"}
    {:error, reason} -> {:error, reason}
  end
end

# Custom error for domain clarity
defmodule MyApp.InvalidOrderError do
  defexception [:message, :field]
end

# Avoid: defensive try/catch around normal operations
def fetch_user(id) do
  try do
    Repo.get!(User, id)
  rescue
    _ -> nil  # Hides real errors
  end
end

# Prefer: let it crash or use Repo.get/2
def fetch_user(id), do: Repo.get(User, id)

Credo checks: Refactor.WithClauses, Warning.RaiseInsideRescue


7. Module Organization

Order module contents consistently; use extension points appropriately.

Follow this ordering within modules (with blank lines between groups):

  1. @moduledoc
  2. @behaviour
  3. use
  4. import
  5. require
  6. alias
  7. Module attributes (@constant)
  8. defstruct
  9. Type definitions (@type, @typep)
  10. Callbacks (@callback, @macrocallback, @optional_callbacks)
  11. Public functions
  12. Private functions

Sort aliases alphabetically. Use __MODULE__ for self-references.

defmodule MyApp.OrderService do
  @moduledoc """
  Handles order processing and fulfillment.
  """

  use GenServer

  import MyApp.Guards

  alias MyApp.{Order, User, Warehouse}

  @timeout 5_000

  defstruct [:order, :status]

  @type t :: %__MODULE__{order: Order.t(), status: atom()}

  # Public API
  def start_link(opts), do: GenServer.start_link(__MODULE__, opts)

  def process(pid, order), do: GenServer.call(pid, {:process, order})

  # GenServer callbacks
  @impl true
  def init(opts), do: {:ok, %__MODULE__{}}

  # Private helpers
  defp validate(order), do: ...
end

Credo checks: Readability.AliasOrder, Readability.ModuleDoc, Design.AliasUsage


8. Typespecs and Documentation

Document behavior with @spec, @type, @moduledoc, and @doc.

  • Use @spec on public functions to document inputs and outputs; Dialyzer will verify them.
  • Define @type for custom types, especially algebraic data types (sum types with |, product types with structs).
  • Every module should have @moduledoc; use @moduledoc false for internal modules.
  • Use @doc to explain non-obvious functions. Include examples with ## Examples in heredocs.
defmodule MyApp.Calculator do
  @moduledoc """
  Provides arithmetic operations with overflow protection.

  ## Examples

      iex> Calculator.safe_add(1, 2)
      {:ok, 3}

  """

  @type result :: {:ok, integer()} | {:error, :overflow}

  @doc """
  Adds two integers, returning an error if overflow occurs.
  """
  @spec safe_add(integer(), integer()) :: result()
  def safe_add(a, b) when is_integer(a) and is_integer(b) do
    # implementation
  end
end

Credo checks: Readability.ModuleDoc, Readability.Specs


9. Concurrency Patterns

Use the right OTP abstraction for the job.

Pattern Use Case
Task One-off async work, parallel map
Task.Supervisor Supervised async work with fault tolerance
Agent Simple state without complex logic
GenServer Stateful processes with message handling
Supervisor Fault tolerance through process supervision
Stream Lazy evaluation of sequences

Tasks: Supervised vs Unsupervised

Unsupervised tasks (Task.async/1) link to the caller. If the task crashes, the caller crashes. If the caller crashes, the task is terminated. Use these for work that is integral to the caller's success.

# Unsupervised: caller and tasks are linked
# If any task crashes, the caller crashes too
tasks = Enum.map(urls, &Task.async(fn -> fetch(&1) end))
results = Task.await_many(tasks)

Supervised tasks (Task.Supervisor) are managed by a supervisor. They can crash independently without taking down the caller, and the supervisor can restart them or apply backpressure. Use these for:

  • Fire-and-forget work: Sending emails, logging to external services, analytics events
  • Work that may fail: External API calls where you don't want to crash the caller
  • Controlled concurrency: Limiting how many tasks run simultaneously
  • Long-running request handlers: Web requests that spawn background work
# Add to your supervision tree (application.ex)
children = [
  {Task.Supervisor, name: MyApp.TaskSupervisor}
]

# Fire-and-forget: don't wait for result, won't crash caller if it fails
Task.Supervisor.start_child(MyApp.TaskSupervisor, fn ->
  send_welcome_email(user)
end)

# Async with fault isolation: get result but isolate failures
task = Task.Supervisor.async_nolink(MyApp.TaskSupervisor, fn ->
  fetch_from_flaky_api(id)
end)

case Task.yield(task, 5_000) || Task.shutdown(task) do
  {:ok, result} -> {:ok, result}
  {:exit, reason} -> {:error, :task_failed}
  nil -> {:error, :timeout}
end

# Controlled concurrency: max 10 simultaneous tasks
Task.Supervisor.async_stream_nolink(
  MyApp.TaskSupervisor,
  urls,
  &fetch/1,
  max_concurrency: 10,
  ordered: false
)
|> Enum.to_list()

Decision guide:

Scenario Use
Need the result, failure is fatal Task.async/1 + Task.await/2
Need the result, failure is recoverable Task.Supervisor.async_nolink/2 + Task.yield/2
Don't need result, best-effort delivery Task.Supervisor.start_child/2
Many items, controlled parallelism Task.Supervisor.async_stream_nolink/4
Parallel map, all must succeed Task.async_stream/3

GenServer for Stateful Services

defmodule MyApp.Counter do
  use GenServer

  def start_link(initial), do: GenServer.start_link(__MODULE__, initial)
  def increment(pid), do: GenServer.call(pid, :increment)

  @impl true
  def init(count), do: {:ok, count}

  @impl true
  def handle_call(:increment, _from, count), do: {:reply, count + 1, count + 1}
end

10. Behaviours: Contracts for Modules

Use behaviours to define and enforce module interfaces.

A behaviour is a contract: it specifies which functions a module must implement. The compiler verifies compliance at compile time.

When to use behaviours:

  • Defining a plugin or adapter interface (e.g., different storage backends)
  • Ensuring consistent APIs across modules that serve similar purposes
  • Creating extension points in libraries or frameworks
  • When you need compile-time guarantees that implementations are complete

When NOT to use behaviours:

  • For polymorphism over data types (use protocols instead)
  • When you only have one implementation (premature abstraction)
  • For simple delegation (just call the module directly)
# Define the contract
defmodule MyApp.Cache do
  @moduledoc "Behaviour for cache implementations."

  @type key :: String.t()
  @type value :: term()

  @callback get(key()) :: {:ok, value()} | {:error, :not_found}
  @callback put(key(), value(), keyword()) :: :ok | {:error, term()}
  @callback delete(key()) :: :ok

  @optional_callbacks delete: 1
end

# Implement the contract
defmodule MyApp.Cache.ETS do
  @behaviour MyApp.Cache

  @impl true
  def get(key), do: ...

  @impl true
  def put(key, value, _opts), do: ...

  # delete/1 is optional, so we can omit it
end

# Use the implementation (typically injected via config or parameter)
defmodule MyApp.UserService do
  @cache Application.compile_env(:my_app, :cache_module, MyApp.Cache.ETS)

  def get_user(id) do
    case @cache.get("user:#{id}") do
      {:ok, user} -> user
      {:error, :not_found} -> fetch_and_cache(id)
    end
  end
end

Key points:

  • Use @callback to declare required functions with their specs
  • Use @optional_callbacks for functions implementations may omit
  • Use @impl true in implementations to signal intent and get compiler warnings if the function doesn't match a callback
  • Behaviours enable dependency injection and make testing easier

11. Protocols: Polymorphism Over Data

Use protocols to dispatch on data type; use behaviours to dispatch on module.

Protocols provide ad-hoc polymorphism—different implementations based on the data type passed in. They're Elixir's answer to interfaces that operate on data rather than modules.

When to use protocols:

  • Operating differently based on data type (the classic polymorphism use case)
  • Extending functionality for types you don't control (e.g., third-party structs)
  • When the data "knows" how to perform an operation (data-driven dispatch)

When to use behaviours instead:

  • The caller chooses which implementation to use (not determined by data)
  • You need compile-time implementation guarantees
  • Defining adapter/plugin interfaces
# Define a protocol
defprotocol MyApp.Renderable do
  @doc "Renders the data structure as an HTML string."
  @spec to_html(t()) :: String.t()
  def to_html(data)
end

# Implement for your structs
defimpl MyApp.Renderable, for: MyApp.User do
  def to_html(%MyApp.User{name: name, email: email}) do
    "<div class=\"user\"><h2>#{name}</h2><p>#{email}</p></div>"
  end
end

defimpl MyApp.Renderable, for: MyApp.Product do
  def to_html(%MyApp.Product{name: name, price: price}) do
    "<div class=\"product\"><h3>#{name}</h3><span>$#{price}</span></div>"
  end
end

# Implement for built-in types
defimpl MyApp.Renderable, for: BitString do
  def to_html(string), do: "<p>#{string}</p>"
end

# Use polymorphically—dispatch is automatic based on data type
def render_all(items) do
  items
  |> Enum.map(&MyApp.Renderable.to_html/1)
  |> Enum.join("\n")
end

Protocol vs Behaviour decision guide:

Question Protocol Behaviour
What determines the implementation? The data type passed in The caller's choice
Can you extend types you don't own? Yes N/A
Compile-time verification? No (runtime dispatch) Yes
Typical use Data transformation, formatting Adapters, plugins, strategies

Derive for automatic implementations:

# If a protocol provides @derive support
defmodule MyApp.Event do
  @derive [MyApp.Renderable]  # Use default implementation
  defstruct [:type, :payload]
end

12. Macros: Use Sparingly, Inspect Thoroughly

Avoid macros unless the abstraction cannot be achieved with functions.

Macros operate on the AST at compile time. They're powerful but obscure: readers can't understand macro-generated code without mentally expanding it, stack traces point to generated code, and debugging becomes significantly harder.

Legitimate macro use cases:

  • DSLs where syntax matters (e.g., ExUnit's test, Ecto's schema)
  • Compile-time code generation from external data
  • Wrapping boilerplate that truly cannot be a function (e.g., use callbacks)

Avoid macros when:

  • A function would work (most of the time)
  • You're just reducing typing (that's not a good tradeoff)
  • The abstraction isn't well-understood yet (macros are hard to change)
# ❌ Avoid: macro for something a function can do
defmacro add(a, b) do
  quote do: unquote(a) + unquote(b)
end

# ✅ Prefer: just use a function
def add(a, b), do: a + b

# ✅ Legitimate: DSL where syntax/compile-time matters
defmacro field(name, type, opts \\ []) do
  quote do
    Module.put_attribute(__MODULE__, :fields, {unquote(name), unquote(type), unquote(opts)})
  end
end

When you must use macros, use decompile to verify the generated code:

Install the decompile tool:

mix archive.install github michalmuskala/decompile

Inspect what your macros actually generate:

# See the expanded Elixir code (most useful for understanding macros)
mix decompile MyApp.SomeModule --to expanded

# See the Erlang code (useful for performance analysis)
mix decompile MyApp.SomeModule --to erlang

# See the BEAM assembly (useful for deep optimization)
mix decompile MyApp.SomeModule --to asm

# See the Core Erlang (intermediate representation)
mix decompile MyApp.SomeModule --to core

Example workflow:

# You wrote this
defmodule MyApp.User do
  use MyApp.Schema

  schema "users" do
    field :name, :string
    field :email, :string
  end
end
# See what it actually becomes
$ mix decompile MyApp.User --to expanded

# Output shows the generated functions, struct definition, etc.

Macro hygiene checklist:

  • Can this be a function instead? (If yes, use a function)
  • Have I tested the generated code with decompile --to expanded?
  • Are variable names properly hygienic (use var! only when intentional)?
  • Does the macro compose well with other macros?
  • Is the error message clear when misused?
  • Have I documented what code gets generated?

Credo check: Refactor.LongQuoteBlocks (warns when quote blocks are too large—consider extracting to functions)


13. Tooling and Reflection

Use mix format, Credo, and Dialyzer; iterate toward clarity.

  • Run mix format on every save—formatting is automated and non-negotiable.
  • Run mix credo --strict to catch style and consistency issues.
  • Run mix dialyzer to verify typespecs and catch type errors.
  • Use IEx.pry (via require IEx; IEx.pry()) for interactive debugging.

Workflow: Write pseudocode or a TODO list → write a test → implement → format → run Credo → run Dialyzer.

# Standard quality checks
mix format --check-formatted
mix credo --strict
mix dialyzer
mix test

Clean code is an ongoing practice: sort, systematize, refine, standardize, and maintain discipline.


Quick Reference: Credo Categories

Category Focus
Consistency Uniform style (tabs vs spaces, naming patterns)
Readability Ease of comprehension (function names, module docs)
Refactor Opportunities to simplify (reduce nesting, extract functions)
Design Architectural guidance (alias usage, TODOs/FIXMEs)
Warning Potential bugs (unused operations, IEx.pry left in code)

References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment