Book 4 โ€” Phoenix: The Web Layer

Phoenix is not your framework-shaped black box. After Books 1โ€“3 you can see it plainly: an OTP application whose supervision tree happens to include a web server, where every HTTP request is handled by its own BEAM process running a pipeline of pure-ish functions over an immutable struct. This book walks the request path end to end, then adds the data layer (Ecto).

๐Ÿ““ Companion notebook: book4-phoenix.livemd โ€” build a Plug pipeline from scratch, boot a real HTTP server inside the notebook, and model data with Ecto changesets (no database required).

1. A Phoenix app is an OTP application

Open any Phoenix project's application.ex and you'll recognize everything from Book 3:

children = [
  MyAppWeb.Telemetry,       # metrics
  MyApp.Repo,               # Ecto: a pool of DB-connection processes
  {Phoenix.PubSub, name: MyApp.PubSub},  # pub/sub (Registry-like, cluster-aware)
  MyAppWeb.Endpoint         # the web server โ€” LAST, so deps are up first
]
Supervisor.start_link(children, strategy: :one_for_one)

And the concurrency model you already understand does the heavy lifting: each HTTP request runs in its own process (spawned by the Bandit/Cowboy server pool). A crashed request = one dead process and a 500 for that user; every other request is untouched. No global middleware state, no event-loop stalls. 100k concurrent requests is just 100k cheap processes.

2. Plug โ€” the whole web layer is one function signature

Everything between the socket and your business logic is a plug: a function (or module) that takes a connection struct and returns a (new, immutable) connection struct:

# The entire contract:
def my_plug(%Plug.Conn{} = conn, _opts) do
  conn                                    # %Plug.Conn{} in, %Plug.Conn{} out
  |> Plug.Conn.assign(:user, load_user(conn))
end

