Files
elixir-patterns/smells/common-mistakes.md
T
Aaron Weiker 4ea9a884aa docs: idiomatic Elixir and Phoenix patterns with source citations
Extracted patterns, conventions, and code smells directly from the
Elixir and Phoenix source code with file path and line number citations.

Covers: GenServer, error handling, data transforms, process design,
testing, documentation, typespecs, macros, behaviours, module organization,
Phoenix-specific patterns, framework deviations, and anti-patterns.
2026-04-29 22:50:12 -07:00

6.2 KiB

Common Mistakes

What "bad Elixir" looks like, based on what the source code explicitly warns against or demonstrates the correct way to avoid.

1. Using ++ in a Loop (O(n²) List Building)

Source: lib/elixir/lib/enum.ex:306-320 (internal macros all use prepend)

# What the source does: prepend then reverse
defmacrop next(_, entry, acc) do
  quote(do: [unquote(entry) | unquote(acc)])
end

The mistake:

# O(n²) — copies the entire left list for every element
Enum.reduce(items, [], fn item, acc -> acc ++ [transform(item)] end)

The fix:

# O(n) — prepend is O(1), reverse once at the end
items |> Enum.map(&transform/1)
# or
Enum.reduce(items, [], fn item, acc -> [transform(item) | acc] end) |> Enum.reverse()

2. Forgetting @impl true

Source: lib/elixir/lib/gen_server.ex:44-56 (every callback uses @impl)

The mistake:

defmodule MyServer do
  use GenServer

  # Typo! This will never be called — no warning without @impl
  def handle_cll(msg, _from, state) do
    {:reply, msg, state}
  end
end

The fix:

@impl true
def handle_call(msg, _from, state) do
  {:reply, msg, state}
end

With @impl true, the compiler catches the typo at compile time.


3. Not Handling All with Failure Cases

Source: lib/elixir/lib/kernel/special_forms.ex:1680-1715 (with Beware! section)

The mistake:

with {:ok, width} <- Map.fetch(opts, "width"),
     {:ok, height} <- Map.fetch(opts, "height") do
  {:ok, width * height}
else
  # Only handles one case — what if Map.fetch returns something else?
  :error -> {:error, :missing_field}
end

If an else block is used and no clause matches, a WithClauseError is raised.

The fix: Either handle all possible non-match values in else, or better yet, normalize return values in helper functions so you don't need else at all.


4. async Without await

Source: lib/elixir/lib/task.ex:38-40

If you are using async tasks, you must await a reply as they are always sent.

The mistake:

# Leaked reference — message sits in mailbox forever
Task.async(fn -> send_email(user) end)
# Never awaited!

The fix:

# Fire-and-forget: use start_child
Task.Supervisor.start_child(MyApp.TaskSupervisor, fn -> send_email(user) end)

# OR if you need the result:
task = Task.async(fn -> send_email(user) end)
Task.await(task)

5. Anonymous Functions in Distributed Agents

Source: lib/elixir/lib/agent.ex:141-160 ("A word on distributed agents")

In a distributed setup with multiple nodes, the API that accepts anonymous functions only works if the caller (client) and the agent have the same version of the caller module.

The mistake:

# Fails if nodes have different code versions
Agent.get({MyAgent, :remote@node}, fn state -> state.count end)

The fix:

# Use MFA (module, function, args) for distributed calls
Agent.get({MyAgent, :remote@node}, MyModule, :get_count, [])

6. Starting Processes Outside Supervision Trees

Source: lib/elixir/lib/task.ex:100-115

We encourage developers to rely on supervised tasks as much as possible.

The mistake:

# No supervision, no monitoring, no logging
spawn(fn -> do_important_work() end)

# Or:
Task.async(fn -> do_important_work() end) |> Task.await()
# Linked to caller but not supervised

The fix:

# Add to your supervision tree:
{Task.Supervisor, name: MyApp.TaskSupervisor}

# Then use it:
Task.Supervisor.start_child(MyApp.TaskSupervisor, fn -> do_important_work() end)

7. Putting State Logic in Controllers

Source: lib/phoenix/controller.ex:28-45

Controllers are shown as thin dispatch layers:

def show(conn, %{"id" => id}) do
  user = Repo.get(User, id)
  render(conn, :show, user: user)
end

The mistake:

def create(conn, params) do
  # Business logic in the controller
  changeset = User.changeset(%User{}, params)
  if changeset.valid? do
    user = Repo.insert!(changeset)
    send_welcome_email(user)
    update_analytics(user)
    notify_admin(user)
    render(conn, :show, user: user)
  else
    render(conn, :error, errors: changeset.errors)
  end
end

The fix: Move business logic to a context module. The controller just dispatches:

def create(conn, params) do
  case Accounts.register_user(params) do
    {:ok, user} -> render(conn, :show, user: user)
    {:error, changeset} -> render(conn, :error, errors: changeset.errors)
  end
end

8. Using :permanent Restart for One-Shot Tasks

Source: lib/elixir/lib/task.ex:178-186

a Task has a default :restart of :temporary. This means the task will not be restarted even if it crashes.

The mistake:

# Will restart infinitely if the HTTP call keeps failing
use Task, restart: :permanent

def start_link(url) do
  Task.start_link(fn -> HTTP.get!(url) end)
end

The fix: Use :temporary (default) for one-shot work. Use :transient if you want restart only on abnormal exit:

use Task, restart: :transient

9. Pattern Matching the Internals of Opaque Types

Source: lib/elixir/lib/task.ex:298-300

@opaque ref :: reference()

The mistake:

# Accessing internal structure of an opaque type
%Task{ref: ref} = task
send(ref, :custom_message)  # This breaks if internals change

The fix: Use the public API. If a type is @opaque, its structure is not guaranteed between versions. Use functions like Task.await/2 that work with the type properly.


10. Not Using on_exit for Test Cleanup

Source: lib/ex_unit/lib/ex_unit/case.ex:86-94

The mistake:

test "writes to file" do
  File.write!("/tmp/test_file", "data")
  assert File.read!("/tmp/test_file") == "data"
  File.rm!("/tmp/test_file")  # Never runs if assert above fails!
end

The fix:

setup do
  path = "/tmp/test_file_#{System.unique_integer()}"
  on_exit(fn -> File.rm(path) end)
  {:ok, path: path}
end

test "writes to file", %{path: path} do
  File.write!(path, "data")
  assert File.read!(path) == "data"
  # Cleanup happens automatically, even on failure
end