Skip to content

Latest commit

 

History

History
901 lines (670 loc) · 21.2 KB

File metadata and controls

901 lines (670 loc) · 21.2 KB

Elixir Coding Conventions

This document defines idiomatic Elixir patterns for any project running on the BEAM. These are not style preferences — they reflect how the runtime actually works. Writing Elixir that looks like Python or Ruby is not acceptable, even if it compiles and runs.


The BEAM Philosophy

The BEAM is not a faster Python runtime. It is a fault-tolerant, concurrent, distributed virtual machine designed for systems that must run forever. Every pattern in this document exists because it maps to how the BEAM works:

  • Processes are cheap — spawn them, let them crash, restart them
  • Immutability is enforced — data does not change, it is transformed
  • Pattern matching is the primary dispatch mechanism — not if/else chains
  • Supervision is built into the runtime — use it, do not work around it
  • "Let it crash" is a design principle, not laziness

If you are uncomfortable with any of these, read Programming Elixir before contributing.


Formatting

Run mix format before every commit. No exceptions. CI enforces this.

A .formatter.exs is in the repository root. Do not modify it without discussion.


Pattern Matching

Pattern matching is the primary tool for control flow, destructuring, and dispatch. Use it everywhere.

Match in function heads, not in the body

# Wrong
def process(result) do
  if elem(result, 0) == :ok do
    value = elem(result, 1)
    do_something(value)
  end
end

# Right
def process({:ok, value}), do: do_something(value)
def process({:error, reason}), do: handle_error(reason)

Match in receive and case, not with conditionals

# Wrong
def handle_message(msg) do
  if msg[:type] == "ping" do
    :pong
  else
    :unknown
  end
end

# Right
def handle_message(%{"type" => "ping"}), do: :pong
def handle_message(_msg), do: :unknown

Use guards for type and value constraints

def divide(a, b) when is_number(a) and is_number(b) and b != 0 do
  {:ok, a / b}
end
def divide(_, 0), do: {:error, :division_by_zero}
def divide(_, _), do: {:error, :invalid_arguments}

Error Handling

Return tagged tuples — never raise for expected failures

# Wrong — raises for expected conditions
def find_user!(id) do
  Repo.get!(User, id)
end

# Right — returns a tagged tuple
def find_user(id) do
  case Repo.get(User, id) do
    nil -> {:error, :not_found}
    user -> {:ok, user}
  end
end

Use with for multi-step operations

# Wrong — nested case pyramid
case authenticate(token) do
  {:ok, user} ->
    case authorize(user, :write) do
      :ok ->
        case save(data) do
          {:ok, record} -> {:ok, record}
          {:error, reason} -> {:error, reason}
        end
      {:error, reason} -> {:error, reason}
    end
  {:error, reason} -> {:error, reason}
end

# Right
with {:ok, user} <- authenticate(token),
     :ok <- authorize(user, :write),
     {:ok, record} <- save(data) do
  {:ok, record}
end

Use Repo.get not Repo.get! + rescue

# Wrong — try/rescue for flow control is Python thinking
try do
  record = Repo.get!(Model, id)
  {:ok, record}
rescue
  Ecto.NoResultsError -> {:error, :not_found}
end

# Right
case Repo.get(Model, id) do
  nil -> {:error, :not_found}
  record -> {:ok, record}
end

Use Integer.parse not String.to_integer + rescue

# Wrong
try do
  {:ok, String.to_integer(value)}
rescue
  ArgumentError -> {:error, :invalid}
end

# Right
case Integer.parse(value) do
  {n, ""} -> {:ok, n}
  _ -> {:error, :invalid}
end

Never swallow errors silently

# Wrong — hides bugs completely
try do
  do_something()
rescue
  _ -> nil
end

# Acceptable — at minimum log what happened
try do
  do_something()
rescue
  e ->
    Logger.warning("do_something failed: #{Exception.message(e)}", error: e)
    {:error, :unexpected}
end

# Best — let it crash, let the supervisor restart
do_something()

