
Elixir Antipatterns
- 154 installs
- 614 repo stars
- Updated March 28, 2026
- gentleman-programming/gentleman-skills
For development and infrastructure management.
About
elixir-antipatterns is an AI coding tool that enhances development workflows. Builders use it for infrastructure, integration, and platform development within the catalog ecosystem.
- elixir-antipatterns
- Development
Elixir Antipatterns by the numbers
- 154 all-time installs (skills.sh)
- Ranked #2,419 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/gentleman-programming/gentleman-skills --skill elixir-antipatternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 154 |
|---|---|
| repo stars | ★ 614 |
| Last updated | March 28, 2026 |
| Repository | gentleman-programming/gentleman-skills ↗ |
What it does
For development and infrastructure management.
Files
Elixir Anti-Patterns
Critical anti-patterns that compromise robustness and maintainability in Elixir/Phoenix applications.
Complement with:mix formatandCredofor style enforcement
Extended reference: See EXTENDED.md for 40+ patterns and deep-dive examples---
When to Use
Topics: Error handling (3 patterns) • Architecture (2 patterns) • Performance (2 patterns) • Testing (1 pattern)
Load this skill when:
- Writing Elixir modules and functions
- Working with Phoenix Framework (Controllers, LiveView)
- Building Ecto schemas and database queries
- Implementing BEAM concurrency (Task, GenServer)
- Handling errors with tagged tuples
- Writing tests with ExUnit
---
Critical Patterns
Quick reference to the 8 core patterns this skill enforces:
1. Tagged Tuples: Return {:ok, value} | {:error, reason} instead of nil or exceptions 2. Explicit @spec: Document error cases in function signatures 3. Context Separation: Business logic in contexts, not LiveView 4. Preload Associations: Use Repo.preload/2 to avoid N+1 queries 5. with Arrow Binding: Use <- for all failable operations in with 6. Database Indexes: Index frequently queried columns 7. Test Assertions: Every test must assert expected behavior 8. Cohesive Functions: Group with chains >4 steps into functions
See ## Anti-Patterns section below for detailed ❌ BAD / ✅ CORRECT code examples.---
Code Examples
Example 1: Error Handling with Tagged Tuples
# ✅ CORRECT - Errors as values, explicit in @spec
defmodule UserService do
@spec fetch_user(String.t()) :: {:ok, User.t()} | {:error, :not_found}
def fetch_user(id) do
case Repo.get(User, id) do
nil -> {:error, :not_found}
user -> {:ok, user}
end
end
end
# ❌ BAD - Exceptions for business errors
def fetch_user(id) do
Repo.get(User, id) || raise "User not found"
endExample 2: Phoenix LiveView with Context Separation
Architecture Layers:
User Request → LiveView (UI only) → Context (business logic) → Schema/Repo (data)
↓ ↓ ↓
handle_event() Accounts.create_user() Repo.insert()# ✅ CORRECT - Thin LiveView, logic in context
defmodule MyAppWeb.UserLive.Index do
use MyAppWeb, :live_view
def handle_event("create", params, socket) do
case Accounts.create_user(params) do
{:ok, user} -> {:noreply, redirect(socket, to: ~p"/users/#{user}")}
{:error, changeset} -> {:noreply, assign(socket, changeset: changeset)}
end
end
end
# ❌ BAD - Business logic in LiveView
def handle_event("create", %{"user" => params}, socket) do
if String.length(params["name"]) < 3 do
{:noreply, put_flash(socket, :error, "Too short")}
else
case Repo.insert(User.changeset(%User{}, params)) do
{:ok, user} -> send_email(user); redirect(socket)
end
end
endExample 3: Ecto N+1 Query Optimization
# ✅ CORRECT - Preload associations (2 queries total)
users = User |> Repo.all() |> Repo.preload(:posts)
Enum.map(users, fn user -> process(user, user.posts) end)
# Note: For complex filtering (e.g., WHERE posts.status = 'published'),
# use join + preload in the query itself. See EXTENDED.md for advanced patterns.
# ❌ BAD - Query in loop (101 queries for 100 users)
users = Repo.all(User)
Enum.map(users, fn user ->
posts = Repo.all(from p in Post, where: p.user_id == ^user.id)
{user, posts}
end)---
Anti-Patterns
Error Management
Don't: Use raise for Business Errors
# ❌ BAD
def fetch_user(id) do
Repo.get(User, id) || raise "User not found"
end
# ✅ CORRECT
@spec fetch_user(String.t()) :: {:ok, User.t()} | {:error, :not_found}
def fetch_user(id) do
case Repo.get(User, id) do
nil -> {:error, :not_found}
user -> {:ok, user}
end
endWhy: @spec documents errors, pattern matching forces explicit handling.
---
Don't: Return nil for Errors
# ❌ BAD - No context on failure
def find_user(email), do: Repo.get_by(User, email: email)
# ✅ CORRECT - Explicit error reason
@spec find_user(String.t()) :: {:ok, User.t()} | {:error, :not_found}
def find_user(email) do
case Repo.get_by(User, email: email) do
nil -> {:error, :not_found}
user -> {:ok, user}
end
end---
Don't: Use = Inside with for Failable Operations
# ❌ BAD - Validate errors silenced
with {:ok, user} <- fetch_user(id),
validated = validate(user), # ← Doesn't check for {:error, _}
{:ok, saved} <- save(validated) do
{:ok, saved}
end
# ✅ CORRECT - All operations use <-
with {:ok, user} <- fetch_user(id),
{:ok, validated} <- validate(user),
{:ok, saved} <- save(validated) do
{:ok, saved}
end---
Architecture & Boundaries
Don't: Put Business Logic in LiveView
# ❌ BAD - Validation in view
def handle_event("create", %{"user" => params}, socket) do
if String.length(params["name"]) < 3 do
{:noreply, put_flash(socket, :error, "Too short")}
else
case Repo.insert(User.changeset(%User{}, params)) do
{:ok, user} -> redirect(socket)
end
end
end
# ✅ CORRECT - Delegate to context
def handle_event("create", params, socket) do
case Accounts.create_user(params) do
{:ok, user} -> {:noreply, redirect(socket, to: ~p"/users/#{user}")}
{:error, changeset} -> {:noreply, assign(socket, changeset: changeset)}
end
endWhy: Contexts testable without Phoenix, logic reusable.
---
Don't: Chain More Than 4 Steps in with
# ❌ BAD - Too many responsibilities
with {:ok, a} <- step1(),
{:ok, b} <- step2(a),
{:ok, c} <- step3(b),
{:ok, d} <- step4(c),
{:ok, e} <- step5(d) do
{:ok, e}
end
# ✅ CORRECT - Group into cohesive functions
with {:ok, validated} <- validate_and_fetch(id),
{:ok, processed} <- process_business_rules(validated),
{:ok, result} <- persist_and_notify(processed) do
{:ok, result}
end---
Data & Performance
Don't: Query Inside Loops (N+1)
# ❌ BAD - 101 queries for 100 users
users = Repo.all(User)
Enum.map(users, fn user ->
posts = Repo.all(from p in Post, where: p.user_id == ^user.id)
end)
# ✅ CORRECT - 2 queries total
User |> Repo.all() |> Repo.preload(:posts)Impact: 100 users with N+1 = 10 seconds vs 5ms with preload.
---
Don't: Query Without Indexes
# ❌ BAD - No index on frequently queried column
# Migration:
create table(:users) do
add :email, :string
end
# ✅ CORRECT - Add index
create table(:users) do
add :email, :string
end
create unique_index(:users, [:email])Why: Full table scan on 1M+ rows vs instant index lookup.
---
Testing
Don't: Write Tests Without Assertions
# ❌ BAD - What's being tested?
test "creates user" do
UserService.create_user(%{name: "Juan"})
end
# ✅ CORRECT - Assert expected behavior
test "creates user successfully" do
assert {:ok, user} = UserService.create_user(%{name: "Juan"})
assert user.name == "Juan"
end---
Quick Reference
| Situation | Anti-Pattern | Correct Pattern |
|---|---|---|
| Error handling | raise "Not found" | {:error, :not_found} |
| Missing data | Return nil | {:error, :not_found} |
| Business logic | In LiveView | In context modules |
| Associations | Enum.map + Repo.get | Repo.preload |
| with chains | validated = fn() | {:ok, validated} <- fn() |
| Frequent queries | No index | create index(:table, [:column]) |
| Testing | No assertions | assert expected behavior |
| Complex logic | 6+ step with | Group into 3 functions |
---
Resources
- Elixir Style Guide
- Phoenix Contexts
- Ecto Query Performance
- ExUnit Best Practices
- Extended patterns: See
EXTENDED.mdfor 40+ anti-patterns
Elixir Anti-Patterns - Extended Reference
Complete catalog of 40+ anti-patterns for Elixir/Phoenix applications.
For AI assistants: Use SKILL.md (core patterns). This is reference documentation.---
Critical Patterns
Pattern 1: Monadic Error Handling
Use tagged tuples for business errors, never exceptions.
Elixir functions should return {:ok, result} or {:error, reason} for expected failures. This makes errors explicit in function specs and forces callers to handle them via pattern matching.
# ✅ CORRECT - Errors as values
@spec fetch_user(String.t()) :: {:ok, User.t()} | {:error, :not_found}
def fetch_user(id) do
case Repo.get(User, id) do
nil -> {:error, :not_found}
user -> {:ok, user}
end
end
# Usage forces error handling
case fetch_user(id) do
{:ok, user} -> process(user)
{:error, :not_found} -> handle_missing()
endPattern 2: Separation of Concerns (Phoenix Context Pattern)
Isolate business logic in contexts, keep views thin.
Phoenix LiveView components should only coordinate UI events. All validation, queries, and business rules belong in context modules for testability and reusability.
# ✅ CORRECT - LiveView delegates to context
def handle_event("create", params, socket) do
case Accounts.create_user(params) do
{:ok, user} ->
{:noreply, redirect(socket, to: user_path(socket, :show, user))}
{:error, changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
# Context handles all business logic
defmodule Accounts do
@spec create_user(map()) :: {:ok, User.t()} | {:error, Changeset.t()}
def create_user(attrs) do
%User{}
|> User.changeset(attrs)
|> validate_business_rules()
|> Repo.insert()
end
endPattern 3: Ecto Query Optimization
Always preload associations, never query inside loops (N+1).
Loading associations inside Enum.map creates N+1 queries. Use Repo.preload to fetch all data in a single query.
# ✅ CORRECT - Single query with preload
User
|> Repo.all()
|> Repo.preload(:posts)
|> Enum.map(fn user ->
process_user_with_posts(user, user.posts)
end)
# Performance: 100 users = 2 queries (users + posts)
# vs N+1: 101 queries (1 user query + 100 post queries)Pattern 4: Pure Function Design
Separate computation from side effects for testability.
Functions should either compute (pure) or perform I/O (coordinator), never both. This enables testing without mocks.
# ✅ CORRECT - Pure calculation
def calculate_total(items) do
Enum.reduce(items, 0, &(&1.price + &2))
end
# ✅ CORRECT - Coordinator handles I/O
def calculate_and_log_total(items) do
total = calculate_total(items) # Delegate to pure function
Logger.info("Total: #{total}")
total
endPattern 5: Consistent Error Conventions
Choose one error convention per module boundary.
Mixing {:ok, _}, exceptions, and nil in the same module makes APIs unpredictable. Pick one and document it with @spec.
# ✅ CORRECT - Uniform convention
defmodule UserService do
@spec create(map()) :: {:ok, User.t()} | {:error, Changeset.t()}
@spec delete(String.t()) :: {:ok, User.t()} | {:error, :not_found}
@spec find(String.t()) :: {:ok, User.t()} | {:error, :not_found}
# All functions follow same pattern
end---
Code Examples
Example 1: Error Handling with Tagged Tuples
# ✅ Complete error handling pattern
defmodule UserService do
alias MyApp.{Repo, User}
@spec fetch_user(String.t()) :: {:ok, User.t()} | {:error, :not_found}
def fetch_user(id) do
case Repo.get(User, id) do
nil -> {:error, :not_found}
user -> {:ok, user}
end
end
@spec find_by_email(String.t()) :: {:ok, User.t()} | {:error, :not_found}
def find_by_email(email) do
case Repo.get_by(User, email: email) do
nil -> {:error, :not_found}
user -> {:ok, user}
end
end
@spec create(map()) :: {:ok, User.t()} | {:error, Changeset.t()}
def create(attrs) do
%User{}
|> User.changeset(attrs)
|> Repo.insert()
end
endExample 2: Phoenix LiveView with Context Separation
# ✅ Thin LiveView component
defmodule MyAppWeb.UserLive.Index do
use MyAppWeb, :live_view
alias MyApp.Accounts
def handle_event("create", %{"user" => params}, socket) do
# All business logic in context
case Accounts.create_user(params) do
{:ok, user} ->
{:noreply,
socket
|> put_flash(:info, "User created")
|> redirect(to: ~p"/users/#{user}")}
{:error, changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
def handle_event("delete", %{"id" => id}, socket) do
case Accounts.delete_user(id) do
{:ok, _user} ->
{:noreply,
socket
|> put_flash(:info, "User deleted")
|> assign(:users, Accounts.list_users())}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Delete failed: #{reason}")}
end
end
end
# ✅ Context with all business logic
defmodule MyApp.Accounts do
alias MyApp.{Repo, User}
def list_users do
User |> Repo.all() |> Repo.preload(:profile)
end
def create_user(attrs) do
%User{}
|> User.changeset(attrs)
|> validate_business_rules()
|> Repo.insert()
end
def delete_user(id) do
case Repo.get(User, id) do
nil -> {:error, :not_found}
user -> Repo.delete(user)
end
end
defp validate_business_rules(changeset) do
# Complex validation logic here
changeset
end
endExample 3: Ecto Query Optimization
⚡ Performance Note: Choose preload strategy based on your use case
# ✅ Efficient query patterns
defmodule MyApp.Posts do
alias MyApp.{Repo, Post, User}
import Ecto.Query
# Strategy 1: Repo.preload/2 (simple case)
# Use when: You need ALL associations (no filtering)
# Queries: 2 (SELECT posts + SELECT authors)
# Best for: Simple 1-to-many loads
def list_posts_with_authors do
Post
|> Repo.all()
|> Repo.preload(:author)
end
# Strategy 2: join + preload in query (complex case)
# Use when: You filter/sort BY association fields
# Queries: 1 (SELECT with JOIN)
# Best for: Filtering by author.country, sorting by author.name
def list_posts_by_author_country(country) do
Post
|> join(:inner, [p], a in assoc(p, :author))
|> where([p, a], a.country == ^country)
|> preload([p, a], author: a) # Reuse joined data
|> Repo.all()
end
# Strategy 3: select_merge for partial loads
# Use when: You only need 2-3 fields from association
# Avoids loading entire author struct
def list_posts_with_author_names do
Post
|> join(:left, [p], a in assoc(p, :author))
|> select_merge([p, a], %{author_name: a.name, author_country: a.country})
|> Repo.all()
end
# Correct: Index on frequently queried columns
# In migration:
def change do
create table(:posts) do
add :title, :string
add :author_id, references(:users)
timestamps()
end
create index(:posts, [:author_id]) # Index foreign key
create index(:posts, [:inserted_at]) # Index for date queries
end
endExample 4: Pure Functions and Side Effects
# ✅ Separation of computation and I/O
defmodule MyApp.OrderCalculator do
# Pure function - testable without mocks
@spec calculate_total([LineItem.t()]) :: Decimal.t()
def calculate_total(items) do
items
|> Enum.map(&(&1.price * &1.quantity))
|> Enum.reduce(Decimal.new(0), &Decimal.add/2)
end
@spec apply_discount(Decimal.t(), Decimal.t()) :: Decimal.t()
def apply_discount(total, discount_percent) do
discount = Decimal.mult(total, Decimal.div(discount_percent, 100))
Decimal.sub(total, discount)
end
end
# Coordinator handles side effects
defmodule MyApp.OrderService do
alias MyApp.{OrderCalculator, Repo, Order}
require Logger
def process_order(order_id) do
with {:ok, order} <- fetch_order(order_id),
total <- OrderCalculator.calculate_total(order.items),
final_total <- OrderCalculator.apply_discount(total, order.discount),
{:ok, updated} <- update_order_total(order, final_total) do
Logger.info("Order #{order_id} processed: #{final_total}")
notify_customer(updated)
{:ok, updated}
end
end
defp fetch_order(id), do: # ... DB query
defp update_order_total(order, total), do: # ... DB update
defp notify_customer(order), do: # ... Email/notification
endExample 5: Monadic Error Propagation (Error.m Pattern)
# ✅ Correct monadic error handling
# Assuming you have a custom Error.m monad implementation
defmodule MyApp.UserWorkflow do
require Error
def create_user_with_profile(user_attrs, profile_attrs) do
Error.m do
# All operations use <- for monadic bind
user <- validate_user_attrs(user_attrs)
saved_user <- insert_user(user)
profile <- validate_profile_attrs(profile_attrs)
saved_profile <- insert_profile(saved_user, profile)
# Wrap non-monadic values
welcome_msg <- generate_welcome_message(saved_user) |> Error.return()
# Short-circuits on first {:error, _}
send_welcome_email(saved_user, welcome_msg)
end
end
defp validate_user_attrs(attrs) do
# Returns {:ok, validated} or {:error, reason}
end
defp insert_user(user), do: Repo.insert(user)
defp insert_profile(user, profile), do: Repo.insert(profile)
endExample 6: Module Size Management
# ✅ CORRECT - Split large modules by responsibility
# Before: UserService (850 lines - TOO LARGE)
# After: Split into cohesive modules
# Validation logic - 80 lines
defmodule MyApp.UserValidator do
def validate_registration(attrs) do
# Email format, password strength, etc.
end
end
# Database queries - 60 lines
defmodule MyApp.UserRepository do
def get_by_id(id), do: # ...
def get_by_email(email), do: # ...
def list_active_users, do: # ...
end
# Business logic coordinator - 120 lines
defmodule MyApp.UserService do
alias MyApp.{UserValidator, UserRepository, Mailer}
def create_user(attrs) do
with {:ok, validated} <- UserValidator.validate_registration(attrs),
{:ok, user} <- UserRepository.insert(validated),
:ok <- Mailer.send_welcome_email(user) do
{:ok, user}
end
end
endExample 7: ExUnit Testing Best Practices
# ✅ CORRECT - Independent, parallel tests
defmodule MyApp.UserServiceTest do
use MyApp.DataCase, async: true # Parallel execution
alias MyApp.UserService
# Each test gets fresh data via setup
setup do
user = insert(:user, email: "test_#{System.unique_integer()}@example.com")
{:ok, user: user}
end
describe "fetch_user/1" do
test "returns user when exists", %{user: user} do
assert {:ok, found} = UserService.fetch_user(user.id)
assert found.id == user.id
assert found.email == user.email
end
test "returns error when not found" do
assert {:error, :not_found} = UserService.fetch_user("nonexistent")
end
end
describe "create_user/1" do
test "creates user with valid attributes" do
attrs = %{name: "John", email: "john@example.com"}
assert {:ok, user} = UserService.create_user(attrs)
assert user.name == "John"
assert user.email == "john@example.com"
end
test "returns error with invalid attributes" do
attrs = %{name: "", email: "invalid"}
assert {:error, changeset} = UserService.create_user(attrs)
assert %{name: ["can't be blank"], email: ["invalid format"]} =
errors_on(changeset)
end
end
end---
All Anti-Patterns
1. Don't: Use raise for Business Errors
Problem: Expected failures (user not found, validation failed) should be values, not exceptions.
# ❌ BAD - Crashes on expected error
def fetch_user(id) do
Repo.get(User, id) || raise "User not found"
end
# Caller must use try/rescue (non-idiomatic)
try do
fetch_user(id)
rescue
_ -> handle_error()
endWhy it's bad:
- Hides error cases from
@spec - Forces try/rescue (not composable)
- Treats expected failures as unexpected crashes
2. Don't: Return nil for Errors
Problem: Loses error context, forces defensive nil checks everywhere.
# ❌ BAD - What went wrong?
def find_user(email) do
Repo.get_by(User, email: email) # Returns nil if not found
end
# Caller can't distinguish "not found" from "DB error" from "invalid input"
case find_user(email) do
nil -> # Which error? Who knows!
user -> # Process
end3. Don't: Mix Error Conventions
Problem: Inconsistent APIs are unpredictable and hard to use.
# ❌ BAD - Module with mixed conventions
defmodule UserService do
def create(attrs), do: Repo.insert(changeset(attrs)) # {:ok, _} | {:error, _}
def delete(id), do: Repo.delete!(Repo.get(User, id)) # Raises exception
def find(id), do: Repo.get(User, id) # Returns nil
end4. Don't: Use = Inside Error.m Blocks
Problem: Breaks monadic chain, silences errors.
# ❌ BAD - validate() errors are silenced
Error.m do
user <- fetch_user(id)
validated = validate(user) # If validate returns {:error, _}, BOOM!
save(validated)
end
# ✅ CORRECT - All binds use <-
Error.m do
user <- fetch_user(id)
validated <- validate(user) # Auto short-circuits on error
save(validated)
end5. Don't: Put Business Logic in LiveView
Problem: Logic tied to UI framework, not testable without browser.
# ❌ BAD - Validation and queries in LiveView
def handle_event("create", %{"user" => params}, socket) do
if String.length(params["name"]) < 3 do
{:noreply, put_flash(socket, :error, "Too short")}
else
case Repo.insert(User.changeset(%User{}, params)) do
{:ok, user} -> send_email(user); redirect(socket)
end
end
endWhy it's bad:
- Can't test validation without Phoenix
- Can't reuse logic in API controllers
- Violates separation of concerns
6. Don't: Put I/O in Pure Functions
Problem: Side effects prevent testing without mocks.
# ❌ BAD - Logger call in calculation
def calculate_total(items) do
total = Enum.reduce(items, 0, &(&1.price + &2))
Logger.info("Total: #{total}") # Side effect!
total
end
# Can't test without capturing logs or mocking Logger7. Don't: Store Derived Data in LiveView Assigns
Problem: Data duplication, manual sync, bugs.
# ❌ BAD - Computed values in assigns
assign(socket,
users: users,
user_count: length(users), # Duplicates data
has_users: users != [] # Duplicates data
)
# What if users changes but you forget to update user_count?8. Don't: Query Inside Loops (N+1)
Problem: Exponential performance degradation.
# ❌ BAD - 101 queries for 100 users
users = Repo.all(User)
Enum.map(users, fn user ->
posts = Repo.all(from p in Post, where: p.user_id == ^user.id)
{user, posts}
end)
# 100 users = 10 seconds (100ms per query)9. Don't: Use Transactions for Single Operations
Problem: Unnecessary overhead and database locks.
# ❌ BAD - Transaction wrapping single insert
Repo.transaction(fn ->
Repo.insert!(user)
end)
# ✅ CORRECT - No transaction needed
Repo.insert(user)10. Don't: Query Without Indexes
Problem: Full table scans on large tables.
# ❌ BAD - Frequently queried column without index
def find_by_email(email) do
Repo.get_by(User, email: email) # Full table scan!
end
# In migration - no index on email column
create table(:users) do
add :email, :string # Will be slow on 1M+ rows
end11. Don't: Use Tasks for CPU-Bound Operations
Problem: Scheduling overhead without benefit.
# ❌ BAD - Task for simple calculation
def calculate_total(items) do
Task.async(fn -> Enum.sum(items) end) |> Task.await()
end
# Process overhead > calculation time12. Don't: Write Tests Without Assertions
Problem: False sense of coverage, doesn't verify behavior.
# ❌ BAD - What are we testing?
test "creates user" do
UserService.create_user(%{name: "Juan"})
# No assertion - test always passes!
end13. Don't: Write Dependent Tests
Problem: Tests fail when run in different order, not parallelizable.
# ❌ BAD - Tests share state
defmodule UserServiceTest do
use ExUnit.Case # No async: true
test "creates user" do
{:ok, _} = UserService.create_user(%{email: "test@example.com"})
end
test "finds user" do
# Assumes previous test ran first!
{:ok, user} = UserService.find_by_email("test@example.com")
end
end14. Don't: Chain More Than 4 Steps in with
Problem: Violates Single Responsibility Principle.
# ❌ BAD - Too many responsibilities
with {:ok, a} <- step1(),
{:ok, b} <- step2(a),
{:ok, c} <- step3(b),
{:ok, d} <- step4(c),
{:ok, e} <- step5(d),
{:ok, f} <- step6(e) do
{:ok, f}
end
# ✅ CORRECT - Group into cohesive functions
with {:ok, validated} <- validate_and_fetch(id),
{:ok, processed} <- process_business_rules(validated),
{:ok, result} <- persist_and_notify(processed) do
{:ok, result}
end15. Don't: Let Modules Exceed 800 Lines
Problem: Multiple responsibilities, low cohesion.
# ❌ BAD - Monolithic module (850 lines)
defmodule UserService do
# Validation (200 lines)
# Queries (150 lines)
# Business logic (300 lines)
# Email sending (100 lines)
# Report generation (100 lines)
end
# Split into: UserValidator, UserRepository, UserService, UserMailer, UserReports---
Resources
- Elixir Official Style Guide
- Phoenix Context Guidelines
- Ecto Query Performance
- ExUnit Best Practices
- Credo Static Analysis
---
Version: 1.0 Last Updated: January 2026 Note: This is the complete reference. For AI assistant usage, see SKILL.md (core patterns).
Elixir Anti-Patterns Skill
Community contribution for Gentleman-Skills: Core catalog of 8 critical anti-patterns for Elixir/Phoenix applications.
Structure
This skill uses a hybrid approach to balance LLM context limits with comprehensive documentation:
elixir-antipatterns/
├── SKILL.md # Core 8 patterns (322 lines as of v1.0)
├── README.md # This file
├── assets/
│ ├── extended.md # Complete 40+ patterns (706 lines)SKILL.md (Core - LLM Loaded)
Optimized for code review and refactoring sessions
Contains the top 8 most critical anti-patterns: 1. Use raise for business errors 2. Return nil for errors 3. Business logic in LiveView 4. N+1 queries 5. = inside with for failable operations 6. Query without indexes 7. Tests without assertions 8. Long with chains (>4 steps)
Why separate files?
- SKILL.md fits within LLM context windows
- Aligns with Gentleman-Skills standard (150-388 line target)
- Includes ASCII diagram for Phoenix architecture visualization
- extended.md preserves all knowledge with advanced Ecto patterns
What This Skill Covers
- Error Handling: Tagged tuples vs exceptions vs nil
- Separation of Concerns: Phoenix contexts vs business logic in views
- Database Performance: N+1 queries, indexing, transactions
- Testing Best Practices: Independent tests, assertions, async execution
Why It's Valuable
- Comprehensive Elixir/Phoenix skill: Covers error handling, contexts, Ecto, testing
- Code review optimized: Trigger designed for PR reviews and refactoring
- Visual aids: ASCII diagrams for Phoenix Context Separation architecture
- Advanced techniques: extended.md documents 3 Ecto preload strategies
Usage with AI Assistants
The AI will automatically load SKILL.md when working with Elixir code. For deeper reference:
## Skills
When working with Elixir/Phoenix code, load the `elixir-antipatterns` skill.
For comprehensive patterns, reference `extended.md`.Testing
Validated with:
- ✅ Cursor on Elixir/Phoenix projects
- ✅ Production applications (2+ years)
- ✅ Mix format + Credo compliance
Contributing
Created for Gentleman-Skills.
License
MIT License - Free to use and modify