F5.08 · part 1 of 3
Ports & adapters
The engine has to persist its event log somewhere — Postgres in production, something
throwaway in a test. The wrong way to allow that is a conditional buried in the core
(if Mix.env() == :test); the right way is the move at the heart of Cockburn's
hexagonal architecture: the core declares what it needs as an interface — a
port — and the outside world supplies an adapter that satisfies it. In
Elixir the port is a @behaviour with typespecs, and an adapter is a module that carries
@behaviour Portal.EventStore and implements the callbacks. The core depends on the
behaviour; the adapters depend on the behaviour; configuration decides which adapter is loaded. This is the
“explicit contracts” approach José Valim argues for over ad-hoc mocking: the behaviour
is the contract, so the test adapter and the production adapter are interchangeable by construction.
One contract, two implementations. Select a piece to see what it is and where it runs.
Driven and driving ports
Cockburn draws two kinds of port, and the symmetry matters. A driving (primary) port is how an outside actor invokes the application — a web request, a test, a console. A driven (secondary) port is how the application reaches an outside resource it depends on — a database, a clock, a mailer. The engine's event store is a driven port: the core calls out through it, and an adapter answers. The same contract has an in-memory implementation for dev and tests and an Ecto implementation for production. Select a piece.
The event-store seam · select a piece
piece: EventStore
what it is: the behaviour the core calls
The core never asks “which store am I using?” It asks the port, and the port is the same shape whichever adapter answers. That is the whole trick: a stable question, a swappable answer.
The port is a behaviour
A behaviour is Elixir's way to declare an interface: a set of @callback specs that an
implementing module must satisfy, checked at compile time. The event-store port declares two callbacks —
append events to a stream, read a stream back — and offers a thin resolver so the core can call the port
without naming an adapter. The adapter is read from configuration, which is the one place the choice lives.
defmodule Portal.EventStore do @moduledoc "The port the engine uses to persist and load its event log." # the contract every adapter must satisfy (checked at compile time) @callback append(stream :: String.t(), events :: [struct]) :: :ok | {:error, term} @callback read_stream(stream :: String.t()) :: {:ok, [struct]} | {:error, term} # the core calls the port through these; config alone decides the adapter def adapter, do: Application.fetch_env!(:portal, :event_store) def append(stream, events), do: adapter().append(stream, events) def read_stream(stream), do: adapter().read_stream(stream) end
Selecting the adapter is one line per environment. Production points at Postgres; the test environment points at the in-memory store, and nothing in the engine changes:
# config/runtime.exs — production config :portal, :event_store, Portal.EventStore.Postgres # config/test.exs — the same core, a different edge config :portal, :event_store, Portal.EventStore.InMemory
Two adapters, one contract
Each adapter carries @behaviour Portal.EventStore, so the compiler insists it
implements append/2 and read_stream/1 with the right shapes.
The in-memory adapter keeps a map of streams in an Agent — fast, disposable,
perfect for the example tests in F5.07. The Postgres adapter writes rows through Ecto and reads them back in
sequence order. The core cannot tell them apart.
defmodule Portal.EventStore.InMemory do @behaviour Portal.EventStore use Agent def start_link(_), do: Agent.start_link(fn -> %{} end, name: __MODULE__) @impl true def append(stream, events) do Agent.update(__MODULE__, &Map.update(&1, stream, events, fn log -> log ++ events end)) end @impl true def read_stream(stream), do: {:ok, Agent.get(__MODULE__, &Map.get(&1, stream, []))} end
defmodule Portal.EventStore.Postgres do @behaviour Portal.EventStore import Ecto.Query alias Portal.Repo @impl true def append(stream, events) do rows = Enum.map(events, &row(stream, &1)) # each event carries an EVT id (F5.05) {_count, _} = Repo.insert_all("events", rows) :ok end @impl true def read_stream(stream) do events = from(e in "events", where: e.stream == ^stream, order_by: e.seq, select: e.data) |> Repo.all() |> Enum.map(&decode_event/1) {:ok, events} end end
Two modules, one promise. Because the promise is a behaviour the compiler enforces, an adapter that forgets a callback fails the build — the contract is not documentation, it is a check.
Which way the arrows point
The reason a port is more than indirection is the direction of dependency. The core depends on the behaviour; each adapter depends on the behaviour; nothing depends on an adapter. That is dependency inversion drawn literally — the arrows point inward, at the abstraction the core owns, so the database is a plug-in to the engine rather than the engine being a feature of the database.
Dependency direction
Not everything deserves a port. Reach for one at a real seam — I/O, time, an external service, or anything you must fake in a test. A pure function needs none, because it is already its own contract.
declare the need
The core owns a behaviour; the database is one way to satisfy it, not the only one.
config wires it
One line picks the adapter, so tests run in memory and production runs on Postgres.
Next: the engine facade — the driving port, the one stable API the UI calls into.
A test double is a module, not a mutated global
The earlier sections swap the whole edge per environment: production resolves :event_store
to Portal.EventStore.Postgres, the test environment to Portal.EventStore.InMemory.
Some tests need finer control than an environment-wide choice — a single test wants the store to fail an
append/2, or to hand back a fixed stream, so the engine's error path can be exercised.
The tempting shortcut is to reach for a library that rewrites Portal.EventStore.InMemory
in place at runtime, redefining its functions for the duration of the test. That mutates a module every other
test shares, and the technique is global: two tests touching the same module can no longer run concurrently.
José Valim's Mocks and explicit contracts
names the fix in one line — a mock is a noun, not a verb. The remedy is not to mock the store (verb,
a thing done to a shared global) but to define a mock (noun): one more ordinary module that carries
@behaviour Portal.EventStore and answers the two callbacks the way a particular test needs.
The behaviour declared in the section above is already the contract, so a hand-written stub is interchangeable
with the real adapters by construction. Selecting it stays one config line, scoped to the test environment;
no production module is touched, and tests that pick different adapters never collide.
# test/support/event_store_stub.ex — a third adapter, authored for tests defmodule Portal.EventStore.Stub do @behaviour Portal.EventStore # the same contract InMemory and Postgres satisfy # a noun: a module that conforms, returning exactly what a test asks for @impl true def append(_stream, _events), do: {:error, :store_unavailable} @impl true def read_stream(_stream), do: {:ok, []} end
The stub is injected the way every other adapter is — through configuration, not by redefining a function
at runtime. The engine calls Portal.EventStore.append/2 exactly as in production; only the
resolved adapter differs, so the failure branch under test is the genuine one:
# config/test.exs — point the port at the stub for the failing-store case config :portal, :event_store, Portal.EventStore.Stub # the call site is unchanged; the contract is what is exercised alias Portal.Events.LearnerEnrolled # the enrollment event from F5.05 Portal.EventStore.append("enrollment-1", [%LearnerEnrolled{}]) # => {:error, :store_unavailable} — Portal.Error.from/1 then maps it for the UI Portal.EventStore.read_stream("enrollment-1") # => {:ok, []}
Mock the verb, or define the mock as a noun
A test double earns its keep by satisfying the same behaviour as the real adapter, selected through config like any other — the contract is mocked as a noun, never the module mutated as a verb.
References
Primary sources for this lesson, and where it connects in the course.
Sources
- Alistair Cockburn — Hexagonal architecture — ports and adapters around a core.
- Elixir — Typespecs and behaviours — @callback defines the port contract.
- José Valim — Mocks and explicit contracts — test seams without mutating globals.