F5 · the engine · module 8
Boundaries & integration seams
Seven modules in, the engine works: a pure core of decide and
evolve, a supervised GenServer that holds the fold, and a test suite that pins it down.
What it does not yet have is a boundary. Right now a caller would have to know the engine is a
process, know the message shapes it accepts, and know how state is stored. That is three kinds of coupling the
web layer in F6 should never carry. This module draws the boundary the way Alistair Cockburn frames
ports and adapters — the application core defines its edges as interfaces and the outside
world plugs into them — rendered in Elixir as behaviours. The core states what it needs
(a driven port such as an event store) and how it is invoked (a driving port,
the facade the UI calls); adapters supply the implementations; and every failure that crosses the line does so
as a typed error contract the UI can render.
One core, two kinds of port, one error vocabulary. Select a seam to see what it is and which dive builds it.
What the caller must know
[ process · messages · store ]
Three couplings the caller must carry.
The shape of the boundary
The hexagon is Cockburn's picture: the application core sits inside, ignorant of what is outside it, and each edge is a port — a named interface. Things that drive the application (a web request, a test, a CLI) come in through a driving port; things the application drives (a database, a clock, a message bus) sit behind driven ports. The crucial rule is the direction of dependency: the core depends on its own ports, never on the adapters that implement them. Select a seam to see where it sits and what it carries.
The hexagon · select a seam
seam: Driven ports
in the Portal: behaviours the core needs
A boundary is not a folder layout; it is a set of named interfaces with the dependency arrow pointing inward. Get the arrow right and the database, the web, and the clock all become details.
Three seams in code
Each seam is one small Elixir construct. A driven port is a @behaviour the core calls
and config resolves to an adapter; the driving port is a context module of intention-revealing functions; the
error contract is a struct with a closed set of codes. Seen together before the dives unpack them:
# 1. a driven port — the core depends on this behaviour, not on any adapter defmodule Portal.EventStore do @callback append(stream :: String.t(), [struct]) :: :ok | {:error, term} @callback read_stream(stream :: String.t()) :: {:ok, [struct]} | {:error, term} end # 2. the driving port — the only surface the UI in F6 will call defmodule Portal do def enroll(user_id, course_id), do: Engine.command({:enroll, user_id, course_id}) end # 3. the error contract — a closed vocabulary the UI can render defmodule Portal.Error do @type code :: :already_enrolled | :course_not_found | :invalid_progress defstruct [:code, :message, :field] end
The config line that picks an adapter —
config :portal, :event_store, Portal.EventStore.Postgres — is the whole of the
wiring; tests set it to Portal.EventStore.InMemory and nothing in the core changes.
Three deep dives
In order: ports as behaviours and adapters as their implementations, with the dependency arrow pointing inward; the facade as the one stable door the UI calls; and the error contract that turns internal failures into a finite set of outcomes the UI can render.
@behaviour; adapters implement it; config swaps them. One event-store contract, an in-memory adapter and a Postgres adapter, and a core that never names either.enroll/2, deliver_lesson/2, progress_of/1 — that hides the GenServer, the log, and the reducer behind one door.%Portal.Error{} codes, mapped at the boundary, so the UI renders a finite set of outcomes and never sees a raw exception or an internal struct.F5.07 · a tested engine
The core is pure and pinned by tests, but its callers still have to know it is a process with message shapes.
F5.08 · a usable boundary
Ports invert the dependencies, a facade hides the runtime, and errors cross the line in a shape the UI knows.
Start with ports & adapters, then the engine facade, then error contracts for the UI. The next module, F5.09 — the engine lab, assembles the facade end to end and mounts it behind a LiveView sketch for F6. The engine behind this boundary is F5.06 · where engine state lives, and the runtime path is the design brief the command & event flow.
References
Ports and adapters, behaviours as the port mechanism, and adapters chosen by configuration.
Sources
- Alistair Cockburn — Hexagonal architecture — the ports-and-adapters pattern.
- Elixir — Typespecs & behaviours — defining a port as a behaviour.
- José Valim — Mocks and explicit contracts — adapters behind a behaviour, chosen in config.
Related in this course
- F5.08.1 · Ports & adapters
- F5.08.2 · The engine facade
- F5.08.3 · Error contracts for the UI
- F5.06 · Where engine state lives — the engine behind the boundary.
- F5.04 · Design by contract — where the error vocabulary begins.
- F5.07 · Pragmatic testing — the seams are also test seams.