Skip to content

RPC Response Extraction

Verified by tests

ResponseExtractorTests, DispatcherRpcExtractionTests, RouteTests, RoutedTests — library CI run #31657041675 (2026-08-13)

RPC (Remote Procedure Call) style invocations allow you to call a receptor and receive a specific response type back, while other returned values cascade through normal routing.

Overview

When using LocalInvokeAsync<TResponse>(command), the dispatcher:

  1. Extracts the requested TResponse type from the receptor's return value
  2. Returns that value directly to the caller
  3. Cascades all other returned values through normal routing (outbox by default)

This enables receptors to return multiple values (via tuples) while callers receive only what they need.

Example

Example

// Command
public record CreateOrder(Guid OrderId, decimal Amount);

// Response types
public record OrderConfirmation {
  public required Guid OrderId { get; init; }
  public required string ConfirmationCode { get; init; }
}

[DefaultRouting(DispatchModes.Outbox)]
public record InventoryReserved([property: StreamId] Guid OrderId) : IEvent;

// Receptor returns tuple: (response to caller, event to cascade)
public class CreateOrderReceptor
    : IReceptor<CreateOrder, (OrderConfirmation, InventoryReserved)> {

  public ValueTask<(OrderConfirmation, InventoryReserved)> HandleAsync(
      CreateOrder command,
      CancellationToken ct = default) {

    var confirmation = new OrderConfirmation {
      OrderId = command.OrderId,
      ConfirmationCode = $"CONF-{command.OrderId:N}"
    };

    var inventory = new InventoryReserved(command.OrderId);

    return ValueTask.FromResult((confirmation, inventory));
  }
}

Caller Side

Caller Side

// RPC call - OrderConfirmation returned to caller
var confirmation = await dispatcher.LocalInvokeAsync<OrderConfirmation>(
    new CreateOrder(Guid.NewGuid(), 99.99m));

// InventoryReserved automatically cascades to outbox (per [DefaultRouting])
// confirmation.ConfirmationCode is available to caller

How It Works

Response Extraction

The ResponseExtractor utility extracts the requested type from complex return values:

Return Type Extraction Behavior
Single value Direct match returns immediately
Tuple (A, B, C) Searches each element for match
Array/List Searches each element for match
Routed<T> wrapper Unwraps and extracts from inner value

Cascade Exclusion

After extraction, remaining values cascade based on their routing:

  • Extracted response: Returned to RPC caller (NOT cascaded)
  • Other IEvent values: Cascade per routing ([DefaultRouting] or wrapper)
  • Non-message values: Ignored (not cascaded)

Routing Wrappers Ignored for RPC

RPC responses are extracted regardless of routing wrappers:

Routing Wrappers Ignored for RPC

// Even if wrapped in Route.Local() or Route.Outbox(),
// the value is still extracted and returned to RPC caller
return (Route.Local(confirmation), inventory);
// confirmation goes to caller, inventory cascades

Supported Return Types

Tuples (2-8 elements)

Tuples (2-8 elements)

// 2-tuple
IReceptor<Cmd, (Response, Event)>

// 3-tuple
IReceptor<Cmd, (Response, Event1, Event2)>

Mixed with Routing

Mixed with Routing

// Explicit routing on cascaded events
IReceptor<Cmd, (Response, Routed<CacheInvalidated>)>

Interface-Based Extraction

Interface-Based Extraction

// Extract by interface
var evt = await dispatcher.LocalInvokeAsync<IEvent>(command);
// Returns first IEvent found in tuple

Discriminated Unions

Discriminated unions enable receptors to return multiple possible outcomes in a type-safe tuple, where only one value is populated and others are explicitly empty using Route.None() or null. This pattern is useful for modeling success/failure paths, validation results, or conditional responses.

Using Route.None()

Route.None() explicitly marks a tuple position as "no value":

Using Route.None()

