Skuld.Effects.Port (skuld v0.2.3)
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 to resolvers
Registry entry for dispatching requests.
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 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()) => resolver()}
Registry mapping port modules to resolvers
@type resolver() :: :direct | (port_module(), port_name(), args() -> term()) | {module(), atom()} | module()
Registry entry for dispatching requests.
:direct– callapply(mod, name, args)function(arity 3) –fun.(mod, name, args){module, function}– invokesapply(module, function, [mod, name, args])module– invokesapply(module, name, args)(implementation module)
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(), args()) :: 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)function(arity 3) –fun.(mod, name, args){module, function}– invokesapply(module, function, [mod, name, args])module– invokesapply(module, name, args)(implementation module)
Handlers may return any value. To signal errors, raise or use
Skuld.Effects.Throw.throw/1.
Example
my_comp
|> Port.with_handler(%{
MyApp.UserQueries => :direct,
MyApp.Repository => MyApp.Repository.Ecto
})
|> Comp.run!()
@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.:output- Transform result when leaving scope:suspend- Decorate Suspend values when yielding
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!()