Skuld.Effects.Port.Provider (skuld v0.2.3)
View SourceMacro 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:
- Calls the impl module's corresponding function to get a computation
- Pipes through the stack function to install effect handlers
- 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/2at 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/1For 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()
endTesting 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
endTo 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
endSince 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.