Skuld.Effects.Port.Provider (skuld v0.2.3)

View Source

Macro for bridging effectful (Provider) implementations to plain Elixir (Consumer) interfaces.

A Provider adapter wraps an effectful implementation module — one whose functions return computation(return_type) — with a handler stack and Comp.run!/1, producing a module that satisfies the Consumer behaviour with plain Elixir functions.

Options

  • :contract — the Port.Contract module (required)
  • :impl — the Provider-behaviour implementation module (required)
  • :stack — a function (computation -> computation) that installs the handler stack (required)

Example

# Contract defines the port
defmodule MyApp.UserService do
  use Skuld.Effects.Port.Contract
  defport find_user(id :: String.t()) :: {:ok, User.t()} | {:error, term()}
end

# Effectful implementation satisfies Provider behaviour
defmodule MyApp.UserService.Effectful do
  @behaviour MyApp.UserService.Provider
  defcomp find_user(id) do
    user <- DB.get(User, id)
    {:ok, user}
  end
end

# Provider adapter satisfies Consumer behaviour, runs effectful impl
defmodule MyApp.UserService.Adapter do
  use Skuld.Effects.Port.Provider,
    contract: MyApp.UserService,
    impl: MyApp.UserService.Effectful,
    stack: &MyApp.Stacks.user_service/1
end

# Now MyApp.UserService.Adapter can be used as a plain Consumer implementation:
MyApp.UserService.Adapter.find_user("user-123")
# => {:ok, %User{...}}

How It Works

For each operation defined in the contract (via __port_operations__/0), the adapter generates a function that:

  1. Calls the impl module's corresponding function to get a computation
  2. Pipes through the stack function to install effect handlers
  3. Runs the computation with Comp.run!/1

The generated module declares @behaviour ContractModule.Consumer, ensuring compile-time verification that all required callbacks are implemented.

Hexagonal Architecture

In hexagonal architecture terms:

  • Consumer (outbound/driven) — effectful code calls out to plain Elixir implementations through the Port effect. The contract's caller functions emit Port effects that are resolved by Port.with_handler/2 at runtime.
  • Provider (inbound/driving) — plain Elixir code calls in to effectful implementations through the Provider adapter. The adapter runs the effectful code with a handler stack, producing plain return values.

This enables a symmetric architecture where the same contract module defines the boundary, and implementations can be either plain Elixir (Consumer) or effectful (Provider), depending on the direction of the call.

Throw handler in the stack

If the effectful implementation can throw (via Skuld.Effects.Throw), the stack function must install a Throw.with_handler/1. Without it, Comp.run!/1 raises Skuld.Comp.ThrowError, which can be confusing if you don't realise a Throw handler is missing from the stack.

A minimal stack that only handles throws:

use Skuld.Effects.Port.Provider,
  contract: MyContract,
  impl: MyEffectfulImpl,
  stack: &Skuld.Effects.Throw.with_handler/1

For stacks with multiple effects, place Throw.with_handler/1 last (outermost) so it catches throws from all inner handlers:

stack: fn comp ->
  comp
  |> State.with_handler(initial_state)
  |> DB.Ecto.with_handler(MyApp.Repo)
  |> Throw.with_handler()
end

Testing Provider Adapters

Provider adapters produce plain Elixir values, so they can be tested directly without effect machinery:

test "adapter returns expected result" do
  result = MyApp.UserService.Adapter.find_user("user-123")
  assert {:ok, %User{id: "user-123"}} = result
end

To test the effectful implementation in isolation (without the adapter), use the standard effect testing patterns — install handlers and run the computation:

test "effectful impl uses DB effect" do
  comp =
    MyApp.UserService.Effectful.find_user("user-123")
    |> DB.with_test_handler(...)
    |> Throw.with_handler()

  {result, _env} = Comp.run(comp)
  assert {:ok, %User{}} = result
end

Since the adapter satisfies the Consumer behaviour, it can also be used as a handler target in Port.with_handler/2, enabling effectful-to-effectful composition through the Port system.