%Plug.Conn{} carries everything: request headers, params, session, response status/body, and assigns (a map for your own data โ€” you'll meet assigns again in LiveView). A request is a pipe:

conn |> plug1() |> plug2() |> plug3() |> router() |> controller_action()
Express/Django comparison: like middleware, but with no next(), no mutation, and no shared request object โ€” each plug returns a new conn. A plug can short-circuit by calling halt(conn) (e.g., auth failure โ†’ send 401, stop the pipeline). Testing a plug is calling a function with a struct. That's it โ€” there is no other abstraction hiding underneath.

3. The request lifecycle โ€” click each stage

1 ยท Endpoint
The front door โ€” a plug pipeline every request enters.
Serves static files, parses request bodies, handles sessions, logs โ€” then hands off to the Router. It's also the process boundary: by now your request has its own BEAM process. Config (port, SSL) lives here.
โ†“
2 ยท Router
Matches verb + path, applies a pipeline, dispatches.
get "/orders/:id", OrderController, :show โ€” compiled to pattern-matched function clauses (fast!). Pipelines like :browser (sessions, CSRF) or :api (JSON only) are named groups of plugs applied per route scope.
โ†“
3 ยท Controller
Your entry point: extract params, call the domain, render.
def show(conn, %{"id" => id}) โ€” params arrive pattern-matched. A good controller is 3 lines: parse input โ†’ call a context function โ†’ render the result. Controllers are themselves plugs.
โ†“
4 ยท Context
The business logic boundary (plain modules, no web).
Shop.get_order!(id) โ€” contexts know nothing about conn, HTTP, or JSON. This is where Ecto gets called. Everything above this line is "web"; everything below is "your actual application".
โ†“
5 ยท View / render
Turn data into HTML (HEEx templates) or JSON.
HTML via function components & HEEx (Book 5's building blocks), JSON via simple map-building functions. Rendering is pure: data in, iodata out โ€” then the conn is sent and the request process exits.

QUIZ 4.1

A controller action calls a context function that raises. What happens to the server?
Process-per-request means the blast radius of any error is exactly one request. This is Book 1's isolation paying rent โ€” no try/catch pyramid required for the server to be robust.

4. Contexts โ€” where your real app lives

Phoenix generators push you toward contexts: plain modules grouping related functionality behind a deliberate API, e.g. Accounts (users, auth), Shop (products, orders). The web layer may only call context functions โ€” never Repo directly.

defmodule Shop do
  # The public API of the "shop" subsystem. Callers never see Ecto details.
  def list_products, do: Repo.all(Product)
  def get_product!(id), do: Repo.get!(Product, id)

  def create_order(user, attrs) do
    %Order{user_id: user.id}
    |> Order.changeset(attrs)
    |> Repo.insert()          # โ†’ {:ok, order} | {:error, changeset}
  end
end

Why it matters: when you add LiveView (Book 5), a JSON API, and background jobs, they all call the same context functions. The web is an interchangeable delivery mechanism.

5. Ecto โ€” data mapping without an ORM's lies

Ecto looks like an ORM but rejects its core premise: there are no live objects that lazy-load and auto-save. There is data (structs), and there are explicit functions that move data to/from the database. Four pieces:

PieceWhat it isJS/Python analogy
RepoThe database gateway โ€” Repo.all/get/insert/update. A supervised connection pool (it's in your app tree!).the db client, but explicit โ€” nothing touches the DB except through it
SchemaMaps a table to a struct: schema "products" do field :name, :string enda model class, minus behavior โ€” pure shape
ChangesetA pipeline that casts + validates untrusted input, accumulating errors, before anything touches the DB.Zod/Pydantic + dirty-field tracking in one
QueryComposable, compile-checked query builder: from p in Product, where: p.price > 10a type-safe query builder (think Kysely/SQLAlchemy Core)

Changesets are the piece worth internalizing โ€” they make "validate then maybe fail" a first-class value you can inspect, test, and render form errors from:

def changeset(product, attrs) do
  product
  |> cast(attrs, [:name, :price])            # whitelist + type-coerce untrusted input
  |> validate_required([:name, :price])
  |> validate_number(:price, greater_than: 0)
  |> unique_constraint(:name)                # converts DB constraint errors to changeset errors
end

# Same changeset drives inserts, updates, AND form error display:
case Repo.insert(Product.changeset(%Product{}, params)) do
  {:ok, product}          -> ...
  {:error, changeset}     -> changeset.errors  # [price: {"must be greater than 0", ...}]
end

QUIZ 4.2

In Ecto, when does order.user load the associated user from the DB?
No lazy loading, ever. You must explicitly preload (in the query or after). Annoying for five minutes, then you realize you've permanently eliminated N+1 query surprises โ€” every DB hit is visible in the code.

6. A JSON API, end to end

# router.ex
scope "/api", MyAppWeb do
  pipe_through :api
  resources "/products", ProductController, only: [:index, :show, :create]
end

# product_controller.ex
defmodule MyAppWeb.ProductController do
  use MyAppWeb, :controller

  action_fallback MyAppWeb.FallbackController   # centralizes error โ†’ status mapping

  def index(conn, _params), do: render(conn, :index, products: Shop.list_products())

  def create(conn, %{"product" => attrs}) do
    with {:ok, product} <- Shop.create_product(attrs) do
      conn |> put_status(:created) |> render(:show, product: product)
    end                                          # {:error, changeset} falls through to FallbackController โ†’ 422
  end
end

Note with: it chains happy-path pattern matches; the first non-match exits the block and (via action_fallback) becomes the error response. The idiomatic replacement for nested try/catch or if-chains.

Deep dive: where's the async? (a Node dev's question)

There are no async/await keywords in controller code, yet nothing blocks the server. When your request process waits on the DB, the scheduler simply runs other processes โ€” every function call is implicitly awaitable because processes are preemptible and cheap. Synchronous-looking code, asynchronous system. This is why Elixir has no "colored functions" problem.

Recap

Endpoint โ†’ Router โ†’ Controller โ†’ Context โ†’ View, all plugs transforming an immutable conn inside one process per request. Contexts guard business logic; Ecto moves explicit data with changeset-validated writes. Next: LiveView โ€” where the request process doesn't exit after render, and that one difference gets you real-time UI.