Skuld.Effects.Port (skuld v0.23.0)
View SourceEffect for dispatching parameterizable blocking calls to pluggable backends.
This effect lets domain code express "call this function" without binding to a particular implementation. Each request specifies:
mod– module identity (contract or implementation module)name– function nameargs– list of positional arguments
Use Cases
Port is ideal for wrapping any existing side-effecting Elixir code:
- Database queries
- HTTP API calls
- File system operations
- External service integrations
- Legacy code that performs I/O
Result Tuple Convention
Port handlers should return {:ok, value} or {:error, reason} tuples.
This convention enables two request modes:
request/3– returns the result tuple as-is for caller to handlerequest!/3– unwraps{:ok, value}or dispatchesThrowon error
Example
alias Skuld.Effects.Port
# Implementation returns result tuples
defmodule MyApp.UserQueries do
def find_by_id(id) do
case Repo.get(User, id) do
nil -> {:error, {:not_found, User, id}}
user -> {:ok, user}
end
end
end
# Using request/3 - returns result tuple
defcomp find_user(id) do
result <- Port.request(MyApp.UserQueries, :find_by_id, [id])
case result do
{:ok, user} -> return(user)
{:error, _} -> return(nil)
end
end
# Using request!/3 - unwraps or throws
defcomp find_user!(id) do
user <- Port.request!(MyApp.UserQueries, :find_by_id, [id])
return(user)
end
# Runtime: dispatch to actual modules
find_user!(123)
|> Port.with_handler(%{MyApp.UserQueries => :direct})
|> Throw.with_handler()
|> Comp.run!()
# Test: stub responses with exact key matching
find_user!(123)
|> Port.with_test_handler(%{
Port.key(MyApp.UserQueries, :find_by_id, [123]) => {:ok, %{id: 123, name: "Alice"}}
})
|> Throw.with_handler()
|> Comp.run!()
# Test: function-based handler with pattern matching
find_user!(123)
|> Port.with_fn_handler(fn
MyApp.UserQueries, :find_by_id, [id] -> {:ok, %{id: id, name: "User #{id}"}}
end)
|> Throw.with_handler()
|> Comp.run!()
Summary
Types
List of positional arguments
Function handler for test scenarios - receives (mod, name, args)
Module identity (contract or implementation module)
Function exported by port_module
Registry mapping port modules (or :__default__) to resolvers
Registry entry for dispatching requests.
Stateful handler function - receives (mod, name, args, state), returns {result, new_state}
Functions
Install Port handler via catch clause syntax.
Build a canonical key usable with with_test_handler/2.
Build a request for the given module/function.
Build a request that unwraps the result or throws on error.
Build a request that applies a custom unwrap function, then unwraps or throws.
Install a function-based test handler.
Install a scoped Port handler for a computation.
Install a stateful function-based test handler.
Install a test handler with canned responses.
Types
@type args() :: list()
List of positional arguments
@type fn_handler() :: (port_module(), port_name(), args() -> term())
Function handler for test scenarios - receives (mod, name, args)
@type port_module() :: module()
Module identity (contract or implementation module)
@type port_name() :: atom()
Function exported by port_module
@type registry() :: %{required(port_module() | :__default__) => resolver()}
Registry mapping port modules (or :__default__) to resolvers
@type resolver() :: :direct | {:effectful, module()} | (port_module(), port_name(), args() -> term()) | {module(), atom()} | module() | {:test_stub, map()} | {:test_stub, map(), fn_handler()} | {:fn_dispatch, fn_handler()} | {:stateful_dispatch, stateful_handler()}
Registry entry for dispatching requests.
Runtime resolvers (used with with_handler/3)
:direct– callapply(mod, name, args), result is a plain valuemodule– invokesapply(module, name, args)(implementation module). Modules where__port_effectful__?/0returns truthy (e.g. viause MyContract.Effectful) are auto-detected as effectful resolvers whose return values are computations inlined into the current effect context. Returningfalseopts out of auto-detection.{:effectful, module}– explicit effectful resolver (same as above, for backward compatibility or modules without the marker)function(arity 3) –fun.(mod, name, args){module, function}– invokesapply(module, function, [mod, name, args])
Default resolvers (used as :__default__ catch-all)
{:test_stub, responses}– map-based test stubs keyed byPort.key/3{:test_stub, responses, fallback}– test stubs with fallback function{:fn_dispatch, handler_fn}– function-based dispatch viafn(mod, name, args){:stateful_dispatch, handler_fn}– stateful dispatch viafn(mod, name, args, state) -> {result, new_state}
Stateful handler function - receives (mod, name, args, state), returns {result, new_state}
Functions
Install Port handler via catch clause syntax.
Config is the registry map, or {registry, opts}:
catch
Port -> %{MyModule => :direct}
Port -> {%{MyModule => :direct}, output: fn r, s -> {r, s} end}
@spec key(port_module(), port_name(), args()) :: {port_module(), port_name(), binary()}
Build a canonical key usable with with_test_handler/2.
Arguments are normalized to produce consistent keys.
Example
Port.key(MyApp.UserQueries, :find_by_id, [123])
@spec request(port_module(), port_name()) :: Skuld.Comp.Types.computation()
Build a request for the given module/function.
Returns the result tuple {:ok, value} or {:error, reason} as-is,
allowing the caller to handle errors explicitly.
Example
Port.request(MyApp.UserQueries, :find_by_id, [123])
# => {:ok, %User{...}} or {:error, {:not_found, User, 123}}
@spec request!(port_module(), port_name(), args()) :: Skuld.Comp.Types.computation()
Build a request that unwraps the result or throws on error.
Expects the handler to return {:ok, value} or {:error, reason}.
On success, returns the unwrapped value. On error, dispatches a
Skuld.Effects.Throw effect with the reason.
Requires a Throw.with_handler/1 in the handler chain.
Example
Port.request!(MyApp.UserQueries, :find_by_id, [123])
# => %User{...} or throws {:not_found, User, 123}
@spec request_bang(port_module(), port_name(), args(), (term() -> {:ok, term()} | {:error, term()})) :: Skuld.Comp.Types.computation()
Build a request that applies a custom unwrap function, then unwraps or throws.
The unwrap_fn receives the raw result from the handler and must return
{:ok, value} or {:error, reason}. The result is then handled like
request!/3: success values are unwrapped, errors dispatch Throw.
This is used by Port.Contract when bang: is set to a custom function.
Requires a Throw.with_handler/1 in the handler chain.
Example
Port.request_bang(MyApp.Users, :find_by_id, [123], fn
nil -> {:error, :not_found}
user -> {:ok, user}
end)
# => %User{...} or throws :not_found
@spec with_fn_handler(Skuld.Comp.Types.computation(), fn_handler(), keyword()) :: Skuld.Comp.Types.computation()
Install a function-based test handler.
The handler function receives (mod, name, args) and can use Elixir's
full pattern matching power including guards, pins, and wildcards.
If no function clause matches, throws {:port_not_handled, mod, name, args}.
Example
handler = fn
# Pin specific values
MyApp.UserQueries, :find_by_id, [^expected_id] ->
{:ok, %{id: expected_id, name: "Expected"}}
# Match any value with wildcard
MyApp.UserQueries, :find_by_id, [_any_id] ->
{:ok, %{id: "default", name: "Default"}}
# Match with guards
MyApp.Queries, :paginate, [_query, limit] when limit > 100 ->
{:error, :limit_too_high}
# Match specific module, any function
MyApp.AuditQueries, _function, _args ->
:ok
# Catch-all (optional)
mod, fun, args ->
raise "Unhandled: #{inspect(mod)}.#{fun}(#{inspect(args)})"
end
my_comp
|> Port.with_fn_handler(handler)
|> Throw.with_handler()
|> Comp.run!()Property-Based Testing
Function handlers are ideal for property-based tests where exact values aren't known upfront:
property "user lookup succeeds" do
check all user_id <- uuid_generator() do
handler = fn
UserQueries, :find_by_id, [^user_id] ->
{:ok, %{id: user_id, name: "Test User"}}
end
result =
find_user(user_id)
|> Port.with_fn_handler(handler)
|> Throw.with_handler()
|> Comp.run!()
assert {:ok, _} = result
end
end
@spec with_handler(Skuld.Comp.Types.computation(), registry(), keyword()) :: Skuld.Comp.Types.computation()
Install a scoped Port handler for a computation.
Pass a registry map keyed by module to control how requests are dispatched. Each entry can be one of:
:direct– callapply(mod, name, args), returns a plain valuemodule– invokesapply(module, name, args). Modules where__port_effectful__?/0returns truthy (e.g. viause MyContract.Effectful) are auto-detected as effectful resolvers whose return values are computations inlined into the current effect context. Returningfalseopts out of auto-detection.{:effectful, module}– explicit effectful resolver (same as above, for backward compatibility or modules without the marker)function(arity 3) –fun.(mod, name, args){module, function}– invokesapply(module, function, [mod, name, args])
Plain resolvers (:direct, function, {mod, fun}, module) return values
that are passed directly to the continuation. Effectful resolvers (auto-
detected or explicit {:effectful, mod}) return computations that
participate in the surrounding effect context.
Nested Handlers
Nested with_handler calls merge registries rather than shadowing.
Inner entries win on conflict. When the inner scope exits, the previous
registry is restored.
# Outer registers ModuleA, inner adds ModuleB — both are available
my_comp
|> Port.with_handler(%{ModuleB => :direct}) # inner: adds ModuleB
|> Port.with_handler(%{ModuleA => :direct}) # outer: registers ModuleA
|> Comp.run!()Note: with_test_handler and with_fn_handler do not merge with
runtime registries — they replace the dispatch mode entirely, which is
the expected behaviour for test stubs.
Options
:log— when truthy, enables dispatch logging. Every Port dispatch records a{mod, name, args, result}4-tuple inPort.State.log. Disabled by default (nil) for zero overhead in production.:output— transform function(result, %Port.State{}) -> outputcalled on scope exit. When logging is enabled,state.logcontains the log entries in chronological order.
Example
# Plain implementations
my_comp
|> Port.with_handler(%{
MyApp.UserQueries => :direct,
MyApp.Repository => MyApp.Repository.Ecto
})
|> Comp.run!()
# Effectful implementation — auto-detected via __port_effectful__?/0
my_comp
|> Port.with_handler(%{
MyApp.Repository => MyApp.Repository.EffectfulImpl
})
|> Throw.with_handler()
|> Comp.run!()
# With dispatch logging in tests
{result, log} =
my_comp
|> Port.with_handler(
%{MyApp.Repo => MyApp.Repo.Test},
log: true,
output: fn result, state -> {result, state.log} end
)
|> Throw.with_handler()
|> Comp.run!()
@spec with_stateful_handler( Skuld.Comp.Types.computation(), term(), stateful_handler(), keyword() ) :: Skuld.Comp.Types.computation()
Install a stateful function-based test handler.
The handler function receives (mod, name, args, state) and returns
{result, new_state}. State is threaded across Port calls within the
scope, enabling test doubles where writes are visible to subsequent reads.
Options
:log— enable dispatch logging (seewith_handler/3):output— transform(result, %Port.State{}) -> outputon scope exit. Thestate.handler_statefield contains the final handler state.
Example
# Stateful in-memory store: insert then read back
handler = fn
MyRepo, :insert, [record], state ->
key = {record.__struct__, record.id}
{{:ok, record}, Map.put(state, key, record)}
MyRepo, :get, [schema, id], state ->
key = {schema, id}
{Map.get(state, key), state}
end
{result, final_state} =
my_insert_then_read_comp
|> Port.with_stateful_handler(%{}, handler,
output: fn result, state -> {result, state.handler_state} end
)
|> Throw.with_handler()
|> Comp.run!()Property-Based Testing
Stateful handlers pair naturally with property-based tests where the in-memory model serves as the oracle:
property "insert then get returns the same record" do
check all id <- integer(), name <- string(:alphanumeric) do
record = %User{id: id, name: name}
handler = fn
Repo, :insert, [r], state -> {{:ok, r}, Map.put(state, {User, r.id}, r)}
Repo, :get, [schema, id], state -> {Map.get(state, {schema, id}), state}
end
result =
insert_then_get(record)
|> Port.with_stateful_handler(%{}, handler)
|> Throw.with_handler()
|> Comp.run!()
assert result == record
end
end
@spec with_test_handler(Skuld.Comp.Types.computation(), map(), keyword()) :: Skuld.Comp.Types.computation()
Install a test handler with canned responses.
Provide a map of responses keyed by Port.key/3. Missing keys will
throw {:port_not_stubbed, key} unless a fallback: function is provided.
Options
:fallback- A function(mod, name, args) -> resultto call when no exact key match is found. Useful for handling dynamic arguments while still using exact matching for known cases.:log— enable dispatch logging (seewith_handler/3):output- Transform(result, %Port.State{}) -> outputon scope exit
Example
responses = %{
Port.key(MyApp.UserQueries, :find_by_id, [123]) => {:ok, %{name: "Alice"}},
Port.key(MyApp.UserQueries, :find_by_id, [456]) => {:error, :not_found}
}
my_comp
|> Port.with_test_handler(responses)
|> Throw.with_handler()
|> Comp.run!()
# With fallback for dynamic cases
my_comp
|> Port.with_test_handler(responses, fallback: fn
MyApp.AuditQueries, _name, _args -> :ok
mod, name, args -> raise "Unhandled: #{inspect(mod)}.#{name}(#{inspect(args)})"
end)
|> Throw.with_handler()
|> Comp.run!()