Skuld.Effects.Port (skuld v0.2.3)

View Source

Effect 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 name
  • args – 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 handle
  • request!/3 – unwraps {:ok, value} or dispatches Throw on 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

args()

@type args() :: list()

List of positional arguments

fn_handler()

@type fn_handler() :: (port_module(), port_name(), args() -> term())

Function handler for test scenarios - receives (mod, name, args)

port_module()

@type port_module() :: module()

Module identity (contract or implementation module)

port_name()

@type port_name() :: atom()

Function exported by port_module

registry()

@type registry() :: %{required(port_module()) => resolver()}

Registry mapping port modules to resolvers

resolver()

@type resolver() ::
  :direct
  | (port_module(), port_name(), args() -> term())
  | {module(), atom()}
  | module()

Registry entry for dispatching requests.

  • :direct – call apply(mod, name, args)
  • function (arity 3) – fun.(mod, name, args)
  • {module, function} – invokes apply(module, function, [mod, name, args])
  • module – invokes apply(module, name, args) (implementation module)

Functions

__handle__(comp, registry)

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}

key(mod, name, args)

@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])

request(mod, name, args \\ [])

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}}

request!(mod, name, args \\ [])

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}

request_bang(mod, name, args, unwrap_fn)

@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

with_fn_handler(comp, handler_fn, opts \\ [])

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

with_handler(comp, registry \\ %{}, opts \\ [])

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 – call apply(mod, name, args)
  • function (arity 3) – fun.(mod, name, args)
  • {module, function} – invokes apply(module, function, [mod, name, args])
  • module – invokes apply(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!()

with_test_handler(comp, responses, opts \\ [])

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) -> result to 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!()