Guide

Elixir fundamentals explained

Elixir is a functional language on the BEAM virtual machine — the same runtime that powers Erlang telecom switches and modern systems like Discord, WhatsApp, and Bleacher Report. Unlike Ruby’s object-oriented GVL model or Go’s goroutines with shared memory, Elixir builds on immutable data, pattern matching, and millions of lightweight processes that communicate by message passing. The Open Telecom Platform (OTP) adds supervision trees so crashed workers restart automatically — “let it crash” is a design principle, not negligence. This guide covers the BEAM runtime, core syntax, GenServer and supervision, Phoenix for HTTP and WebSockets, Ecto for Postgres, LiveView for server-rendered interactivity, a Harbor Fleet real-time status API worked example, a language decision table, common pitfalls, and a production checklist alongside our distributed systems and PostgreSQL references where they overlap.

What Elixir is: BEAM, Erlang, and the runtime model

Elixir compiles to bytecode executed by BEAM (Bogdan/Erlang Abstract Machine). You invoke it with elixir script.exs or run releases built with mix release. The VM schedules thousands of processes across CPU cores with preemptive scheduling — no global interpreter lock blocking parallel Elixir execution.

Erlang invented the model in the 1980s for fault-tolerant telecom; Elixir (José Valim, 2012) adds modern syntax, macros, and tooling while calling the same OTP libraries. Interop is seamless: Elixir modules can call Erlang functions and vice versa. Both target .beam files and share the hex.pm package ecosystem.

Why teams choose BEAM

  • Soft real-time latency — predictable millisecond response under load; garbage collection per process, not stop-the-world pauses on the whole VM.
  • Fault isolation — a bug in one process does not corrupt another’s heap; supervisors restart failed children.
  • Hot code upgrades — deploy new modules without dropping connections (used in telecom; less common in web but available).
  • Distribution — nodes connect in clusters; processes can spawn on remote nodes transparently when architected for it.

Pin Elixir and Erlang/OTP versions in .tool-versions or your Dockerfile; use mix as the build tool for deps, tests, and releases.

Syntax essentials: immutability, pattern matching, and pipes

Variables are immutable — rebinding uses = for pattern matching, not mutation:

{:ok, shipment} = Fleet.get_shipment("sh-42")
# shipment is bound; {:error, :not_found} would raise MatchError

%{status: status} = shipment
# destructures map keys

Pattern matching appears in function heads, case, and with expressions. Functions are identified by name and arity (handle/2 vs handle/3), enabling multiple clauses:

defp format_result({:ok, data}), do: Jason.encode!(data)
defp format_result({:error, reason}), do: "error: #{inspect(reason)}"

The pipe operator |> threads the previous expression as the first argument to the next function — readable data transformation pipelines:

fleet_id
|> Fleet.list_vehicles()
|> Enum.filter(&(&1.status == :active))
|> Enum.map(&Vehicle.to_json/1)

Atoms, tuples, and structs

  • Atoms:active, :ok; global constants, cheap equality, never garbage-collected (do not generate atoms from user input).
  • Tuples — fixed-size containers; {:ok, value} and {:error, reason} are idiomatic result types.
  • Structs — maps with a defined schema via defstruct; Ecto schemas and domain models use them.
  • Modulesdefmodule Harbor.Fleet.Vehicle do ... end; group functions; use alias and import sparingly.

Processes, GenServer, and message passing

A process is not an OS thread — it is a BEAM lightweight actor with its own mailbox and heap. Spawn with spawn/1 or, preferably, wrap stateful logic in a GenServer (generic server behaviour from OTP):

defmodule Harbor.Fleet.Tracker do
  use GenServer

  def start_link(fleet_id), do: GenServer.start_link(__MODULE__, fleet_id, name: via_tuple(fleet_id))

  def get_position(fleet_id), do: GenServer.call(via_tuple(fleet_id), :get_position)

  @impl true
  def init(fleet_id), do: {:ok, %{fleet_id: fleet_id, last_fix: nil}}

  @impl true
  def handle_call(:get_position, _from, state), do: {:reply, state.last_fix, state}

  @impl true
  def handle_info({:gps_update, fix}, state), do: {:noreply, %{state | last_fix: fix}}
end

GenServer.call is synchronous (waits for reply); GenServer.cast is fire-and-forget. Processes send messages with send(pid, msg). Because there is no shared mutable state, race conditions on in-memory data disappear — coordination happens through well-defined messages and transactional databases.

