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).
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).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.
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()
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.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.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.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".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.
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:
| Piece | What it is | JS/Python analogy |
|---|---|---|
Repo | The 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 |
Schema | Maps a table to a struct: schema "products" do field :name, :string end | a model class, minus behavior โ pure shape |
Changeset | A pipeline that casts + validates untrusted input, accumulating errors, before anything touches the DB. | Zod/Pydantic + dirty-field tracking in one |
Query | Composable, compile-checked query builder: from p in Product, where: p.price > 10 | a 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
order.user load the associated user from the DB?# 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.
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.
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.