defmodule Skuld.Adapter do
@moduledoc """
Bridges effectful implementations to plain Elixir interfaces.
`Skuld.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 DoubleDown.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 DoubleDown.Contract
defcallback 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
# Skuld.Adapter satisfies Plain behaviour, runs effectful impl
defmodule MyApp.UserService.Adapter do
use Skuld.Adapter,
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 `__callbacks__/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`, ensuring
compile-time verification that all required callbacks are implemented.
## Four Directions
There are four scenarios for bridging between plain and effectful code:
* **Skuld→Plain** — effectful code calls out to plain Elixir implementations
through the Port effect, resolved by `Port.with_handler/2` at runtime.
* **Skuld→Effectful** — effectful code calls out to effectful implementations
through the Port effect with an `:effectful` resolver.
* **Legacy→Plain** — plain Elixir code calls plain implementations through
the DoubleDown-generated facade.
* **Legacy→Effectful** — plain Elixir code calls into effectful implementations
through `Skuld.Adapter`, 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.Adapter,
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)
|> Transaction.Ecto.with_handler(MyApp.Repo)
|> Throw.with_handler()
end
## Testing
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 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
end
"""
defmacro __using__(opts) do
contract = Keyword.fetch!(opts, :contract)
impl = Keyword.fetch!(opts, :impl)
stack_ast = Keyword.fetch!(opts, :stack)
# Store stack as escaped AST so it survives module attribute storage
# and can be re-injected in __before_compile__
escaped_stack = Macro.escape(stack_ast)
quote do
@before_compile {Skuld.Adapter, :__before_compile__}
@__port_provider_contract__ unquote(contract)
@__port_provider_impl__ unquote(impl)
@__port_provider_stack_ast__ unquote(escaped_stack)
end
end
defmacro __before_compile__(env) do
contract = Module.get_attribute(env.module, :__port_provider_contract__)
impl = Module.get_attribute(env.module, :__port_provider_impl__)
stack_ast = Module.get_attribute(env.module, :__port_provider_stack_ast__)
# Validate contract module has __callbacks__/0
unless function_exported?(contract, :__callbacks__, 0) do
raise CompileError,
description:
"#{inspect(contract)} does not appear to be a contract module " <>
"(missing __callbacks__/0). Ensure it uses DoubleDown.Contract " <>
"and defines at least one defcallback.",
file: env.file,
line: 0
end
operations = contract.__callbacks__()
functions =
Enum.map(operations, fn %{name: name, params: param_names} ->
param_vars = Enum.map(param_names, fn pname -> {pname, [], nil} end)
quote do
@impl true
def unquote(name)(unquote_splicing(param_vars)) do
unquote(impl).unquote(name)(unquote_splicing(param_vars))
|> unquote(stack_ast).()
|> Skuld.Comp.run!()
end
end
end)
quote do
@behaviour unquote(contract)
unquote_splicing(functions)
end
end
end