Anti Patterns
Building production Elixir systems? Avoid these critical anti-patterns that lead to process leaks, supervision failures, and system instability.
This guide identifies anti-patterns through the OTP-First lens, showing violations of BEAM principles and their correct OTP implementations.
Why Anti-Patterns Matter
Production Elixir failures often stem from:
- Process lifecycle mismanagement - Leaks, orphaned processes
- Supervision violations - Incorrect strategies, missing supervision
- Stateful design errors - God objects, improper state management
- Message queue overflow - Unbounded queues, no backpressure
- Resource exhaustion - Connection pooling failures, file handle leaks
Impact: System instability, unpredictable failures, difficult debugging
Solution: Recognize anti-patterns early, apply OTP principles correctly
GenServer Anti-Patterns
1. God Object GenServer
FAIL - Monolithic state manager handling unrelated concerns:
# ANTI-PATTERN: God Object GenServer
defmodule SystemManager do
use GenServer # => Handles too many concerns
def init(_opts) do
state = %{
users: %{}, # => User management
connections: %{}, # => Connection pooling
cache: %{}, # => Caching layer
metrics: %{}, # => Metrics collection
config: %{} # => Configuration management
} # => Single point of failure
# => Impossible to supervise granularly
{:ok, state} # => Type: {:ok, map()}
end
def handle_call({:add_user, user}, _from, state) do
# User management logic # => Mixing concerns
end # => Violates single responsibility
def handle_call({:get_cached, key}, _from, state) do
# Cache retrieval logic # => Unrelated to users
end # => Same process handles everything
def handle_call({:record_metric, metric}, _from, state) do
# Metrics recording # => Third unrelated concern
end # => Process becomes bottleneck
endWhy It Fails:
- Single point of failure - One crash loses all state
- Performance bottleneck - All calls serialized through one process
- Supervision complexity - Cannot restart individual subsystems
- Debugging difficulty - Hard to isolate issues
- Testing complexity - Cannot test concerns in isolation
CORRECT - Separate GenServers with focused responsibilities:
# Focused GenServer for user management
defmodule UserManager do
use GenServer # => Single responsibility: users
def init(_opts) do
{:ok, %{users: %{}}} # => Only user-related state
# => Type: {:ok, %{users: map()}}
end
def handle_call({:add_user, user}, _from, state) do
users = Map.put(state.users, user.id, user)
# => Add user to state
{:reply, :ok, %{state | users: users}} # => Reply success, update state
# => Type: {:reply, :ok, map()}
end
def handle_call({:get_user, id}, _from, state) do
user = Map.get(state.users, id) # => Retrieve user
{:reply, user, state} # => Reply with user, state unchanged
# => Type: {:reply, user | nil, map()}
end
end
# Focused GenServer for connection pooling
defmodule ConnectionPool do
use GenServer # => Single responsibility: connections
def init(opts) do
max_connections = Keyword.get(opts, :max, 10)
# => Get max connections config
{:ok, %{connections: [], max: max_connections}}
# => Initialize connection pool state
# => Type: {:ok, %{connections: list(), max: integer()}}
end
def handle_call(:checkout, _from, state) do
case state.connections do
[conn | rest] -> # => Connection available
{:reply, {:ok, conn}, %{state | connections: rest}}
# => Return connection, update pool
[] -> # => No connections available
{:reply, {:error, :no_connections}, state}
# => Return error, state unchanged
end # => Type: {:reply, result, map()}
end
end
# Focused GenServer for caching
defmodule CacheServer do
use GenServer # => Single responsibility: caching
def init(opts) do
ttl = Keyword.get(opts, :ttl, 60_000) # => Get TTL config (default 60s)
{:ok, %{cache: %{}, ttl: ttl}} # => Initialize cache state
# => Type: {:ok, %{cache: map(), ttl: integer()}}
end
def handle_call({:get, key}, _from, state) do
case Map.get(state.cache, key) do
{value, timestamp} -> # => Cache entry found
if System.monotonic_time(:millisecond) - timestamp < state.ttl do
{:reply, {:ok, value}, state} # => Entry valid, return value
else
{:reply, :not_found, state} # => Entry expired
end
nil -> # => Key not in cache
{:reply, :not_found, state} # => Return not found
end # => Type: {:reply, result, map()}
end
def handle_call({:put, key, value}, _from, state) do
timestamp = System.monotonic_time(:millisecond)
# => Get current timestamp
cache = Map.put(state.cache, key, {value, timestamp})
# => Store value with timestamp
{:reply, :ok, %{state | cache: cache}} # => Reply success, update cache
# => Type: {:reply, :ok, map()}
end
end
# Supervision tree for independent subsystems
defmodule SystemSupervisor do
use Supervisor # => Supervises separate concerns
def start_link(opts) do
Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
# => Start supervisor
# => Type: {:ok, pid()}
end
def init(_opts) do
children = [
{UserManager, []}, # => User management process
{ConnectionPool, [max: 10]}, # => Connection pool process
{CacheServer, [ttl: 60_000]} # => Cache process
] # => Each can fail/restart independently
Supervisor.init(children, strategy: :one_for_one)
# => If one crashes, restart only that one
# => Type: {:ok, supervisor_spec()}
end
endBenefits:
- Each GenServer focused on single responsibility
- Independent supervision and restart
- Parallel processing (no single bottleneck)
- Easier testing and debugging
- Granular resource management
2. Stateless GenServer
FAIL - Using GenServer for stateless operations:
# ANTI-PATTERN: GenServer with no state management
defmodule MathServer do
use GenServer # => Unnecessary GenServer
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
# => Creates process for no reason
# => Type: {:ok, pid()}
end
def init(_opts) do
{:ok, %{}} # => Empty state (never used)
# => Type: {:ok, map()}
end
def add(a, b) do
GenServer.call(__MODULE__, {:add, a, b})
# => Synchronous call overhead
# => Serializes all additions
# => Type: integer()
end
def multiply(a, b) do
GenServer.call(__MODULE__, {:multiply, a, b})
# => Another synchronous call
# => Unnecessary process communication
end
def handle_call({:add, a, b}, _from, state) do
result = a + b # => Pure calculation (no state needed)
{:reply, result, state} # => State unchanged
# => Type: {:reply, integer(), map()}
end
def handle_call({:multiply, a, b}, _from, state) do
result = a * b # => Pure calculation
{:reply, result, state} # => State never modified
end
endWhy It Fails:
- Unnecessary process overhead - GenServer adds latency for pure functions
- Performance bottleneck - Serializes concurrent operations
- Resource waste - Process created for stateless operations
- Complexity overhead - GenServer boilerplate for simple functions
CORRECT - Use plain modules for stateless operations:
# Plain module for stateless operations
defmodule Math do
@doc """
Adds two numbers.
"""
def add(a, b) do
a + b # => Direct calculation
# => No process overhead
# => Type: integer()
end
@doc """
Multiplies two numbers.
"""
def multiply(a, b) do
a * b # => Direct calculation
# => Fully concurrent (no serialization)
end
@doc """
Calculates compound interest.
"""
def compound_interest(principal, rate, years) do
principal * :math.pow(1 + rate, years) # => Pure calculation
# => No state management needed
# => Type: float()
end
end
# Usage - no GenServer overhead
result = Math.add(10, 20) # => Direct function call
# => result: 30 (type: integer())
product = Math.multiply(5, 6) # => No process communication
# => product: 30 (type: integer())
interest = Math.compound_interest(1000, 0.05, 10)
# => interest: 1628.89 (type: float())When GenServer IS appropriate:
# CORRECT - GenServer for stateful operations
defmodule Counter do
use GenServer # => State management needed
def init(initial) do
{:ok, %{count: initial, history: []}} # => Maintains state
# => Type: {:ok, %{count: integer(), history: list()}}
end
def handle_call(:increment, _from, state) do
new_count = state.count + 1 # => Modify state
history = [new_count | state.history] # => Track history
new_state = %{count: new_count, history: history}
# => State updated
{:reply, new_count, new_state} # => Reply with new count
# => Type: {:reply, integer(), map()}
end
endDecision Rule: Use GenServer only when managing mutable state, process lifecycle, or coordinating resources.
3. Blocking Operations in GenServer
FAIL - Synchronous blocking in handle_call:
# ANTI-PATTERN: Blocking handle_call
defmodule ApiClient do
use GenServer # => GenServer for API requests
def handle_call({:fetch_user, user_id}, _from, state) do
# Blocking HTTP request # => Blocks GenServer process
response = HTTPoison.get("https://api.example.com/users/#{user_id}")
# => All other calls blocked during request
# => GenServer unresponsive for seconds
case response do
{:ok, %{status_code: 200, body: body}} ->
user = Jason.decode!(body) # => Parse response
{:reply, {:ok, user}, state} # => Finally reply
{:error, reason} ->
{:reply, {:error, reason}, state} # => Reply with error
end # => GenServer blocked entire time
end
endWhy It Fails:
- GenServer blocks all calls - Other operations wait during HTTP request
- Timeout risk - Default GenServer timeout is 5000ms
- Cascading failures - Slow external service blocks entire process
- No concurrency - Sequential processing of all requests
CORRECT - Async operations with Task:
# Delegate blocking work to Task
defmodule ApiClient do
use GenServer # => GenServer for coordination only
def fetch_user(user_id) do
GenServer.call(__MODULE__, {:fetch_user, user_id})
# => Immediate response (no blocking)
# => Type: {:ok, Task.t()}
end
def handle_call({:fetch_user, user_id}, _from, state) do
# Start async task # => Doesn't block GenServer
task = Task.async(fn ->
HTTPoison.get("https://api.example.com/users/#{user_id}")
# => HTTP request in separate process
end) # => Type: Task.t()
{:reply, {:ok, task}, state} # => Reply immediately with task
# => GenServer continues processing
# => Type: {:reply, {:ok, Task.t()}, map()}
end
end
# Usage - caller handles async task
{:ok, task} = ApiClient.fetch_user(123) # => Returns immediately
# => task: Task.t()
result = Task.await(task, 10_000) # => Caller waits for result
# => result: {:ok, response} | {:error, reason}Better - Async cast with callback:
defmodule ApiClient do
use GenServer # => Non-blocking GenServer
def fetch_user_async(user_id, callback_pid) do
GenServer.cast(__MODULE__, {:fetch_user, user_id, callback_pid})
# => Async cast (no reply expected)
# => Type: :ok
end
def handle_cast({:fetch_user, user_id, callback_pid}, state) do
# Spawn task for HTTP request # => Doesn't block GenServer
Task.start(fn ->
result = HTTPoison.get("https://api.example.com/users/#{user_id}")
# => HTTP request in separate process
send(callback_pid, {:user_fetched, result})
# => Send result to callback process
end) # => Task runs independently
{:noreply, state} # => GenServer continues immediately
# => Type: {:noreply, map()}
end
end
# Usage - receive result asynchronously
defmodule UserController do
def get_user(user_id) do
ApiClient.fetch_user_async(user_id, self())
# => Async request with callback to self
receive do
{:user_fetched, {:ok, response}} -> # => Receive successful result
parse_user(response) # => Process response
{:user_fetched, {:error, reason}} -> # => Receive error result
handle_error(reason) # => Handle error
after
10_000 -> # => Timeout after 10 seconds
{:error, :timeout} # => Return timeout error
end
end
endRule: Never block GenServer with slow operations - delegate to Task, handle asynchronously.
Supervision Anti-Patterns
4. No Supervision
FAIL - Processes started without supervision:
# ANTI-PATTERN: Manual process start without supervision
defmodule Application do
use Application # => Application behavior
def start(_type, _args) do
# Start processes manually # => No supervision tree
{:ok, user_pid} = UserManager.start_link([])
# => Process not supervised
# => If crashes, stays dead
{:ok, cache_pid} = CacheServer.start_link([])
# => Another unsupervised process
# Store PIDs globally (WRONG)
:ets.new(:pids, [:named_table, :public])
# => Manual PID tracking
:ets.insert(:pids, {:user_manager, user_pid})
# => Store PIDs for lookup
:ets.insert(:pids, {:cache_server, cache_pid})
{:ok, self()} # => Return application PID
# => Processes not part of supervision
end
endWhy It Fails:
- No automatic restart - Crashed processes stay dead
- Manual recovery required - No fault tolerance
- PID tracking overhead - Manual bookkeeping required
- Debugging difficulty - No supervision reports
- Violates “Let It Crash” - Must handle failures manually
CORRECT - Supervision tree for all processes:
defmodule MyApp.Application do
use Application # => Application behavior
def start(_type, _args) do
children = [
{UserManager, []}, # => Supervised UserManager
{CacheServer, [ttl: 60_000]}, # => Supervised CacheServer
{ConnectionPool, [max: 10]} # => Supervised ConnectionPool
] # => All processes supervised
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
# => one_for_one: restart only failed process
Supervisor.start_link(children, opts) # => Start supervision tree
# => Type: {:ok, pid()}
end
end
# Supervised GenServer with proper name registration
defmodule UserManager do
use GenServer # => GenServer behavior
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
# => Register with module name
# => Supervisor can restart by name
# => Type: {:ok, pid()}
end
def init(opts) do
{:ok, %{users: %{}}} # => Initialize state
# => Type: {:ok, map()}
end
endBenefits:
- Automatic restart on crash
- Supervision reports for debugging
- No manual PID tracking (use named processes)
- Follows “Let It Crash” philosophy
- System self-healing
5. Wrong Supervision Strategy
FAIL - Mismatched supervision strategy:
# ANTI-PATTERN: :one_for_all when processes are independent
defmodule DatabaseSupervisor do
use Supervisor # => Supervisor behavior
def init(_opts) do
children = [
{UserRepo, []}, # => User database connection
{ProductRepo, []}, # => Product database connection
{OrderRepo, []} # => Order database connection
] # => Independent database pools
# WRONG: one_for_all strategy
Supervisor.init(children, strategy: :one_for_all)
# => If UserRepo crashes, restart ALL repos
# => Unnecessary downtime for ProductRepo and OrderRepo
# => Type: {:ok, supervisor_spec()}
end
endWhy It Fails:
- Cascading restarts - One failure restarts all independent processes
- Unnecessary downtime - Healthy processes restarted needlessly
- Resource waste - Re-establishing connections for healthy processes
- Longer recovery time - All processes must restart sequentially
CORRECT - Match strategy to dependency:
# :one_for_one for independent processes
defmodule DatabaseSupervisor do
use Supervisor # => Supervisor behavior
def init(_opts) do
children = [
{UserRepo, []}, # => Independent user repo
{ProductRepo, []}, # => Independent product repo
{OrderRepo, []} # => Independent order repo
] # => No dependencies between repos
# CORRECT: one_for_one strategy
Supervisor.init(children, strategy: :one_for_one)
# => Restart only failed process
# => Other processes continue running
# => Type: {:ok, supervisor_spec()}
end
end
# :rest_for_one for dependent processes
defmodule PaymentSupervisor do
use Supervisor # => Supervisor behavior
def init(_opts) do
children = [
{PaymentGateway, []}, # => Must start first (foundation)
{TransactionLogger, []}, # => Depends on gateway
{FraudDetector, []} # => Depends on logger
] # => Sequential dependency chain
# CORRECT: rest_for_one strategy
Supervisor.init(children, strategy: :rest_for_one)
# => If gateway crashes, restart gateway + logger + detector
# => If logger crashes, restart logger + detector only
# => If detector crashes, restart detector only
# => Type: {:ok, supervisor_spec()}
end
end
# :one_for_all for tightly coupled processes
defmodule ClusterSupervisor do
use Supervisor # => Supervisor behavior
def init(_opts) do
children = [
{ClusterNode1, []}, # => Cluster node 1
{ClusterNode2, []}, # => Cluster node 2
{ClusterCoordinator, []} # => Cluster coordinator
] # => Tightly coupled (cluster state)
# CORRECT: one_for_all strategy
Supervisor.init(children, strategy: :one_for_all)
# => Any crash requires full cluster restart
# => Ensures consistent cluster state
# => Type: {:ok, supervisor_spec()}
end
endStrategy Selection:
| Strategy | Use Case | Restart Behavior |
|---|---|---|
:one_for_one | Independent processes | Restart only failed child |
:rest_for_one | Sequential dependencies (A → B → C) | Restart failed + rest |
:one_for_all | Tightly coupled (shared state/order) | Restart all children |
6. Missing Child Specs
FAIL - Incorrect child spec configuration:
# ANTI-PATTERN: Improper child spec
defmodule WorkerPool do
use GenServer # => GenServer behavior
def start_link(opts) do
GenServer.start_link(__MODULE__, opts) # => No name registration
# => Supervisor cannot restart by name
# => Type: {:ok, pid()}
end
def child_spec(opts) do
%{
id: WorkerPool, # => ID for supervisor
start: {WorkerPool, :start_link, [opts]},
# => Start specification
restart: :temporary, # => WRONG: temporary means no restart
type: :worker # => Type: worker
} # => Process won't restart on crash
end
endWhy It Fails:
- No restart -
:temporarymeans process not restarted on crash - No name registration - Cannot reference process after restart
- Lost process - Supervisor loses track after crash
CORRECT - Proper child spec with restart:
defmodule WorkerPool do
use GenServer # => GenServer behavior
def start_link(opts) do
name = Keyword.get(opts, :name, __MODULE__)
# => Get name from opts or use module name
GenServer.start_link(__MODULE__, opts, name: name)
# => Register with name
# => Type: {:ok, pid()}
end
def child_spec(opts) do
%{
id: Keyword.get(opts, :id, __MODULE__),
# => Unique ID (allows multiple instances)
start: {__MODULE__, :start_link, [opts]},
# => Start specification
restart: :permanent, # => CORRECT: always restart
shutdown: 5000, # => Graceful shutdown timeout (5s)
type: :worker # => Type: worker
} # => Type: child_spec()
end
def init(opts) do
{:ok, %{workers: [], max: Keyword.get(opts, :max, 10)}}
# => Initialize worker pool state
# => Type: {:ok, map()}
end
end
# Usage with multiple instances
defmodule MyApp.Supervisor do
use Supervisor # => Supervisor behavior
def init(_opts) do
children = [
{WorkerPool, [name: :pool_1, id: :pool_1, max: 5]},
# => First pool with 5 workers
{WorkerPool, [name: :pool_2, id: :pool_2, max: 10]}
# => Second pool with 10 workers
] # => Multiple instances with unique IDs
Supervisor.init(children, strategy: :one_for_one)
# => Type: {:ok, supervisor_spec()}
end
endRestart Options:
:permanent- Always restart (default for critical processes):temporary- Never restart (one-time tasks):transient- Restart only on abnormal termination (optional services)
Process Communication Anti-Patterns
7. Message Queue Overflow
FAIL - Unbounded message accumulation:
# ANTI-PATTERN: No backpressure, unbounded queue
defmodule Logger do
use GenServer # => GenServer for logging
def init(_opts) do
{:ok, %{queue: []}} # => Unbounded queue
# => Type: {:ok, map()}
end
def log(message) do
GenServer.cast(__MODULE__, {:log, message})
# => Async cast (no backpressure)
# => Fast producer can overwhelm
# => Type: :ok
end
def handle_cast({:log, message}, state) do
# Slow operation (write to file)
File.write!("app.log", message <> "\n", [:append])
# => Slow I/O operation
# => Messages accumulate faster than processing
{:noreply, state} # => No queue tracking
# => Memory can grow unbounded
# => Type: {:noreply, map()}
end
endWhy It Fails:
- Memory exhaustion - Queue grows unbounded
- System instability - OOM kills entire application
- No feedback - Producer doesn’t know queue is full
- Delayed processing - Messages queued for minutes/hours
CORRECT - Backpressure with queue limits:
defmodule Logger do
use GenServer # => GenServer with backpressure
@max_queue_size 1000 # => Maximum queue size
def init(_opts) do
{:ok, %{queue: :queue.new(), size: 0}} # => Erlang queue + size tracking
# => Type: {:ok, map()}
end
def log(message) do
GenServer.call(__MODULE__, {:log, message}, 5000)
# => Synchronous call with timeout
# => Provides backpressure to producer
# => Type: :ok | {:error, :queue_full}
end
def handle_call({:log, message}, _from, state) do
if state.size >= @max_queue_size do
# Queue full - reject message # => Apply backpressure
{:reply, {:error, :queue_full}, state}
# => Producer must handle rejection
else
# Add to queue
queue = :queue.in(message, state.queue)
# => Enqueue message
new_state = %{state | queue: queue, size: state.size + 1}
# => Update state and size
# Process async if not already processing
if state.size == 0 do
send(self(), :process_queue) # => Trigger processing
end
{:reply, :ok, new_state} # => Reply success
# => Type: {:reply, :ok, map()}
end
end
def handle_info(:process_queue, state) do
case :queue.out(state.queue) do
{{:value, message}, new_queue} -> # => Message available
# Process message
File.write!("app.log", message <> "\n", [:append])
# => Write to file
new_state = %{state | queue: new_queue, size: state.size - 1}
# => Update queue and size
# Continue processing if queue not empty
if state.size > 1 do
send(self(), :process_queue) # => Schedule next processing
end
{:noreply, new_state} # => Type: {:noreply, map()}
{:empty, _queue} -> # => Queue empty
{:noreply, state} # => No action needed
end
end
endBetter - Use GenStage for producer/consumer backpressure:
# Producer with explicit demand
defmodule LogProducer do
use GenStage # => GenStage producer
def start_link(opts) do
GenStage.start_link(__MODULE__, opts, name: __MODULE__)
# => Type: {:ok, pid()}
end
def init(_opts) do
{:producer, %{queue: :queue.new()}} # => Producer state
# => Type: {:producer, map()}
end
def handle_demand(demand, state) when demand > 0 do
# Only send messages when consumer requests them
events = dequeue_messages(state.queue, demand)
# => Dequeue up to demand count
new_queue = update_queue(state.queue, events)
# => Remove sent events
{:noreply, events, %{state | queue: new_queue}}
# => Emit events to consumer
# => Type: {:noreply, [event], map()}
end
end
# Consumer with backpressure
defmodule LogConsumer do
use GenStage # => GenStage consumer
def start_link(opts) do
GenStage.start_link(__MODULE__, opts, name: __MODULE__)
# => Type: {:ok, pid()}
end
def init(_opts) do
{:consumer, %{}, subscribe_to: [{LogProducer, max_demand: 10, min_demand: 5}]}
# => Consumer subscribes to producer
# => Requests 5-10 events at a time
# => Type: {:consumer, map(), keyword()}
end
def handle_events(events, _from, state) do
# Process events (producer waits until we finish)
Enum.each(events, fn event ->
File.write!("app.log", event <> "\n", [:append])
# => Write each event
end)
{:noreply, [], state} # => Request more events
# => Type: {:noreply, [], map()}
end
endRule: Always implement backpressure for message-heavy systems - use call instead of cast, or use GenStage.
8. Synchronous Chain Calls
FAIL - Nested synchronous GenServer calls:
# ANTI-PATTERN: Synchronous call chain leading to deadlock
defmodule ServiceA do
use GenServer # => GenServer A
def handle_call(:process, _from, state) do
# Call ServiceB synchronously # => Blocks ServiceA
result = GenServer.call(ServiceB, :compute)
# => Waits for ServiceB response
{:reply, result, state} # => ServiceA blocked until reply
# => Type: {:reply, term(), map()}
end
end
defmodule ServiceB do
use GenServer # => GenServer B
def handle_call(:compute, _from, state) do
# Call ServiceC synchronously # => Blocks ServiceB
result = GenServer.call(ServiceC, :calculate)
# => Waits for ServiceC response
{:reply, result, state} # => ServiceB blocked until reply
end
end
defmodule ServiceC do
use GenServer # => GenServer C
def handle_call(:calculate, _from, state) do
# DEADLOCK: Tries to call ServiceA # => ServiceA waiting for ServiceC
result = GenServer.call(ServiceA, :validate)
# => ServiceC waiting for ServiceA
# => Circular wait = DEADLOCK
{:reply, result, state} # => Never reached
end
endWhy It Fails:
- Deadlock risk - Circular dependencies cause deadlock
- Timeout cascades - One slow service times out entire chain
- Poor concurrency - Sequential blocking reduces throughput
- Debugging difficulty - Hard to trace call chains
CORRECT - Async messaging with cast or Task:
# Use cast for async communication
defmodule ServiceA do
use GenServer # => GenServer A
def handle_call(:process, from, state) do
# Send async request to ServiceB # => Non-blocking
GenServer.cast(ServiceB, {:compute, from, self()})
# => Include reply destination
{:noreply, state} # => Don't block waiting for reply
# => Type: {:noreply, map()}
end
def handle_cast({:result, result}, state) do
# Receive result from ServiceB # => Async result
# Process result...
{:noreply, state} # => Type: {:noreply, map()}
end
end
defmodule ServiceB do
use GenServer # => GenServer B
def handle_cast({:compute, reply_to, caller}, state) do
# Perform computation # => Non-blocking
result = do_compute() # => Calculate result
# Send result back to ServiceA # => Async reply
GenServer.cast(caller, {:result, result})
# => No blocking
# Also reply to original caller
GenServer.reply(reply_to, result) # => Reply to original caller
{:noreply, state} # => Type: {:noreply, map()}
end
endBetter - Use Task for async operations:
defmodule ServiceA do
use GenServer # => GenServer A
def handle_call(:process, _from, state) do
# Start async task # => Non-blocking
task = Task.async(fn ->
ServiceB.compute() # => Run in separate process
end) # => Type: Task.t()
# Return task reference immediately # => No blocking
{:reply, {:ok, task}, state} # => Caller can await task
# => Type: {:reply, {:ok, Task.t()}, map()}
end
end
# Usage
{:ok, task} = ServiceA.process() # => Get task reference
result = Task.await(task, 10_000) # => Wait with timeout
# => result: term()Rule: Avoid synchronous call chains - use async cast or Task to prevent deadlocks and improve concurrency.
Performance Anti-Patterns
9. Process Per Request
FAIL - Spawning process for every request:
# ANTI-PATTERN: Creating short-lived processes repeatedly
defmodule RequestHandler do
def handle_request(request) do
# Spawn new process for each request # => Process creation overhead
spawn(fn ->
process_request(request) # => Short-lived process
end) # => Process dies after processing
# => Type: pid()
end
defp process_request(request) do
# Process request...
result = expensive_computation(request) # => Computation
send_response(result) # => Send response
end # => Process terminates
endWhy It Fails:
- Process creation overhead - Spawning processes is not free
- No pooling - No process reuse
- Supervision complexity - Short-lived processes hard to supervise
- Resource waste - Constant allocation/deallocation
CORRECT - Use Task with supervision:
# Supervised task pool
defmodule RequestHandler do
def handle_request(request) do
# Use Task.Supervisor for supervised task
Task.Supervisor.start_child(MyApp.TaskSupervisor, fn ->
process_request(request) # => Supervised task
end) # => Automatic restart on crash
# => Type: {:ok, pid()}
end
defp process_request(request) do
result = expensive_computation(request) # => Computation in supervised task
send_response(result) # => Send response
end # => Task supervised until completion
end
# Supervision tree
defmodule MyApp.Application do
use Application # => Application behavior
def start(_type, _args) do
children = [
{Task.Supervisor, name: MyApp.TaskSupervisor}
# => Task supervisor for async tasks
]
Supervisor.start_link(children, strategy: :one_for_one)
# => Type: {:ok, pid()}
end
endBetter - Use GenServer pool for long-lived processes:
# Worker pool with Poolboy
defmodule WorkerPool do
def handle_request(request) do
:poolboy.transaction(
:worker_pool, # => Pool name
fn worker ->
GenServer.call(worker, {:process, request})
# => Reuse pooled worker
end,
5000 # => Timeout
) # => Type: term()
end
end
# Poolboy configuration
defmodule MyApp.Application do
use Application # => Application behavior
def start(_type, _args) do
poolboy_config = [
{:name, {:local, :worker_pool}}, # => Pool name
{:worker_module, Worker}, # => Worker module
{:size, 10}, # => Pool size (10 workers)
{:max_overflow, 5} # => Max overflow (15 total)
]
children = [
:poolboy.child_spec(:worker_pool, poolboy_config, [])
# => Poolboy worker pool
]
Supervisor.start_link(children, strategy: :one_for_one)
# => Type: {:ok, pid()}
end
endRule: Use Task.Supervisor for short tasks, GenServer pools for long-lived workers - avoid spawning bare processes.
10. ETS Misuse
FAIL - Using ETS without read/write patterns:
# ANTI-PATTERN: Write-heavy ETS with :set type
defmodule Cache do
def init do
:ets.new(:cache, [:set, :public, :named_table])
# => :set type for frequent writes
# => Contention on write operations
# => Type: :ets.tid()
end
def put(key, value) do
:ets.insert(:cache, {key, value}) # => Frequent writes cause contention
# => Type: true
end
def get(key) do
case :ets.lookup(:cache, key) do
[{^key, value}] -> {:ok, value} # => Lookup value
[] -> :not_found # => Key not found
end # => Type: {:ok, term()} | :not_found
end
endWhy It Fails:
- Write contention - :set type has contention on writes
- No expiration - Cache grows unbounded
- No access control - :public allows any process to modify
- Single table - All data in one table (no sharding)
CORRECT - Match ETS type to access pattern:
# Read-heavy cache with :ordered_set
defmodule ReadHeavyCache do
def init do
:ets.new(:read_cache, [
:ordered_set, # => Ordered for range queries
:public, # => Public for read access
:named_table, # => Named table
read_concurrency: true # => Optimize for concurrent reads
]) # => Type: :ets.tid()
end
def get(key) do
case :ets.lookup(:read_cache, key) do
[{^key, value, timestamp}] ->
if System.monotonic_time(:second) - timestamp < 3600 do
{:ok, value} # => Cache hit (valid)
else
:ets.delete(:read_cache, key) # => Expired entry
:not_found # => Cache miss
end
[] -> :not_found # => Cache miss
end # => Type: {:ok, term()} | :not_found
end
def put(key, value) do
timestamp = System.monotonic_time(:second)
# => Current timestamp
:ets.insert(:read_cache, {key, value, timestamp})
# => Insert with timestamp
# => Type: true
end
end
# Write-heavy log with :bag type
defmodule WriteHeavyLog do
def init do
:ets.new(:log, [
:bag, # => Allows duplicate keys
:public, # => Public access
:named_table, # => Named table
write_concurrency: true # => Optimize for concurrent writes
]) # => Type: :ets.tid()
end
def append(key, entry) do
:ets.insert(:log, {key, entry}) # => Append entry (duplicate key allowed)
# => Low contention with write_concurrency
# => Type: true
end
def get_all(key) do
:ets.lookup(:log, key) # => Get all entries for key
# => Type: [tuple()]
end
end
# Sharded cache for high concurrency
defmodule ShardedCache do
@shard_count 16 # => Number of shards
def init do
for i <- 1..@shard_count do
:ets.new(
:"cache_shard_#{i}",
[:set, :public, :named_table, read_concurrency: true]
) # => Create 16 sharded tables
end # => Reduces contention
end
def put(key, value) do
shard = get_shard(key) # => Determine shard by key hash
:ets.insert(shard, {key, value}) # => Insert into specific shard
# => Type: true
end
def get(key) do
shard = get_shard(key) # => Determine shard by key hash
case :ets.lookup(shard, key) do
[{^key, value}] -> {:ok, value} # => Lookup in specific shard
[] -> :not_found
end # => Type: {:ok, term()} | :not_found
end
defp get_shard(key) do
hash = :erlang.phash2(key) # => Hash key
shard_index = rem(hash, @shard_count) + 1
# => Calculate shard index (1-16)
:"cache_shard_#{shard_index}" # => Return shard table name
# => Type: atom()
end
endETS Type Selection:
| Type | Use Case | Concurrency Options |
|---|---|---|
:set | Unique keys, general | read_concurrency |
:ordered_set | Range queries, sorted | read_concurrency |
:bag | Duplicate keys allowed | write_concurrency |
:duplicate_bag | Duplicate entries | write_concurrency |
Rule: Match ETS type and concurrency options to access pattern - use sharding for high contention.
Summary of Anti-Patterns
| Anti-Pattern | Problem | Solution |
|---|---|---|
| God Object GenServer | Single point of failure | Separate GenServers per concern |
| Stateless GenServer | Unnecessary overhead | Use plain modules |
| Blocking GenServer | Process blocked | Delegate to Task |
| No Supervision | No automatic restart | Supervision tree for all processes |
| Wrong Supervision Strategy | Cascading restarts | Match strategy to dependencies |
| Missing Child Specs | Cannot restart by name | Proper child specs with :permanent |
| Message Queue Overflow | Memory exhaustion | Backpressure with queue limits |
| Synchronous Call Chains | Deadlock risk | Async cast or Task |
| Process Per Request | Creation overhead | Task.Supervisor or GenServer pool |
| ETS Misuse | Write contention | Match type to access pattern |
Key Principles
OTP-First Approach:
- Use supervision trees for all processes
- Apply correct supervision strategies
- Implement proper child specs
Process Design:
- GenServers for stateful operations only
- Plain modules for stateless operations
- Delegate blocking work to Tasks
Communication:
- Implement backpressure for message-heavy systems
- Avoid synchronous call chains
- Use async cast or Task for non-blocking operations
Performance:
- Pool long-lived processes
- Match ETS type to access pattern
- Shard for high concurrency
Production Reliability:
- Supervise all processes
- Handle errors with “Let It Crash”
- Monitor message queues for overflow
Next Steps
Apply these patterns to avoid production failures:
- Best Practices - Production patterns and idioms
- Genserver Patterns - GenServer design patterns
- Supervisor Trees - Supervision strategies
- Performance Optimization - Production optimization