F6.04 · part 1 of 3
Context boundaries
A context earns its keep by what it refuses to expose. The Phoenix guides describe a context as a
module that groups related functionality and presents a public API; the discipline that makes the description
worth anything is the second half — everything else is private. The schemas the context
defines, the queries it builds, the Repo it calls: none of that crosses the boundary. A
caller sees Catalog.list_courses/0 and Catalog.get_course!/1,
not Course the schema and not Repo.all/1. That single rule is
what lets a context change its storage, rename a column, or split a table without any caller noticing — the
same payoff the F5 facade gave the engine, now applied to the data layer. The hard part is not the syntax; it is
deciding where the line goes, and then holding it when a controller would find it convenient to reach
through.
Four parts: what a context exposes versus hides, how to choose the boundary by cohesion, the public API in real code, and what it looks like when a caller respects the seal versus breaks it.
Exposed and hidden
Three things live in a context, and only one of them is public. The public API is the set of named functions a caller may use. The schemas the context defines are private — they describe its tables, but no one outside builds queries against them. And the Repo is sealed inside: the context is the only code that runs a query or an insert for its slice. Select one to see which side of the boundary it sits on.
Inside a context · select a part
part: Public API
is: named functions callers use
The boundary is a promise about what may break. Anything public is a contract you keep; anything private is yours to change. A context with a wide public surface has promised too much.
Where the line goes
The Phoenix guides are deliberate that contexts are grouped by cohesion, not by table. The question is not “how many tables” but “what changes together.” Courses and lessons change with the catalog and belong in one context; a user’s identity changes with authentication and belongs in another, even though an enrollment links them. Drawing the line well means two contexts can evolve on separate schedules; drawing it badly means every feature touches every context. The map below contrasts a cohesive split with the temptation to make a context per table.
By cohesion, not by table
When unsure, prefer fewer contexts. It is easier to split a context that has grown a clear internal seam than to merge four anemic ones that turned out to be the same capability all along.
The public API
In code, a context is a module whose public functions are the API and whose alias of
its own schema stays inside. Catalog exposes three functions; the
Course schema and every Repo call are private to the module.
A caller that wants a course calls a function and receives a struct — it never learns that
Course exists.
defmodule Portal.Catalog do @moduledoc "The catalog of courses. The only public entry to course data." import Ecto.Query alias Portal.Repo alias Portal.Catalog.Course # the schema is private to this module @doc "List published courses, newest first." def list_courses do Repo.all(from c in Course, where: c.published == true, order_by: [desc: c.inserted_at]) end @doc "Fetch one course by its Snowflake id, or raise." def get_course!(id), do: Repo.get!(Course, id) @doc "Create a course; returns {:ok, course} | {:error, changeset}." def create_course(attrs) do %Course{id: Portal.ID.snowflake("CRS")} |> Course.changeset(attrs) |> Repo.insert() end end
The ! on get_course!/1 is a contract choice: it
raises on a missing row, which a controller route turns into a 404. A non-raising
get_course/1 returning nil or
{:ok, course} is the variant a composing context prefers — F6.04.3 uses it.
Respecting the seal
The boundary only holds if callers honour it. The two controllers below do the same thing; one reaches past
the API into Catalog’s schema and Repo, the other calls
the public function. The first compiles and works — which is exactly why the discipline matters —
but it has welded the web layer to a table name, so the next schema change breaks a controller.
# WRONG — the controller reaches past the boundary into Catalog's internals def show(conn, %{"id" => id}) do course = Portal.Repo.get!(Portal.Catalog.Course, id) # knows the schema AND the Repo render(conn, :show, course: course) end # RIGHT — the controller depends only on the public API def show(conn, %{"id" => id}) do course = Portal.Catalog.get_course!(id) # one function, no internals render(conn, :show, course: course) end
expose a little
A few named functions; schemas and the Repo stay inside.
change freely
The slice can rewrite its storage without breaking a single caller.
Next: contexts vs the F5 facade
— this public-API-over-a-slice is exactly the Portal facade, and the two names
describe one boundary.