Silent rescue _ makes the system appear healthy when it is not. Supervisors exist to handle crashes. Use them.

! functions are for programmer errors only

Bang functions (get!, fetch!, parse!) raise on failure. Use them only when failure means the program itself is wrong — misconfigured, missing required data at boot time. Never use them for expected runtime failures like "user not found" or "invalid input."


Atoms

Never create atoms from runtime strings

Atoms are never garbage collected. Each unique atom created from a runtime string is a permanent memory allocation.

# Wrong — memory leak, will eventually crash the VM
key = String.to_atom("provider_#{id}")
key = :"#{module_name}_handler"

# Right — use tuples as keys
key = {:provider, id}
key = {module_name, :handler}

The only acceptable use of String.to_atom/1 is with a compile-time known, bounded set of values. If the value comes from user input, a database, or any external source, use a tuple or string key instead.

String.to_existing_atom/1 is safer but still requires the atom to have been created at compile time.


Pipes

Only pipe when there is an actual transformation chain

# Wrong — single-value pipe adds noise
result = value |> transform()

# Right
result = transform(value)

Keep pipe chains readable — one transformation per line

# Wrong — unreadable wall of pipes
result = input |> String.trim() |> String.downcase() |> String.replace(" ", "_") |> String.slice(0, 50)

# Right
result =
  input
  |> String.trim()
  |> String.downcase()
  |> String.replace(" ", "_")
  |> String.slice(0, 50)

Control Flow

Prefer unless over if not for single-branch negation

# Wrong
if not valid?(x), do: handle_invalid(x)

# Right
unless valid?(x), do: handle_invalid(x)

Never use unless...else

Double negation is hard to reason about. unless with an else branch is explicitly discouraged by the Elixir style guide.

# Wrong
unless disabled? do
  :active
else
  :inactive
end

# Right
if disabled? do
  :inactive
else
  :active
end

Remove identity case expressions

# Wrong — noise
result = case value do
  x -> x
end

# Right
result = value

Avoid deeply nested conditionals — flatten with with or function heads

# Wrong
def handle(params) do
  if Map.has_key?(params, :user_id) do
    if Map.has_key?(params, :action) do
      if params.action in [:read, :write] do
        execute(params)
      else
        {:error, :invalid_action}
      end
    else
      {:error, :missing_action}
    end
  else
    {:error, :missing_user_id}
  end
end

# Right
def handle(%{user_id: _, action: action} = params) when action in [:read, :write] do
  execute(params)
end
def handle(%{user_id: _, action: _}), do: {:error, :invalid_action}
def handle(%{action: _}), do: {:error, :missing_user_id}
def handle(_), do: {:error, :missing_action}

Lists and Enumerables

Never append with ++ in loops

list ++ [item] copies the entire list on every iteration. This is O(n²).

# Wrong — O(n²)
Enum.reduce(items, [], fn item, acc ->
  acc ++ [transform(item)]
end)

# Right — prepend O(1), reverse once O(n)
items
|> Enum.reduce([], fn item, acc -> [transform(item) | acc] end)
|> Enum.reverse()

# Better — just use Enum.map
Enum.map(items, &transform/1)

Prefer Enum over manual recursion for collection operations

# Wrong — manual recursion for a standard operation
defp sum([]), do: 0
defp sum([h | t]), do: h + sum(t)

# Right
Enum.sum(items)

Use Stream for lazy evaluation of large collections

# Wrong — loads everything into memory
File.read!("large_file.log")
|> String.split("\n")
|> Enum.filter(&String.contains?(&1, "ERROR"))
|> Enum.take(100)

# Right — lazy, processes line by line
File.stream!("large_file.log")
|> Stream.filter(&String.contains?(&1, "ERROR"))
|> Enum.take(100)

Strings

Use string interpolation, not concatenation

# Wrong
"Hello " <> name <> ", you are " <> Integer.to_string(age) <> " years old."

# Right
"Hello #{name}, you are #{age} years old."

Use sigils for complex strings and patterns

