HexPort

TestHex.pmDocumentation

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
end

This generates:

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

  # ...
end

Configure for production

# config/config.exs
config :my_app, MyApp.Todos, impl: MyApp.Todos.Ecto

Test 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
end

Testing 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)
end

Async 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
end

Three 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:

  1. Test handler — NimbleOwnership process-scoped lookup (zero-cost in production: GenServer.whereis returns nil when the ownership server isn’t started)
  2. Application configApplication.get_env(otp_app, contract)[:impl]
  3. 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"}
  ]
end

Ecto 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.