Skuld.Effects.Port.Adapter.Effectful (skuld v0.23.0)
View SourceMacro for bridging effectful implementations to plain Elixir interfaces.
An effectful 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 Plain behaviour with
plain Elixir functions.
Options
:contract— the Port.Contract module (required):impl— the Effectful-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 HexPort.Contract
defport find_user(id :: String.t()) :: {:ok, User.t()} | {:error, term()}
end
# Effectful implementation satisfies Effectful behaviour
defmodule MyApp.UserService.EffectfulImpl do
@behaviour MyApp.UserService.Effectful
defcomp find_user(id) do
user <- MyApp.UserRepo.EffectPort.get(id)
{:ok, user}
end
end
# Effectful adapter satisfies Plain behaviour, runs effectful impl
defmodule MyApp.UserService.Adapter do
use Skuld.Effects.Port.Adapter.Effectful,
contract: MyApp.UserService,
impl: MyApp.UserService.EffectfulImpl,
stack: &MyApp.Stacks.user_service/1
end
# Now MyApp.UserService.Adapter can be used as a plain 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, ensuring
compile-time verification that all required callbacks are implemented.
Hexagonal Architecture
In hexagonal architecture terms, there are four scenarios for a port contract:
- Skuld→Plain — effectful code calls out to plain Elixir implementations
through the Port effect, resolved by
Port.with_handler/2at runtime. - Skuld→Effectful — effectful code calls out to effectful implementations
through the Port effect with an
:effectfulresolver. - Legacy→Plain — plain Elixir code calls plain implementations through
the HexPort-generated
X.Portfacade. - Legacy→Effectful — plain Elixir code calls into effectful implementations
through this adapter (
Port.Adapter.Effectful), which runs the effectful code with a handler stack, producing plain return values.
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.Adapter.Effectful,
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)
|> Transaction.Ecto.with_handler(MyApp.Repo)
|> Throw.with_handler()
endTesting Effectful Adapters
Effectful 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 with handlers" do
comp =
MyApp.UserService.EffectfulImpl.find_user("user-123")
|> MyApp.UserRepo.with_test_handler(...)
|> Throw.with_handler()
{result, _env} = Comp.run(comp)
assert {:ok, %User{}} = result
endSince the adapter satisfies the Behaviour, it can also be used as a handler
target in Port.with_handler/2, enabling effectful-to-effectful composition
through the Port system.