# Regex
pattern = ~r/^\d{4}-\d{2}-\d{2}$/

# Multiline strings
query = """
SELECT *
FROM users
WHERE active = true
"""

# String lists
roles = ~w(admin editor viewer)

Structs and Maps

Use structs for domain entities, maps for ad-hoc data

# Wrong — map for a domain entity with known fields
user = %{name: "Alex", email: "alex@example.com", role: :admin}

# Right — struct enforces the shape
defmodule User do
  defstruct [:name, :email, :role]
end
user = %User{name: "Alex", email: "alex@example.com", role: :admin}

Use @enforce_keys for required fields

Fields that must always be present should be enforced at compile time, not validated at runtime.

# Wrong — missing required fields only fail at runtime, maybe
defmodule Connection do
  defstruct [:host, :port, timeout: 5_000]
end

# Right — missing :host or :port raises at compile time
defmodule Connection do
  @enforce_keys [:host, :port]
  defstruct [:host, :port, timeout: 5_000]
end

Always match on the struct type when pattern matching structs

# Wrong — matches any map with a name key
def greet(%{name: name}), do: "Hello #{name}"

# Right — only matches User structs
def greet(%User{name: name}), do: "Hello #{name}"

Use Map.get/3 with a default, not Map.get/2 + nil check

# Wrong
timeout = Map.get(config, :timeout)
timeout = if timeout == nil, do: 5000, else: timeout

# Right
timeout = Map.get(config, :timeout, 5000)

OTP and Processes

Always use Task.Supervisor for fire-and-forget tasks

# Wrong — crash is invisible, no restart, no monitoring
Task.start(fn -> do_background_work() end)

# Right — crash is reported, supervised
Task.Supervisor.start_child(MyApp.TaskSupervisor, fn -> do_background_work() end)

Add the supervisor to your application tree:

# application.ex
children = [
  {Task.Supervisor, name: MyApp.TaskSupervisor},
  # ...
]

Use handle_continue/2 for post-init work — never send(self(), :init)

# Wrong — works, but sends a message through the mailbox unnecessarily;
# another message could arrive and be processed before :do_init
def init(args) do
  send(self(), :do_init)
  {:ok, initial_state(args)}
end

def handle_info(:do_init, state) do
  {:noreply, do_heavy_init(state)}
end

# Right — handle_continue runs before any other message is processed
def init(args) do
  {:ok, initial_state(args), {:continue, :init}}
end

def handle_continue(:init, state) do
  {:noreply, do_heavy_init(state)}
end

Do not call GenServer functions from within the same GenServer

This causes a deadlock. Extract the logic into a private function and call it directly.

# Wrong — deadlock
def handle_call(:do_thing, _from, state) do
  result = GenServer.call(__MODULE__, :helper)
  {:reply, result, state}
end

# Right
def handle_call(:do_thing, _from, state) do
  result = compute_helper(state)
  {:reply, result, state}
end

defp compute_helper(state), do: ...

Keep GenServer state minimal and callbacks thin

# Wrong — business logic buried in callback
def handle_call({:process, data}, _from, state) do
  result =
    data
    |> validate()
    |> enrich()
    |> persist()
    |> notify()
  {:reply, result, %{state | last_processed: DateTime.utc_now()}}
end

# Right — callback delegates to pure functions
def handle_call({:process, data}, _from, state) do
  result = process(data)
  {:reply, result, update_state(state)}
end

defp process(data) do
  data
  |> validate()
  |> enrich()
  |> persist()
  |> notify()
end

defp update_state(state), do: %{state | last_processed: DateTime.utc_now()}

Use GenServer.call for synchronous operations, GenServer.cast for fire-and-forget

# Synchronous — caller waits for result
GenServer.call(pid, {:get, key})

# Asynchronous — caller does not wait
GenServer.cast(pid, {:update, key, value})

Do not store large data in GenServer state

GenServer state lives in the process heap. Large binaries, growing lists, or cached datasets should live in ETS, a database, or a dedicated cache process.

