Elixir: Designing Elixir Systems

Elixir architecture answers three questions: where does state live, where are the boundaries, and what happens when each piece fails. This lesson turns the OTP mechanics you learned into a design method.

Where Does State Live?

Layers of an Elixir system: web layer calling contexts, contexts using processes and Ecto, all supervised in a tree
Fig. 1 — Dependencies point downward only: web code calls contexts, contexts own data and processes, nothing calls upward.
State kindHomeExample
Request-scopedthe conn / function argumentsform data, auth token
Session-ish, per connectionthe connection's process (LiveView assigns, channel state)wizard progress, cursor position
Shared, mutable, smallnamed GenServer or Agentrate-limit counters, cached config
Shared, read-mostly, hotETS / :persistent_termroute tables, feature flags
Durable, authoritativeEcto / external storeorders, 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.call timeouts, 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 :sys and Process.exit route) 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.Utils instead of naming domain concepts.
  • Hidden state flow — the process dictionary, global singletons, or :persistent_term as a mutable store.
  • Web code in contexts — Plug.Conn below 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

  1. Draw the supervision tree for a chat service: connections, presence, message fan-out, and what restarts when each crashes.
  2. Take an existing project and extract two contexts; list the public API of each in ten lines.
  3. Pick one anti-pattern above and find it in an open-source project's issue tracker — real-world archaeology beats theory.

Next: Performance & Observability