jonnifyelixir
F6 / F6.04 / composition

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

BY PUBLIC API Enrollment calls Catalog.fetch_course/1 — a public function allowed BY ID OR STRUCT pass course_id or a public %Course{} — not a private schema data, not internals NEVER THE SCHEMA no Repo.get(Catalog.Course, id) from outside Catalog forbidden

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

HEALTHY — acyclic Enrollment Catalog Accounts (independent) one way AVOID — a cycle Billing Orders each calls the other

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.