Use Registry or Horde for dynamic process naming across clustered nodes. For simple caching, consider Cachex or ETS tables owned by a GenServer rather than external Redis when a single node suffices.

Supervision trees and fault tolerance

OTP Supervisors start child processes and restart them on failure according to a strategy:

  • :one_for_one — restart only the crashed child (most web apps).
  • :one_for_all — restart all children if one dies (tightly coupled groups).
  • :rest_for_one — restart the failed child and those started after it.
children = [
  {Harbor.Fleet.Tracker, fleet_id},
  {Harbor.Fleet.Publisher, fleet_id}
]

Supervisor.start_link(children, strategy: :one_for_one)

Your Phoenix application starts a supervision tree in Application.start/2: the endpoint, PubSub, Ecto repo, and custom workers. Let it crash means: detect failure quickly, isolate blast radius, restart clean state — do not wrap everything in defensive try/rescue that hides bugs. Rescue at boundaries (HTTP layer, job consumers) and log with structured metadata.

Compare with circuit breakers for external HTTP dependencies — supervisors handle internal process health; circuits handle partner API outages.

Mix, Hex, and project layout

Mix is Elixir’s build tool. mix new harbor_fleet scaffolds a project; mix deps.get fetches Hex packages; mix test runs ExUnit; mix phx.new generates a Phoenix app.

  • Phoenix — web framework: router, controllers, channels, LiveView.
  • Ecto — database wrapper: schemas, changesets, migrations, query DSL.
  • Oban — Postgres-backed background jobs with uniqueness and cron plugins.
  • Telemetry — metrics events consumed by Prometheus exporters.
  • Credo / Dialyzer — style linting and static analysis for typespecs.

Standard Phoenix layout: lib/my_app/ domain logic, lib/my_app_web/ HTTP boundary, priv/repo/migrations, test/. Keep business rules in plain modules testable without HTTP — controllers should parse params, call context functions, and render responses.

Phoenix, Ecto, and LiveView

Phoenix routes HTTP and WebSocket traffic. Define routes in router.ex; controllers return JSON via json/2 or HTML. Plugs are middleware pipelines — authentication, request IDs, body parsing — composable like Rack or Express middleware.

defmodule HarborFleetWeb.StatusController do
  use HarborFleetWeb, :controller

  def show(conn, %{"fleet_id" => id}) do
    case Fleet.get_status(id) do
      {:ok, status} -> json(conn, status)
      {:error, :not_found} -> send_resp(conn, 404, "")
    end
  end
end

Ecto maps structs to PostgreSQL tables. Changesets validate and cast params before insert/update — never trust raw maps. Migrations version schema; Ecto.Repo.transaction wraps multi-step writes. Preload associations to avoid N+1 queries; Ecto.Query composes type-safe SQL.

LiveView renders interactive UIs server-side over a WebSocket — state lives in a process, diffs push to the browser. Ideal for dashboards, admin panels, and forms without a separate SPA. Pair with WebSocket fundamentals when you need custom channel protocols outside LiveView’s lifecycle.

Concurrency, clustering, and Oban jobs

Phoenix handles concurrent requests with one process per connection (or channel). CPU-bound work should not block the connection process — delegate to a Task.Supervisor or Oban job. Oban stores jobs in Postgres (no Redis required), supports unique constraints for idempotency, and provides cron via Oban.Plugins.Cron.

  • PubSub — Phoenix.PubSub broadcasts events to channels and LiveViews; Redis adapter optional for multi-node.
  • libcluster — discovers nodes on Kubernetes DNS or EC2 tags for distributed Erlang.
  • TasksTask.async_stream/3 for bounded parallel I/O with back-pressure.
  • Telemetry — attach handlers for [:phoenix, :endpoint, :stop] duration histograms.

For extreme read throughput, add read replicas via Ecto’s put_dynamic_repo/1 or cache hot keys in ETS with TTL — profile before introducing Redis unless you need cross-node cache invalidation at scale.

Worked example: Harbor Fleet real-time status API

