Elixir: Designing Elixir Systems
Where Does State Live?
| State kind | Home | Example |
|---|---|---|
| Request-scoped | the conn / function arguments | form data, auth token |
| Session-ish, per connection | the connection's process (LiveView assigns, channel state) | wizard progress, cursor position |
| Shared, mutable, small | named GenServer or Agent | rate-limit counters, cached config |
| Shared, read-mostly, hot | ETS / :persistent_term | route tables, feature flags |
| Durable, authoritative | Ecto / external store | orders, users, balances |
The classic mistake is putting durable state in a process. A process is a cache of the truth, not the truth: if the data must survive a crash, it belongs in Ecto or an ETS table backed by a guardian process.
Contexts and Boundaries
A context module is a named slice of the domain (Billing, Identity,
Catalog) exposing a public API and hiding its internals. Boundaries are enforced
socially at first and mechanically with the mix arch/boundary libraries or
Elixir 1.20's clearer warnings about cross-module references.
defmodule MyApp.Billing do
@moduledoc """
All money movement. Nothing outside this module may touch
Invoice/charge schemas or call payment providers directly.
"""
alias MyApp.Billing.{Charge, Invoice}
alias MyApp.Repo
defdelegate total(invoice), to: Invoice # narrow surface, cheap to keep
def capture(%{} = params), do: %Charge{} |> Charge.changeset(params) |> Repo.insert()
end
Monolith or Umbrella?
Start with a single app and let contexts define the seams. Umbrellas pay off when apps have genuinely different dependency sets or when a component needs independent deployment (an escript, a separate worker release). Reach for microservices only when organizational or scaling pressure demands it — the BEAM gives you distribution inside one deploy artifact, which removes the most common reason for splitting services (see Distributed Elixir).
Designing for Failure
- Draw the supervision tree before the code. Every box is a restart domain; if you cannot explain what a restart means for users, the design is not done.
- Timeouts at every boundary.
GenServer.calltimeouts, HTTP timeouts, pool checkout timeouts — a system without timeouts is a deadlock waiting for load. - Circuit-break at process boundaries: when a downstream dependency fails, keep failing fast (a small GenServer tracking breaker state) instead of queuing requests.
- Test failure, not just success: kill processes in test (the
:sysandProcess.exitroute) and assert the recovery behaviour.
Anti-Patterns
- The God GenServer — one process owning everything serializes the whole system; split by ownership (one process per entity).
- The Application junk drawer — dumping helpers into
MyApp.Utilsinstead of naming domain concepts. - Hidden state flow — the process dictionary, global singletons, or
:persistent_termas a mutable store. - Web code in contexts —
Plug.Connbelow the web layer makes domain code untestable and un-reusable. - Optional dependencies everywhere — five service-discovery layers for a three-node cluster is résumé architecture.
Practice
- Draw the supervision tree for a chat service: connections, presence, message fan-out, and what restarts when each crashes.
- Take an existing project and extract two contexts; list the public API of each in ten lines.
- Pick one anti-pattern above and find it in an open-source project's issue tracker — real-world archaeology beats theory.