External Integration
View Source< Persistence & Data | Up: Foundational Effects | Index | Yield (Coroutines) >
The Port system abstracts calls to external code - database reads, HTTP APIs, file I/O, or any side-effecting function - behind a dispatch layer with pluggable backends. This makes external dependencies trivially swappable for testing.
Port has three layers:
- Port - low-level dispatch via
Port.request/3 - Port.Facade - typed contracts via
defcallback, with Dialyzer support and generated effectful dispatch functions - Port.Adapter.Effectful - bridges plain Elixir code into effectful implementations (the inbound side of hexagonal architecture)
Most applications should use Skuld.Effects.Port.Facade. The low-level
Port API is useful for quick prototyping or when you need maximum flexibility.
Port (Low-Level API)
Dispatch parameterised blocking calls to pluggable backends. Port uses
positional arguments - Port.request/3 takes a module, function name,
and args list.
Basic usage
defmodule MyQueries do
def find_user(id), do: {:ok, %{id: id, name: "User #{id}"}}
end
# Production: dispatch to actual module
comp do
user <- Port.request!(MyQueries, :find_user, [123])
user
end
|> Port.with_handler(%{MyQueries => :direct})
|> Throw.with_handler()
|> Comp.run!()
#=> %{id: 123, name: "User 123"}Operations
Port.request(mod, name, args)- dispatch a call, returns the raw result (e.g.{:ok, value}or{:error, reason})Port.request!(mod, name, args)- dispatch a call, unwraps{:ok, v}or throws on{:error, r}
Handler
Port.with_handler(dispatch_map)The dispatch map keys are modules and values are resolvers:
:direct-apply(mod, name, args)(call directly on the keyed module)module-apply(module, name, args)(dispatch to an implementation module). Modules where__port_effectful__?/0returns truthy (e.g. viause Skuld.Effects.Port.Facade) are auto-detected as effectful resolvers whose return values are computations inlined into the caller's effect context. Returningfalseopts out of auto-detection.{:effectful, module}- explicit effectful resolver (same as above, for backward compatibility or modules without the marker)fun/3-fun.(mod, name, args)(function receives all three){module, function}-apply(module, function, [mod, name, args])
Nested handlers and merging
Nested with_handler, with_test_handler, and with_fn_handler calls
merge into a single unified registry rather than shadowing. Inner
entries win on conflict; the previous registry is restored when the
inner scope exits.
# Outer registers MyQueries, inner adds AuditLog — both accessible
comp
|> Port.with_handler(%{AuditLog => AuditLog.Ecto})
|> Port.with_handler(%{MyQueries => :direct})
|> Comp.run!()Dispatch logging
All three handler installers accept a :log option. When truthy,
every Port dispatch records a {mod, name, args, result} 4-tuple
directly in Port.State.log — no Writer needed, no extra effect
dispatch per call. Use the :output option to extract the log on
scope exit.
Logging is disabled by default (State.log is nil) for zero
overhead in production:
{result, log} =
comp do
user <- Port.request!(MyQueries, :find_user, [123])
user
end
|> Port.with_handler(
%{MyQueries => :direct},
log: true,
output: fn result, state -> {result, state.log} end
)
|> Throw.with_handler()
|> Comp.run!()
# log is [{MyQueries, :find_user, [123], {:ok, %{id: 123, ...}}}]The :log option works with all handler types — with_handler,
with_test_handler, and with_fn_handler:
# Function-based handler with logging
{result, log} =
comp
|> Port.with_fn_handler(
fn mod, name, args -> apply(mod, name, args) end,
log: true,
output: fn r, state -> {r, state.log} end
)
|> Comp.run!()When nested handlers both specify log: true, the inner scope
starts a fresh log; its entries are captured by the inner :output
callback and don't leak into the outer scope. When only the outer
scope has log: true, inner dispatches accumulate into the outer log.
When neither specifies :log, no logging overhead is incurred.
Mixed handler modes
All three handler installers share the same registry. Module-specific
entries (from with_handler) take priority over catch-all entries
(from with_test_handler / with_fn_handler). This lets you mix
runtime and test dispatch for different contracts:
# ModuleA dispatched via :direct, everything else via test stubs
comp
|> Port.with_test_handler(%{
Port.key(ModuleB, :fetch, [1]) => {:ok, :stubbed}
})
|> Port.with_handler(%{ModuleA => :direct})
|> Comp.run!()Testing patterns
Exact-match stubs for simple cases:
comp do
user <- Port.request!(MyQueries, :find_user, [456])
user
end
|> Port.with_test_handler(%{
Port.key(MyQueries, :find_user, [456]) => {:ok, %{id: 456, name: "Stubbed"}}
})
|> Throw.with_handler()
|> Comp.run!()
#=> %{id: 456, name: "Stubbed"}Function-based handler for pattern matching (ideal for property tests where exact values aren't known upfront):
comp do
user <- Port.request!(MyQueries, :find_user, [789])
user
end
|> Port.with_fn_handler(fn
MyQueries, :find_user, [id] -> {:ok, %{id: id, name: "Generated User #{id}"}}
MyQueries, :list_users, [limit] when limit > 100 -> {:error, :limit_too_high}
_mod, _fun, _args -> {:ok, :default}
end)
|> Throw.with_handler()
|> Comp.run!()
#=> %{id: 789, name: "Generated User 789"}The function handler gives you full Elixir pattern matching power -
pins, guards, wildcards. Use with_test_handler for exact-match cases
and with_fn_handler for dynamic scenarios.
Port.Facade
Typed contracts via defcallback declarations. Generates Dialyzer-checked
caller functions, behaviour callbacks, test key helpers, and
introspection. This is the recommended way to define effectful ports.
Defining a contract and facade
The single-module pattern is the simplest — use Skuld.Effects.Port.Facade
with no options creates the contract, effectful behaviour, and dispatch
facade in one module:
defmodule MyApp.Repository do
use Skuld.Effects.Port.Facade
alias MyApp.Todo
defcallback get_todo(tenant_id :: String.t(), id :: String.t()) ::
{:ok, Todo.t()} | {:error, term()}
defcallback list_todos(tenant_id :: String.t(), opts :: map()) ::
{:ok, [Todo.t()]} | {:error, term()}
defcallback health_check() :: :ok | {:error, term()}
endWhen you have a separate plain contract module (e.g. from DoubleDown.ContractFacade),
use the :double_down_contract option:
# Plain contract
defmodule MyApp.Repository.Contract do
use DoubleDown.Contract
defcallback get_todo(tenant_id :: String.t(), id :: String.t()) ::
{:ok, Todo.t()} | {:error, term()}
end
# Effectful facade (same callbacks, effectful dispatch)
defmodule MyApp.Repository do
use Skuld.Effects.Port.Facade,
double_down_contract: MyApp.Repository.Contract
endThe single-module approach means swapping between plain DoubleDown dispatch
and skuld effectful dispatch is a one-line use change.
Plain and Effectful behaviours
Skuld.Effects.Port.Facade generates effectful @callback declarations
with computation()-wrapped return types on the facade module, while
__callbacks__/0 preserves the original plain operation metadata:
# Plain behaviour (on a separate DoubleDown.Contract module)
MyApp.Repository.Contract
@callback get_todo(String.t(), String.t()) :: {:ok, Todo.t()} | {:error, term()}
# Effectful behaviour (on the facade module)
MyApp.Repository
@callback get_todo(String.t(), String.t()) :: computation({:ok, Todo.t()} | {:error, term()})Explicit bang operations
Bang variants are explicit defcallback declarations — auto-bang generation
was removed in double_down 0.38. If you want a bang version, declare it:
defmodule MyApp.Users do
use Skuld.Effects.Port.Facade
defcallback get_user(id :: String.t()) ::
{:ok, User.t()} | {:error, term()}
# Explicit bang variant — different return type
defcallback get_user!(id :: String.t()) :: User.t()
endWriting a contract implementation
Plain implementations satisfy the contract behaviour with plain Elixir functions:
defmodule MyApp.Repository.Ecto do
@behaviour MyApp.Repository.Contract
@impl true
def get_todo(tenant_id, id) do
case Repo.get_by(Todo, tenant_id: tenant_id, id: id) do
nil -> {:error, {:not_found, Todo, id}}
todo -> {:ok, todo}
end
end
@impl true
def list_todos(tenant_id, opts), do: ...
@impl true
def health_check, do: :ok
endHandler installation
# Production: dispatch to Ecto implementation
my_comp
|> Port.with_handler(%{MyApp.Repository => MyApp.Repository.Ecto})
|> Comp.run!()
# Test: dispatch to in-memory implementation
my_comp
|> Port.with_handler(%{MyApp.Repository => MyApp.Repository.InMemory})
|> Comp.run!()
# Test: stub specific calls with generated key helpers
my_comp
|> Port.with_test_handler(%{
MyApp.Repository.__key__(:get_todo, "tenant-1", "id-1") => {:ok, mock_todo}
})
|> Throw.with_handler()
|> Comp.run!()Benefits over raw Port
- Dialyzer checks call sites and implementations via
@specand@callback - LSP autocomplete on
Repository.shows available operations - Missing callback implementations produce compiler warnings
- Facade
.__key__/Nhelpers replace verbosePort.key(Module, :name, [args...])calls - Single-module pattern: swap between DoubleDown
ContractFacadeand skuldPort.Facadewith a one-lineusechange
Port.Adapter.Effectful
Port.Adapter.Effectful enables the provider side of hexagonal architecture:
plain Elixir code calling into effectful implementations. It generates a
module that satisfies the Behaviour by wrapping an Effectful
implementation with a handler stack and Comp.run!/1.
When to use Port.Adapter.Effectful
Use Port.Adapter.Effectful when non-effectful code (a Phoenix controller, a GenServer, a CLI) needs to call into logic that's written with effects. The adapter handles the effect machinery so callers don't need to know about it.
Defining a provider adapter
# 1. Plain contract
defmodule MyApp.UserService.Contract do
use DoubleDown.Contract
defcallback find_user(id :: String.t()) :: {:ok, User.t()} | {:error, term()}
defcallback list_users(opts :: map()) :: {:ok, [User.t()]} | {:error, term()}
end
# 2. Effectful contract + facade (single-module)
defmodule MyApp.UserService do
use Skuld.Effects.Port.Facade,
double_down_contract: MyApp.UserService.Contract
end
# 3. Effectful implementation satisfies the effectful facade's behaviour
defmodule MyApp.UserService.EffectfulImpl do
use Skuld.Syntax
@behaviour MyApp.UserService
defcomp find_user(id) do
UserQueries.get_user(id)
end
defcomp list_users(opts) do
UserQueries.list_users(opts)
end
end
# 4. Effectful adapter bridges effectful impl to plain Elixir
defmodule MyApp.UserService.Adapter do
use Skuld.Effects.Port.Adapter.Effectful,
contract: MyApp.UserService.Contract,
impl: MyApp.UserService.EffectfulImpl,
stack: fn comp ->
comp
|> Port.with_handler(%{UserQueries => UserQueries.Ecto})
|> Throw.with_handler()
end
endUsing the adapter
The adapter returns plain Elixir values - the effect stack runs internally:
# Direct call from non-effectful code (controllers, GenServers, etc.)
{:ok, user} = MyApp.UserService.Adapter.find_user("user-123")
# Or use it as a Port handler for effectful code
my_comp
|> Port.with_handler(%{MyApp.UserService => MyApp.UserService.Adapter})
|> Comp.run!()The stack function
The stack function receives a computation and returns a computation with handlers installed. It's where you compose all the effect handlers the implementation needs:
# Simple: single effect
stack: &Throw.with_handler/1
# Complex: multiple effects
stack: fn comp ->
comp
|> State.with_handler(initial_state)
|> Port.with_handler(%{MyRepo => MyRepo.Ecto})
|> Throw.with_handler()
endNote: If the effectful implementation can throw (via Throw), the stack function must include
Throw.with_handler/1. Without it,Comp.run!/1raisesThrowError. The position of Throw in the handler pipeline doesn't matter - it just needs to be installed.
Hexagonal architecture
The Port system supports both directions of hexagonal architecture through the same contract:
Contract
(defcallback)
/ \
Plain side Effectful side
(outbound/driven) (inbound/driving)
| |
Effectful code Plain Elixir code
calls out to calls in to
plain Elixir impl effectful impl
| |
Port.with_handler Port.Adapter.Effectful
→ Plain impl → Effectful impl
→ stack
→ Comp.run!()- Plain (outbound) - effectful code emits Port effects (via
effectful facade), resolved by
Port.with_handler/2dispatching to a Behaviour implementation - Effectful (inbound) -
Port.Adapter.Effectfulwraps an Effectful implementation with a handler stack andComp.run!/1, producing a Behaviour-compatible module that plain code calls directly
Testing provider adapters
Effectful adapters produce plain Elixir values, so they test like ordinary Elixir code:
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, use standard effect testing patterns:
test "effectful impl uses Port effect" do
result =
MyApp.UserService.EffectfulImpl.find_user("user-123")
|> Port.with_test_handler(%{
UserQueries.__key__(:get_user, "user-123") => {:ok, %User{id: "user-123"}}
})
|> Throw.with_handler()
|> Comp.run!()
assert {:ok, %User{}} = result
end< Persistence & Data | Up: Foundational Effects | Index | Yield (Coroutines) >