Elixir/OTP anti-pattern audit
Classify every suspect by call path, then judge — severity follows the path, not the construct:
- hot = per-request dispatch (
Hook.call/3) or a tight loop → fix it. - warm = periodic/background loop (timers, polls) → fix if it scales with N.
- cold = boot / compile / admin / diagnostic → usually fine as-is.
A try/rescue, apply/3, or function_exported? on a cold path is fine; the same on a hot path is the bug. Always trace callers before judging.
This file is extensible — check the boxes as you refactor, and add new items as more are found.
Open items in THIS project (audit 2026-06)
- WARM — plugin status poll hit Mnesia every ~1s per plugin.
lib/event/hook.explugin_status_poll/2now usesEvent.dirty_get(:name, module)(a non-transactional dirty index read) instead ofEvent.get(a txn) — same result, no lock/commit overhead. (Further option if it ever matters: push via the:re_eventbroadcast and drop the poll entirely.) - HOT (low) — per-dispatch string→atom.
ModuleStateCompiler.module_event_name/1now memoizes the event-string→module atom in:persistent_term(write-once, lock-free reads), soHook.call/3no longer rerunsString.to_atom+trim/replace/regex/camelize each dispatch. (Thefunction_exported?(module, :call, 2)guard stays — it's the cheap, intentional "ghost event returns data unchanged" safety;String.to_existing_atomhardening is moot now that the name is cached.) - HOT — generated
call/2apply/3per plugin → unrolled.create/3now compiles the chain into straight-line direct calls ({:reply, s} = Mod.call(prev)per plugin, one sharedMacro.var(:state, …)) — noapply, no list walk;perform/2helper deleted. The one outerrescuestays (the faithful error boundary: a plugin breaking the{:reply, _}contract → input state). Module names are baked in at compile time. (Router/event-string dispatch — item ② — deferred.) - WARM (by design) —
EventHandlerserializes compiles. Intentional mutual-exclusion around:code.purge/recompile/cluster-broadcast; not a bug. MonitorProcess.info(pid, :message_queue_len); shard by event hash only if churn ever bites. - COLD — no action:
rescue_initialize?(dead code), health-proberescue+catch(intentional, time-boxed in aspawn_monitor), compile-pathCode.ensure_loaded?inEventHandler.perform.
Reusable checklist
GenServer / process bottlenecks
- Code organization by process — a GenServer doing pure logic/lookups with no real concurrency need. Grep
GenServer.call\|handle_call. Fix: plain functions; keep the process only for genuine runtime concerns. - Process instead of ETS for read-heavy shared state — a server mostly answering "value for key K". Fix: ETS (
read_concurrency: true) or:persistent_term; keep the process only to serialize writes. - Blocking/CPU-bound work in
handle_call— stalls every queued caller. Fix:GenServer.replyfrom a spawned worker, or move state to ETS.
Hot-path try/rescue & exceptions
- try/rescue/catch as control flow on hot paths/loops. Grep
rescue\|catch \|try do, confirm each is cold. Fix: pattern-match expected shapes; return{:ok,_}/{:error,_}on the fast path. - Reifying stack traces on the hot path —
__STACKTRACE__per request/loop. Fix: capture only where a real crash is logged.
Runtime introspection in loops
-
function_exported?/Code.ensure_loaded?/apply/3on hot/warm paths. Grepfunction_exported?\|Code.ensure_loaded\|apply(. Fix: cache the boolean/module ref at compile/register time (:persistent_term); prefer a direct static call or compile-time AST. - Periodic poll doing DB/introspection on a timer. Grep
send_after\|send_interval. Fix: push on change via broadcast/PubSub; cache in ETS/:persistent_term.
Dynamic atoms
- Dynamic atom creation —
String.to_atom/*_to_atomon runtime input. Grepto_atom. Fix:*_to_existing_atom, or an allow-list map; convert once, not per call. - Dynamic apply with built atoms —
apply(String.to_atom(...), ...). Fix: dispatch via a map keyed by the string to{Module, Fun}.
Untracked / compile-time deps
- Untracked compile-time deps —
Module.concat/:"Elixir.#{name}"building names dynamically. Fix: explicit module names, or generate AST in a macro. (Intentional runtime DB-driven modules are NOT this anti-pattern.) - Macro compile-time deps — DSL macros referencing module args in a module body → recompilation cascades. Fix: expand inside a generated function (
Macro.expand_literals/2).
Non-assertive access
- Non-assertive map access —
map[:key]for required keys (returnsnil). Fix:map.key/ match in the head; reserve[:key]for optional keys. - Non-assertive pattern matching —
Enum.atwithout bounds, catch-all_, tolerating wrong arities. Fix: assert shape ([k, v] = String.split(...)), match all expectedcaseclauses.
Design / control flow
- Unrelated multi-clause function/module —
@docsays "if called like X… if like Y…". Fix: split into named functions. -
with…elseflattening distinct errors — anelse _ -> ...collapsing different failures. Grepwith, inspectelse. Fix: match each error shape, or let the tagged error propagate unchanged. - Folklore micro-optimization — contortions "because BEAM is slow", blanket tail-recursion, NIF-first. Fix: don't optimize on myth; profile (
fprof/eprof). NIFs are a last resort.
References
- Elixir: code · design · process · macro anti-patterns
- Erlang Efficiency Guide: processes · common caveats · seven myths