F6.04 · part 3 of 3
Composing contexts
Real domains have dependencies: enrolling a student needs to know the course exists. The boundary
does not forbid that — it dictates how it happens. One context depends on another only through its
public API: Enrollment calls
Catalog.fetch_course/1, branches on the public struct it gets back, and never builds a
query against Catalog’s tables or touches its Repo. Data
crosses a boundary as an id or a public struct, not as a schema the other side could couple to.
And the dependency graph stays acyclic — if Enrollment uses
Catalog, then Catalog must not reach back, or the two collapse
into one tangled unit. When a single operation spans contexts, you orchestrate the public calls with
with and return the one closed error shape; when writes inside a single context must be
atomic, you reach for Ecto.Multi. The Phoenix guides frame this as the difference
between cross-context dependencies and intra-context transactions, and getting it right is what keeps a growing
app from turning back into a ball of mud.
Four parts: the rules for one context calling another, the shape of a healthy dependency graph, a real cross-context call, and how to keep an operation consistent across or within contexts.
How contexts relate
Three rules keep composition from eroding the boundary. Relate by public API — call the other context’s functions. Pass data by id or public struct — never a private schema. And never the schema or Repo — one context does not query another’s tables. Select a rule.
Rules of composition · select one
rule: By public API
means: call its public functions
A foreign struct passed across a boundary is a quiet coupling: if
Enrollment pattern-matches the internal fields of Catalog’s
schema, a column rename in Catalog now breaks Enrollment. Pass
the id, or a struct the other context publishes on purpose.
A healthy graph
Dependencies between contexts form a graph, and the graph should have no cycles. A one-way edge
(Enrollment → Catalog) is fine: enrollment needs the
catalog, the catalog knows nothing of enrollment. A cycle — each context calling the other — means
they cannot be understood, tested, or compiled apart, and the boundary is fiction. When you feel a cycle
forming, the fix is usually a third context, an event, or moving the shared concept to whichever side truly
owns it.
One-way edges, no cycles
A cycle is the clearest signal a boundary is wrong. Two contexts that need each other constantly were probably one context all along — or there is a missing third that both should depend on.
One context calling another
Enrollment.enroll/2 must refuse to enroll in a course that does not exist or is not
published. It does so by calling Catalog’s public, non-raising
fetch_course/1, branching on the returned struct’s public field, and folding
every outcome into the closed %Portal.Error{} contract. It never imports
Catalog.Course and never calls Repo.
defmodule Portal.Enrollment do alias Portal.Catalog # depend on Catalog's public API only alias Portal.{EventStore, Error} alias Portal.Enrollment.Enrolled @doc "Enroll a user, refusing an unknown or unpublished course." def enroll(user_id, course_id) do case Catalog.fetch_course(course_id) do # {:ok, course} | {:error, :not_found} {:ok, %{published: true} = course} -> event = %Enrolled{user_id: user_id, course_id: course.id, at: DateTime.utc_now()} EventStore.append("enrollment:#{user_id}", [event]) {:ok, %{published: false}} -> {:error, Error.new(:course_unpublished)} {:error, :not_found} -> {:error, Error.new(:course_not_found)} end end end
Notice the variant: composition wants fetch_course/1 returning a tagged
tuple, not the raising get_course!/1 a controller uses. A context exposes both so each
caller picks the failure mode it can handle.
Consistency across contexts
When one operation spans contexts, chain their public calls with with: each step
returns a tagged tuple, the first error short-circuits to else, and the whole thing
returns one %Portal.Error{}. That is orchestration, not a transaction — each
context owns its own writes. When writes within one context must be all-or-nothing, that context
reaches for Ecto.Multi and a single Repo.transaction; you do
not stretch one transaction across contexts.
# orchestrating across contexts: each step is a public call returning a tagged tuple def enroll_and_welcome(user_id, course_id) do with {:ok, course} <- Catalog.fetch_course(course_id), {:ok, _enr} <- Enrollment.enroll(user_id, course.id), {:ok, _msg} <- Accounts.notify(user_id, {:enrolled, course.id}) do {:ok, course} else {:error, %Portal.Error{} = err} -> {:error, err} # one closed error shape end end # inside ONE context, group writes transactionally with Ecto.Multi (all-or-nothing): # Multi.new() |> Multi.insert(:enr, cs) |> Multi.update(:seat, seat_cs) |> Repo.transaction()
across contexts
Orchestrate public calls with with; return the closed error.
within a context
Ecto.Multi + one Repo.transaction for atomicity.
That completes F6.04. The domain is a graph of public APIs, each a context, all reachable through the
Portal facade. The next module, F6.05 — HEEx & components,
renders these contexts’ data into markup. Back to the module
overview or the F6 chapter.