Skuld.Effects.Port (skuld v0.23.0)

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

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() | :__default__) => resolver()}

Registry mapping port modules (or :__default__) to resolvers

resolver()

@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 – call apply(mod, name, args), result is a plain value
  • module – invokes apply(module, name, args) (implementation module). Modules where __port_effectful__?/0 returns truthy (e.g. via use MyContract.Effectful) are auto-detected as effectful resolvers whose return values are computations inlined into the current effect context. Returning false opts 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} – invokes apply(module, function, [mod, name, args])

Default resolvers (used as :__default__ catch-all)

  • {:test_stub, responses} – map-based test stubs keyed by Port.key/3
  • {:test_stub, responses, fallback} – test stubs with fallback function
  • {:fn_dispatch, handler_fn} – function-based dispatch via fn(mod, name, args)
  • {:stateful_dispatch, handler_fn} – stateful dispatch via fn(mod, name, args, state) -> {result, new_state}

stateful_handler()

@type stateful_handler() :: (port_module(), port_name(), args(), term() ->
                         {term(), term()})

Stateful handler function - receives (mod, name, args, state), returns {result, new_state}

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)

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), returns a plain value
  • module – invokes apply(module, name, args). Modules where __port_effectful__?/0 returns truthy (e.g. via use MyContract.Effectful) are auto-detected as effectful resolvers whose return values are computations inlined into the current effect context. Returning false opts 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} – invokes apply(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 in Port.State.log. Disabled by default (nil) for zero overhead in production.
  • :output — transform function (result, %Port.State{}) -> output called on scope exit. When logging is enabled, state.log contains 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!()

with_stateful_handler(comp, initial_state, handler_fn, opts \\ [])

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 (see with_handler/3)
  • :output — transform (result, %Port.State{}) -> output on scope exit. The state.handler_state field 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

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.
  • :log — enable dispatch logging (see with_handler/3)
  • :output - Transform (result, %Port.State{}) -> output on 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!()