HexPort
Hexagonal architecture ports for Elixir.
The problem
Clean Architecture and Hexagonal Architecture tell you to put your domain logic behind port boundaries — but Elixir doesn’t give you a standard way to define those boundaries. You end up with ad-hoc behaviours, manual delegation modules, test doubles that aren’t process-safe, and no way to log or inspect what crossed a boundary during a test.
What HexPort does
HexPort gives you a single macro — defport — that generates typed
contracts, behaviours, and dispatch facades. In production, dispatch
reads from application config. In tests, dispatch uses process-scoped
handlers via NimbleOwnership,
giving you full async test isolation with zero global state.
| Feature | Description |
|---|---|
| Typed contracts | defport declarations with full typespecs |
| Behaviour generation |
Standard @behaviour + @callback — Mox-compatible |
| Separate dispatch facades | HexPort.Port generates dispatch with configurable otp_app |
| Async-safe test doubles | Process-scoped handlers via NimbleOwnership |
| Stateful test handlers | In-memory state with read-after-write consistency |
| Dispatch logging | Record every call that crosses a port boundary |
| Built-in Repo contract | 15-operation Ecto Repo port with test + in-memory impls |
Two entry points
HexPort has two macros for two separate concerns:
use HexPort.Contract— defines the contract (pure interface definition). GeneratesX.Behaviour(callbacks) andX.__port_operations__/0(introspection). Nootp_app, no dispatch facade.use HexPort.Port, contract: X, otp_app: :my_app— generates the dispatch facade. ReadsX.__port_operations__/0at compile time and creates facade functions, bang variants, and key helpers. The consuming application controls whichotp_appto use.
This separation means library-provided contracts (like HexPort.Repo) don’t
hardcode an otp_app — the consuming application decides how dispatch works.
Quick example
Define a contract
defmodule MyApp.Todos do
use HexPort.Contract
defport get_todo(tenant_id :: String.t(), id :: String.t()) ::
{:ok, Todo.t()} | {:error, term()}
defport list_todos(tenant_id :: String.t()) :: [Todo.t()]
defport create_todo!(params :: map()) :: Todo.t(), bang: false
endThis generates:
MyApp.Todos.Behaviour— standard@behaviourwith@callbacksMyApp.Todos.__port_operations__/0— introspection for the contract
Generate a dispatch facade
# In a separate file (contract must compile first)
defmodule MyApp.Todos.Port do
use HexPort.Port, contract: MyApp.Todos, otp_app: :my_app
end
This generates facade functions (get_todo/2, list_todos/1, create_todo!/1)
that dispatch via HexPort.Dispatch.
Implement the behaviour
defmodule MyApp.Todos.Ecto do
@behaviour MyApp.Todos.Behaviour
@impl true
def get_todo(tenant_id, id) do
case MyApp.Repo.get_by(Todo, tenant_id: tenant_id, id: id) do
nil -> {:error, :not_found}
todo -> {:ok, todo}
end
end
# ...
endConfigure for production
# config/config.exs
config :my_app, MyApp.Todos, impl: MyApp.Todos.EctoTest with process-scoped handlers
# test/test_helper.exs
HexPort.Testing.start()
# test/my_test.exs
defmodule MyApp.TodosTest do
use ExUnit.Case, async: true
setup do
HexPort.Testing.set_fn_handler(MyApp.Todos, fn
:get_todo, [_tenant, id] -> {:ok, %Todo{id: id, title: "Test"}}
:list_todos, [_tenant] -> [%Todo{id: "1", title: "Test"}]
:create_todo!, [params] -> struct!(Todo, params)
end)
:ok
end
test "gets a todo" do
assert {:ok, %Todo{id: "42"}} = MyApp.Todos.Port.get_todo("t1", "42")
end
endTesting features
Function handlers
Map operations to return values with a simple function:
HexPort.Testing.set_fn_handler(MyApp.Todos, fn
:get_todo, [_, id] -> {:ok, %Todo{id: id}}
:list_todos, [_] -> []
end)Stateful handlers
Maintain state across calls for read-after-write consistency:
HexPort.Testing.set_stateful_handler(
MyApp.Todos,
%{todos: []}, # initial state
fn
:create_todo!, [params], state ->
todo = struct!(Todo, params)
{todo, %{state | todos: [todo | state.todos]}}
:list_todos, [_tenant], state ->
{state.todos, state}
end
)Dispatch logging
Record and inspect every call that crosses a port boundary:
setup do
HexPort.Testing.enable_log(MyApp.Todos)
HexPort.Testing.set_fn_handler(MyApp.Todos, fn
:get_todo, [_, id] -> {:ok, %Todo{id: id}}
end)
:ok
end
test "logs dispatch calls" do
MyApp.Todos.Port.get_todo("t1", "42")
assert [{:get_todo, ["t1", "42"], {:ok, %Todo{id: "42"}}}] =
HexPort.Testing.get_log(MyApp.Todos)
endAsync safety
All test handlers are process-scoped via NimbleOwnership. async: true
tests run in full isolation. Task.async children automatically inherit
their parent’s handlers.
HexPort.Testing.allow(MyApp.Todos, self(), some_pid)Built-in Repo contract
HexPort includes a ready-made 15-operation Ecto Repo contract covering
insert, update, delete, update_all, delete_all, get, get!,
get_by, get_by!, one, one!, all, exists?, aggregate, and
transact.
# HexPort.Repo defines the contract (no otp_app)
# Your app creates its own Port module:
defmodule MyApp.Repo.Port do
use HexPort.Port, contract: HexPort.Repo, otp_app: :my_app
endThree implementations are provided:
| Module | Purpose |
|---|---|
HexPort.Repo.Ecto |
Delegates to your real Ecto.Repo |
HexPort.Repo.Test | Stateless defaults (applies changesets, returns structs) |
HexPort.Repo.InMemory | Stateful in-memory store with auto-increment IDs |
Transactions with transact
transact/2 mirrors Ecto.Repo.transact/2 — it accepts either a function
or an Ecto.Multi as the first argument.
With a function:
MyApp.Repo.Port.transact(fn ->
{:ok, user} = MyApp.Repo.Port.insert(user_changeset)
{:ok, profile} = MyApp.Repo.Port.insert(profile_changeset(user))
{:ok, {user, profile}}
end, [])
The function must return {:ok, result} or {:error, reason}.
With an Ecto.Multi:
Ecto.Multi.new()
|> Ecto.Multi.insert(:user, user_changeset)
|> Ecto.Multi.run(:profile, fn repo, %{user: user} ->
repo.insert(profile_changeset(user))
end)
|> MyApp.Repo.Port.transact([])
On success, returns {:ok, changes} where changes is a map of operation
names to results. On failure, returns {:error, failed_op, failed_value, changes_so_far}.
Multi :run callbacks receive the Port facade as the repo argument in
Test and InMemory adapters (so repo.insert(cs) dispatches correctly),
or the underlying Ecto Repo module in the Ecto adapter.
Supported Multi operations: insert, update, delete, run, put,
error, inspect, merge, insert_all, update_all, delete_all.
Bulk operations (insert_all, update_all, delete_all) return {0, nil}
in Test and InMemory adapters.
No transact! bang variant is generated (consistent with Ecto, which does
not define Repo.transact! either).
Concurrency limitations of transact in test adapters
The Ecto adapter provides real database transactions with full ACID isolation — this is the production path and works correctly under concurrent access.
The Test and InMemory adapters do not provide true transaction
isolation. In the Test adapter, transact simply calls the function (or
steps through the Multi) without any locking. In the InMemory adapter,
transact uses {:defer, fn} to avoid NimbleOwnership deadlocks — the
function runs outside the lock, and each sub-operation acquires the lock
individually.
This means:
- There is no rollback on error — side effects from earlier operations in a function-based transact are not undone (Multi-based transact in test adapters also does not roll back successful operations on failure).
- Concurrent writes to the InMemory store within a transaction are not isolated from each other.
This is acceptable for test-only adapters where transactions are typically exercised in serial, single-process tests. If your tests require true transaction isolation, use the Ecto adapter with a real database and Ecto’s sandbox.
Dispatch resolution
HexPort.Dispatch.call/4 resolves handlers in order:
- Test handler — NimbleOwnership process-scoped lookup (zero-cost
in production:
GenServer.whereisreturnsnilwhen the ownership server isn’t started) - Application config —
Application.get_env(otp_app, contract)[:impl] - Raise — clear error message if nothing is configured
Installation
Add hex_port to your dependencies in mix.exs:
def deps do
[
{:hex_port, "~> 0.1"}
]
endEcto is an optional dependency. If you want the built-in Repo contract, add Ecto to your own deps.
Relationship to Skuld
HexPort extracts the port system from Skuld (algebraic effects for Elixir) into a standalone library. You get typed contracts, async-safe test doubles, and dispatch logging without needing Skuld’s effect system. Skuld depends on HexPort and layers effectful dispatch on top.
License
MIT License - see LICENSE for details.