// Receptor returning success OR failure
public class ProcessPaymentReceptor
    : IReceptor<ProcessPayment, (PaymentSucceeded?, PaymentFailed?)> {

  public async ValueTask<(PaymentSucceeded?, PaymentFailed?)> HandleAsync(
      ProcessPayment command,
      CancellationToken ct = default) {

    var result = await _paymentService.ProcessAsync(command);

    if (result.Success) {
      // Success path - failure is Route.None()
      return (new PaymentSucceeded(command.PaymentId), null);
    } else {
      // Failure path - success is null
      return (null, new PaymentFailed(command.PaymentId, result.Error));
    }
  }
}

Extracting from Discriminated Unions

The caller extracts whichever value is present:

Extracting from Discriminated Unions

// Try to extract success
var success = await dispatcher.LocalInvokeAsync<PaymentSucceeded>(command);
// Returns PaymentSucceeded if success path was taken
// Throws InvalidOperationException if failure path (success was null)

Explicit Route.None() Syntax

For more explicit code, use Route.None() instead of null:

Explicit Route.None() Syntax

return (success: Route.None(), failure: new PaymentFailed(...));

Route.None() values are: - Never extracted as RPC responses - Never cascaded as events - AOT-compatible (simple struct with DispatchModes.None)

RoutedNone Type

Route.None() returns a RoutedNone struct:

RoutedNone Type

/// <summary>
/// Represents an explicitly empty value in a discriminated union tuple.
/// </summary>
public readonly struct RoutedNone : IRouted {
  public object? Value => null;
  public DispatchModes Mode => DispatchModes.None;
}

RoutedNone is useful when: - Returning discriminated union tuples with conditional paths - Explicitly marking "no value" (clearer than null) - Maintaining type safety in tuple return types

Three-Way Unions

Discriminated unions can have more than two paths:

Three-Way Unions

// Success, validation error, or system error
IReceptor<Cmd, (SuccessResult?, ValidationError?, SystemError?)>

// Implementation
return command.Amount < 0
    ? (null, new ValidationError("Amount must be positive"), null)
    : command.Amount > 10000
    ? (null, null, new SystemError("Amount exceeds limit"))
    : (new SuccessResult(command.Amount), null, null);

Error Handling

Type Not Found

If the requested type doesn't exist in the return value:

Type Not Found

// Receptor returns (OrderConfirmation, InventoryReserved)
// But caller requests PaymentProcessed
await dispatcher.LocalInvokeAsync<PaymentProcessed>(command);
// Throws InvalidOperationException

Multiple Matches

If multiple values match the requested type, the first match is returned:

Multiple Matches

// Tuple: (OrderCreated{Id="first"}, OrderCreated{Id="second"})
var order = await dispatcher.LocalInvokeAsync<OrderCreated>(command);
// order.Id == "first"

Performance Considerations

Fast Path (Exact Match)

When the receptor's return type exactly matches TResponse, no extraction is needed:

Fast Path (Exact Match)

// Receptor: IReceptor<Cmd, OrderConfirmation>
// Caller: LocalInvokeAsync<OrderConfirmation>(cmd)
// Result: Fast path - no extraction overhead

Extraction Path

When types differ, extraction adds minimal overhead:

  • Uses ITuple interface (AOT-compatible)
  • No reflection - pattern matching only
  • Single pass through tuple elements

AOT Compatibility

RPC extraction is fully AOT-compatible:

  • Uses ITuple interface for tuple handling
  • Pattern matching with is TResponse
  • No Type.GetType() or reflection APIs
  • ReferenceEquals for cascade exclusion

Best Practices

  1. Return tuples for multi-value responses
  2. Clear separation between RPC response and events
  3. Explicit about what cascades vs returns

  4. Use [DefaultRouting] on events

  5. Cascaded events route automatically
  6. No need for explicit Route.Outbox() wrappers

  7. Request specific types

  8. Avoid interface-based extraction when possible
  9. More predictable behavior with concrete types

  10. Handle extraction failures

  11. Wrap calls in try-catch for production code
  12. Log when extraction fails for debugging