jonnifyelixir
F5 / F5.08 / ports

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

EVENTSTORE · the port @callback append/2 · read_stream/1 — the core calls these behaviour INMEMORY · adapter @behaviour EventStore · an Agent-backed table dev · test POSTGRES · adapter @behaviour EventStore · an Ecto-backed table production

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

ENGINE CORE calls the port PORT @behaviour EventStore ADAPTER InMemory · Postgres depends on implements dependencies point at the behaviour, never at an adapter

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

VERB · mutate a shared global redefine InMemory.append/2 at runtime every test shares it · cannot run concurrently Portal.EventStore.InMemory (rewritten) the change leaks into the next test NOUN · a module that conforms @behaviour Portal.EventStore a separate Stub · config selects it · concurrent-safe Portal.EventStore.Stub (untouched modules) shared modules stay as written

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

Related in this course