jonnifyelixir
F5 / F5.02 / contexts

F5.02 · part 2 of 3

Bounded contexts

Structs on their own are a pile of shapes; a bounded context gives them an owner. A context is a module that holds a few related entities and the rules that bind them — Accounts owns User and Session, Catalog owns Course, Lesson, and Page, Learning owns Enrollment and Progress. The rule that keeps them decoupled is simple: a context references another only by branded id, never by reaching into its structs. An Enrollment carries a user_id and a course_id, not a %User{} and a %Course{} — so Accounts and Catalog can change their internals without breaking Learning.

Three contexts and the references between them. Select one to see what it owns and what it points at.

Three contexts

The dependencies all run one way: Learning depends on Accounts and Catalog by holding their ids, and neither of those depends on anything. A reference by id is a loose coupling — it survives the other context reshaping its data — where a reference by struct would weld the two together. Select a context to read its owned entities and outgoing references.

The contexts · select one

user_id course_id ACCOUNTS User · Session USR · SES CATALOG Course · Lesson · Page CRS LSN PGE LEARNING Enrollment · Progress ENR · PRG

context: Learning

references: USR, CRS (by id)

A context is the unit of decoupling. Owning its entities and pointing at others only by id is what lets each one be understood, tested, and changed on its own.

In code

Each context is one module with its entities beneath it, and the reference rule shows up directly in the data: an Enrollment holds ids, not structs. That single discipline is what keeps the arrows in the diagram pointing one way.

# one module per context, entities beneath it
lib/portal/
  accounts.ex      # Portal.Accounts  -> accounts/{user.ex, session.ex}
  catalog.ex       # Portal.Catalog   -> catalog/{course.ex, lesson.ex, page.ex}
  learning.ex      # Portal.Learning  -> learning/{enrollment.ex, progress.ex}

defmodule Portal.Learning do
  alias Portal.Learning.{Enrollment, Progress}
  # owns Enrollment and Progress; exposes a small public API (next dive)
end

# references cross contexts by id, never by struct
%Enrollment{user_id: "USR0Nb2", course_id: "CRS0Nb3"}   # ids — not %User{} / %Course{}

a boundary

A context owns its entities and the rules that bind them — one place to look.

referenced by id

Other contexts hold a USR or CRS id, so each side changes alone.

Next: a context’s public API — the small set of functions that is the only way into a context.

Translating at the boundary

A reference by id keeps two contexts apart, but eventually one has to read another’s data. The discipline that keeps the boundary intact at that moment is the anti-corruption layer: when Learning needs a course, it calls Catalog’s public function and then translates the returned %Course{} into a plain shape of its own, instead of storing the foreign struct and depending on its fields. The name and the pattern come from Eric Evans’ Domain-Driven Design, where an anti-corruption layer is the seam that stops one model’s vocabulary from leaking into another’s. The Phoenix — Contexts guide gives the same rule a sharper edge: a context call returns a value, and the caller owns whatever it keeps.

The benefit is the one the reference-by-id rule already promised, now extended to the moment of reading. A %Course{} can grow a field, drop a field, or rename one, and only the translation function inside Learning changes. The ubiquitous language stays consistent on each side: Catalog speaks of a Course with a syllabus; Learning records a course_id and the small %{title, lessons} summary it actually uses.

The translating seam between two contexts

LEARNING course_summary/1 CATALOG fetch_course/1 TRANSLATE %Course{} ↓ %{title, lessons} call: CRS id return: local map the %Course{} stops at the seam; only a local shape moves on
defmodule Portal.Learning do
  alias Portal.Catalog

  # cross a boundary through the API, then translate the foreign struct away
  @spec course_summary(String.t()) :: {:ok, map()} | {:error, atom()}
  def course_summary(course_id) do
    with {:ok, course} <- Catalog.fetch_course(course_id) do
      {:ok, translate(course)}   # %Course{} never leaves this module
    end
  end

  # the anti-corruption layer: Catalog's model -> Learning's own small shape
  defp translate(%Catalog.Course{title: title, lessons: lessons}) do
    %{title: title, lessons: length(lessons)}
  end
end

# Catalog answers with its own entity; Learning keeps only what it translated
Portal.Learning.course_summary("CRS0Nb3")
# => {:ok, %{title: "Pragmatic Elixir", lessons: 12}}

Portal.Learning.course_summary("CRS0000")
# => {:error, :not_found}

an anti-corruption layer

The seam where one context reads another — through its API, then into a shape of its own.

a local shape

Only the translated %{title, lessons} moves on; the %Course{} stays in Catalog’s world.

A boundary holds at the moment of reading too: cross through the public API, translate the foreign struct into a local shape, and each context’s language stays its own.

References

Primary sources for this lesson, and where it connects in the course.

Sources

  • Phoenix — Contexts — bounded contexts as modules with a public function API, and how one context references or calls another.
  • Eric Evans — Domain-Driven Design: Tackling Complexity in the Heart of Software (Addison-Wesley, 2003) — the source of bounded context, ubiquitous language, and the anti-corruption layer.
  • ExUnit — Elixir documentation — the test framework that exercises each context across its boundary.

Related in this course