F6.01 · part 3 of 3
Controllers, views & the facade seam
This is where your code lives in the lifecycle, and it is the place the whole F5/F6 split pays off.
A controller action receives the matched request, calls the Portal
facade, branches on the closed %Portal.Error{} contract, and hands a
view its data; the view turns that data into markup. The rule is one sentence: a controller
names only Portal and its error struct — never Portal.Engine,
never a repo, never a GenServer.call. Keep that rule and the controller stays a thin
translation between HTTP and the engine, which is exactly what let the F5.09 lab promise that F6 would change
nothing below the facade.
See the three players and the one-way seam between them, then read a real controller and the view it renders.
Three players
The controller, the view, and the facade each do one job. The controller decides and delegates; the facade holds the domain; the view renders. Select one to see its role — and notice that only the controller touches the facade, and only the view touches markup.
The seam · select a player
player: Controller
does: calls the facade, picks a view
A fat controller is the usual way a web app rots: domain logic leaks into actions until the engine and the web are tangled. The facade rule keeps the controller honest — if a line is not a facade call, a branch on the result, or a render, it does not belong here.
The seam, one direction
The relationships only point one way. The controller calls down into the facade and hands data sideways to the view; neither the facade nor the view ever calls back into the controller, and the view never calls the facade. That one-way shape is what makes the web layer replaceable — swap a controller for a LiveView in F6.06 and the facade below does not change.
Controller in the middle, calls down, renders sideways
One-way dependencies are the whole game. The controller knows the facade and the view; neither knows the controller. Replace the caller and everything it called stays exactly as it was.
A controller and its view
A write action shows the shape: build nothing, call Portal.enroll/2, and map its two
outcomes onto a redirect with a flash message. The view is a separate module that embeds templates; the HEEx
renders only from assigns, so it has no way to reach the engine even if it wanted to.
# the controller: call the facade, branch on the contract, redirect defmodule PortalWeb.EnrollmentController do use PortalWeb, :controller def create(conn, %{"user" => uid, "course" => cid}) do case Portal.enroll(uid, cid) do :ok -> conn |> put_flash(:info, "Enrolled") |> redirect(to: ~p"/courses/#{cid}") {:error, %Portal.Error{message: msg}} -> conn |> put_flash(:error, msg) |> redirect(to: ~p"/courses") end end end
The view module embeds its templates; the HEEx draws from assigns and nothing else:
# the view module: embed the templates for this controller defmodule PortalWeb.CourseHTML do use PortalWeb, :html embed_templates "course_html/*" end # course_html/show.html.heex — renders only from assigns, calls no engine <h1>{@course.title}</h1> <progress value={@progress} max="100"></progress> <.link navigate={~p"/courses"}>All courses</.link>
decide & delegate
The controller calls the facade and maps the result — the only domain-aware step.
render assigns
The view turns assigns into HEEx — data in, markup out.
That completes F6.01. The next module, F6.02 — Routing, controllers & plugs, builds out the match in the middle of the lifecycle. Back to the module overview or the F6 chapter.
References
Primary sources for this lesson, and where it connects in the course.
Sources
- Overview — Phoenix documentation — the framework at a glance.
- Request life-cycle — Phoenix documentation — endpoint to view, where the controller sits.