Use Process.send_after for recurring work, not receive loops

# In GenServer init
def init(state) do
  schedule_tick()
  {:ok, state}
end

def handle_info(:tick, state) do
  do_periodic_work()
  schedule_tick()
  {:noreply, state}
end

defp schedule_tick do
  Process.send_after(self(), :tick, :timer.seconds(30))
end

Never use the process dictionary for application state

Process.put/2 and Process.get/2 are global mutable state scoped to a process. They are invisible to callers, invisible to supervisors, and make code untestable. The only acceptable uses are Logger metadata and library internals.

# Wrong — hidden state, impossible to test or inspect
Process.put(:current_user, user)
user = Process.get(:current_user)

# Right — pass state explicitly
def handle_request(conn, user) do
  do_work(conn, user)
end

Supervision strategies

Strategy Use when
:one_for_one Children are independent — default choice
:one_for_all Children are interdependent — restart all on any failure
:rest_for_one Children have ordered dependencies — restart failed + all started after it

Module Design

alias, use, import — pick the right tool

# alias — resolves a module name locally, no code is imported
alias MyApp.Accounts.User
# Now %User{} works instead of %MyApp.Accounts.User{}

# import — brings functions into scope; avoid unless the benefit is clear
import Ecto.Query, only: [from: 2, where: 3]

# use — injects code via __using__/1 macro, significant side effect
# Only use when a behaviour or macro injection is explicitly required
use GenServer
use Phoenix.LiveView

Prefer alias over import. Use use only when the library requires it (GenServer, LiveView, Ecto.Schema, etc.). Never use a module just to avoid typing the full name.

One module, one responsibility

A module that does too many things should be split. Signs a module needs splitting:

  • More than ~300 lines
  • Functions that could be grouped into clearly distinct namespaces
  • Mix of pure business logic and side effects (DB, HTTP, IO)

Use context modules (Phoenix pattern) for domain boundaries

MyApp.Accounts         # User, Session, Token operations
MyApp.Accounts.User    # Schema only
MyApp.Content          # Post, Comment, Tag operations
MyApp.Content.Post     # Schema only

Context modules are the public API. Schemas are private data structures.

Extract shared helpers into dedicated modules

# Wrong — same utility function copy-pasted across 5 modules
defp blank?(nil), do: true
defp blank?(""), do: true
defp blank?(_), do: false

# Right — one module, used everywhere
defmodule MyApp.Utils do
  def blank?(nil), do: true
  def blank?(""), do: true
  def blank?(_), do: false
end

Keep defp functions close to the def that calls them

Callers before helpers. Public functions at the top of the module.


Typespecs and Documentation

Every public function gets @spec

@spec find_user(integer()) :: {:ok, User.t()} | {:error, :not_found}
def find_user(id) do

Every module gets @moduledoc

defmodule MyApp.Accounts do
  @moduledoc """
  Manages user accounts, sessions, and authentication.
  """

Use @doc false for intentionally undocumented public functions

If a function must be public (e.g., for use in tests or callbacks) but should not appear in documentation:

@doc false
def __callback__, do: :ok

Consistent timestamp types

Choose one timestamp type and use it across all schemas. :utc_datetime is recommended. Do not mix :naive_datetime and :utc_datetime.


Ecto

Use changesets for all data mutations

Never write directly to the database without a changeset. Changesets provide validation, type casting, and an audit trail.

# Wrong
Repo.insert!(%User{name: name, email: email})

# Right
%User{}
|> User.changeset(%{name: name, email: email})
|> Repo.insert()

Use Ecto.Multi for multi-step database operations

Repo.transaction/1 with with works for simple cases. Ecto.Multi is the right tool when you have multiple named steps, need to inspect which step failed, or want composable transaction building.

# Acceptable for simple two-step cases
Repo.transaction(fn ->
  with {:ok, user} <- create_user(params),
       {:ok, _log} <- create_audit_log(user) do
    user
  else
    {:error, reason} -> Repo.rollback(reason)
  end
end)