Harbor Fleet tracks delivery vehicles. Operators need a JSON status endpoint and a live map that updates when GPS fixes arrive — without polling overload or dropped connections during deploys.

  1. Context moduleHarbor.Fleet exposes get_status/1 and ingest_gps/2; domain logic stays out of controllers.
  2. GenServer tracker — one Harbor.Fleet.Tracker per fleet via Registry; stores latest positions in memory; crashes and restarts reload from Postgres on init.
  3. HTTP APIGET /api/v1/fleets/:id/status returns JSON from Tracker or Repo fallback; 404 when fleet unknown.
  4. GPS ingest — partners POST signed payloads; verify HMAC plug; cast to Tracker; persist async via Oban worker for audit trail.
  5. LiveView map — operators subscribe to PubSub topic "fleet:#{id}"; Tracker broadcasts {:position, vehicle_id, lat, lng} on each fix.
  6. Supervision — DynamicSupervisor starts Tracker processes on demand; top-level Application supervisor uses :one_for_one.
  7. Deploymix release in Docker; rolling updates behind nginx; run mix ecto.migrate before traffic shift; cluster nodes with libcluster for shared PubSub when horizontally scaled.

Result: sub-10 ms status reads from memory, live map updates without client polling, and a crashed tracker restarts without taking down the entire API.

Language decision table

NeedPrefer ElixirConsider instead
Real-time WebSockets at high concurrencyYes — Phoenix Channels and LiveView excelNode.js with Socket.io if team is JS-only
Fault-tolerant telephony or IoT ingestionYes — OTP supervision is the core strengthGo for simpler ops if crash recovery is manual anyway
CRUD admin with minimal JavaScriptYes — LiveView reduces SPA surfaceDjango for Python hiring pool
CPU-heavy numeric ML trainingNo — call Python ports or external servicesPython with NumPy/PyTorch
Greenfield team with no BEAM experienceMaybe — invest in OTP learning curveRuby or Go for faster onboarding
Embedded firmware on microcontrollersNoC, Rust, or Nerves for BEAM-on-device niche
Existing Erlang system to extendYes — seamless interopStay on Erlang if codebase is pure Erlang already
Batch ETL on terabyte Parquet filesNoSpark, DuckDB, or Python pandas pipelines

Common pitfalls

  • Atom exhaustion from user inputString.to_atom/1 on dynamic strings leaks atoms; use String.to_existing_atom/1 or string keys.
  • Blocking the connection process — long GenServer.call chains or synchronous HTTP inside channels stall WebSockets; use casts, Tasks, or Oban.
  • Missing supervision — bare spawn without a supervisor loses state silently; always wire OTP trees.
  • Changeset skipped validation — inserting raw maps bypasses Ecto constraints; always pipe through changeset/2.
  • N+1 queries in JSON APIs — preload associations in context functions before serialization.
  • Cluster cookie exposedRELEASE_COOKIE must stay secret; treat distributed Erlang as a trusted network boundary.
  • Over-using macros — compile-time magic obscures stack traces; prefer functions and behaviours.
  • Ignoring back-pressure — unbounded mailboxes on hot processes; monitor queue length and shed load.
  • Single-node PubSub in production — LiveView on multiple nodes needs Redis PubSub or PG2 adapter configured.

Production checklist

  • Pin Elixir and OTP in CI and Docker; run mix test and Credo on every pull request.
  • Use mix release with runtime_tools for observer and remote debugging in staging only.
  • Configure Ecto pool size from expected concurrent DB usage; use connection pooling (PgBouncer) at scale.
  • Oban queues with unique periods on webhook and payment jobs; monitor oban_jobs stuck states.
  • Structured JSON logging via LoggerJSON; include request_id from Phoenix plugs.
  • Expose /health (liveness) and /ready (DB + critical deps) for Kubernetes probes.
  • Telemetry metrics exported to Prometheus; alert on endpoint latency p99 and BEAM memory.
  • Secrets via environment or vault — never commit config/prod.secret.exs.
  • Load-test WebSocket fan-out before launch; verify PubSub adapter handles target node count.
  • Document OTP supervision tree in README so on-call engineers know what restarts automatically.

Key takeaways

  • Elixir runs on BEAM with immutable data, pattern matching, and message-passing processes — designed for fault tolerance, not shared-memory threads.
  • OTP supervisors restart failed workers; pair GenServer state with Postgres for durability across crashes.
  • Phoenix + Ecto deliver HTTP, WebSockets, and LiveView UIs with Postgres persistence and idiomatic validation via changesets.
  • Oban provides Postgres-backed background jobs without a separate Redis queue for many workloads.
  • Choose Elixir for real-time, high-connection services and teams willing to learn OTP; prefer other stacks for heavy numeric compute or embedded constraints.

Related reading