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 |
| Dispatch facades |
Generated Port module with delegation + bang variants |
| 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 | 14-operation Ecto Repo port with test + in-memory impls |
Quick example
Define a contract
defmodule MyApp.Todos do
use HexPort, otp_app: :my_app
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— facade functions dispatching viaHexPort.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 14-operation Ecto Repo contract covering
insert, update, delete, update_all, delete_all, get, get!,
get_by, get_by!, one, one!, all, exists?, and aggregate.
defmodule MyApp.Repo do
use 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 |
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.