# Right for complex multi-step operations — named steps, clear failure attribution
Ecto.Multi.new()
|> Ecto.Multi.insert(:user, User.changeset(%User{}, params))
|> Ecto.Multi.insert(:profile, fn %{user: user} ->
  Profile.changeset(%Profile{}, %{user_id: user.id})
end)
|> Ecto.Multi.run(:notify, fn _repo, %{user: user} ->
  Notifications.send_welcome(user)
end)
|> Repo.transaction()
|> case do
  {:ok, %{user: user}} -> {:ok, user}
  {:error, :user, changeset, _} -> {:error, changeset}
  {:error, :notify, reason, _} -> {:error, reason}
end

Never trigger N+1 queries — preload associations explicitly

# Wrong — each access hits the database inside the loop
orders = Repo.all(Order)
Enum.each(orders, fn order -> IO.inspect(order.items) end)

# Right — single query with preload
orders = Repo.all(Order) |> Repo.preload(:items)
Enum.each(orders, fn order -> IO.inspect(order.items) end)

If you access an association that has not been preloaded, Ecto raises Ecto.Association.NotLoaded. Never rescue that — fix the preload.

Use from queries instead of raw SQL for standard operations

# Acceptable for simple queries
Repo.get(User, id)
Repo.all(User)

# Right for complex queries
from(u in User,
  where: u.active == true and u.role == ^role,
  order_by: [desc: u.inserted_at],
  limit: ^limit
)
|> Repo.all()

Security

Never create atoms from user input

(See Atoms section above — this is also a security issue, not just a memory issue.)

Always use Plug.Crypto.secure_compare for token/signature comparison

# Wrong — timing attack vulnerability
signature == computed_signature

# Right — constant-time comparison
Plug.Crypto.secure_compare(signature, computed_signature)

Never interpolate user input into HTML without escaping

# Wrong — XSS vulnerability
html = "<p>Hello #{user_input}</p>"

# Right — in Phoenix templates, assign to a variable and let HEEx escape it
# In controllers, use Phoenix.HTML.html_escape/1
safe_input = Phoenix.HTML.html_escape(user_input)

No hardcoded secrets, salts, or keys

# Wrong
signing_salt: "my_app_salt"
secret_key: "hardcoded_secret"

# Right
signing_salt: Application.fetch_env!(:my_app, :signing_salt)
secret_key: System.fetch_env!("SECRET_KEY")

Testing

Test behaviour, not implementation

# Wrong — tests internal function
test "parse_response transforms the map correctly" do
  assert MyModule.parse_response(%{...}) == %{...}
end

# Right — tests public contract
test "fetch_user returns {:ok, user} when user exists" do
  user = insert(:user)
  assert {:ok, ^user} = Accounts.fetch_user(user.id)
end

Default to async: true

Tests should run concurrently unless they touch shared mutable state: database without the Ecto sandbox, named processes, ETS tables, or global config.

# Default for unit tests and context tests using the Ecto sandbox
use MyApp.DataCase, async: true

# Only false when sharing state that cannot be isolated
use MyApp.DataCase, async: false

Use ExUnit tags to organize test suites

@tag :integration
test "creates a record in the database" do

@tag :unit
test "validates email format" do

Run subsets with mix test --only unit or mix test --exclude integration.

Use ExMachina (or equivalent) for test data — never raw structs

# Wrong — brittle, couples tests to schema details
user = %User{id: 1, name: "Test", email: "test@test.com", role: :admin, ...}

# Right — factory handles defaults, tests specify only what matters
user = insert(:user, role: :admin)

Test the error paths, not just the happy path

Every function that returns {:error, reason} should have at least one test that exercises that path.


What This Is Not

This document covers Elixir/OTP code. It does not define conventions for:

  • Python code — follow PEP 8
  • JavaScript/TypeScript — follow the project's ESLint config
  • SQL — use Ecto query syntax where possible; raw SQL in separate files with comments
  • Shell scripts — follow Google Shell Style Guide

Resources