Skip to content

Phase 0+1+2+3 of custom Result<T> support — registry, seams 1 & 3, caller-side unwrap (refs #2221)#2952

Merged
jeremydmiller merged 1 commit into
mainfrom
feat-2221-result-type-support
May 28, 2026
Merged

Phase 0+1+2+3 of custom Result<T> support — registry, seams 1 & 3, caller-side unwrap (refs #2221)#2952
jeremydmiller merged 1 commit into
mainfrom
feat-2221-result-type-support

Conversation

@jeremydmiller
Copy link
Copy Markdown
Member

First slice of issue #2221, following @jeremydmiller's design plan posted there. Implements the in-process mediator portion end-to-end: the registry, seams 1 & 3, and the caller-side unwrap (component R). Leaves seam 2 (request/reply wire-format-is-literal-Result), HTTP (Phase 4), tiny library adapters (Phase 5), diagnostics (Phase 6), and the tutorial (Phase 7) for follow-ups, plus the two specific test scenarios noted at the bottom that need seam 2 to land cleanly.

What works end-to-end (validated)

opts.UseResultType(
    typeof(Result<>),
    stopWhen: x => ((ResultBase)x).IsFailed,
    unwrapWith: x => x.GetType().GetProperty(nameof(Result<int>.ValueOrDefault))!.GetValue(x),
    errorsFrom: x => ((ResultBase)x).Errors.Select(e => e.Message));

// Handler — feels like writing a normal MediatR-style Result handler
public static class CreateOrderHandler
{
    public static Result<OrderPlaced> Handle(CreateOrder cmd, OrdersBook book)
    {
        if (cmd.Quantity <= 0) return Result.Fail<OrderPlaced>("Quantity must be positive");
        book.Placed.Add(cmd.OrderId);
        return Result.Ok(new OrderPlaced(cmd.OrderId));
    }
}

// Caller
var placed = await bus.InvokeAsync<OrderPlaced>(new CreateOrder("o-1", 5));   // success: unwrap to OrderPlaced
await bus.InvokeAsync(new CreateOrder("o-2", 0));                              // failure: errors logged, no cascade

That's exactly the user-experience Jeremy described in the design-plan "What it looks like end-to-end" section, working today against FluentResults 3.16.0 — no library-specific code in Wolverine; the registration takes accessors as lambdas so it works against any Result library.

Architecture

Four files form the new feature surface:

Component File Role
Registry Wolverine/Configuration/ResultTypeRegistry.cs + ResultTypeRegistration.cs + IResultTypeRegistration.cs Per-host catalog. Resolves a closed handler-return type against the registered set (exact match + open-generic match), memoized hot path.
Public API Wolverine/WolverineOptions.Results.cs UseResultType<TResult>(...) (closed) + UseResultType(typeof(Result<>), ...) (open-generic). Idempotent: re-registering replaces; only registers the policies + DI singleton on first call.
Seam 1 Wolverine/Middleware/ResultTypeContinuationPolicy.cs Continuation strategy mirroring RequirementResultContinuationPolicy. Detects a Before-style middleware method whose return is a registered Result type and emits an early-return frame.
Seam 3 Wolverine/Runtime/Handlers/ResultUnwrappingActionSource.cs + ResultTypeReturnActionPolicy.cs Phase-A IHandlerPolicy walks chains and replaces the default CascadingMessageActionSource with the unwrap-and-cascade variant whenever the handler returns a registered Result type.
Component R Wolverine/Runtime/RemoteInvocation/ReplyListener<T>.Complete Caller-side unwrap. T == wrapper → pass-through. T == inner → unwrap on success, throw ResultFailureException on failure. Threaded via ReplyTracker so the same path serves in-process and remote callers.

One supporting change: IContinuationStrategy got an opt-in extension IRulesAwareContinuationStrategy so strategies that need to consult per-host state can receive GenerationRules. Non-breaking — existing strategies keep the rules-free overload and aren't touched.

ResultFailureException is the new top-level exception type, with Errors : IReadOnlyList<string>.

Tests + verification

Testing/CoreTests/Acceptance/result_types_end_to_end.cs against FluentResults 3.16.0:

ID Scenario Status
B-1 InvokeAsync<OrderPlaced>(...) against Result<OrderPlaced> success ✅ pass
B-2 Same, failure throws ResultFailureException [Skip] — needs seam 2
B-3 InvokeAsync<Result<OrderPlaced>>(...) returns wrapper on both branches [Skip] — needs seam 2
B-4 InvokeAsync(...) void against Result<T> success cascades inner T ✅ pass
B-5 Same, failure suppresses cascade entirely ✅ pass
B-7 Task<Result<T>> async handler unwraps normally ✅ pass
E-1 Non-Result handlers unaffected by registration (regression guard) ✅ pass

5/5 active tests pass; 2 deferred with clear follow-up notes inlined in the file. Full dotnet build wolverine.slnx -c Release clean (0 warnings, 0 errors).

Why B-2 and B-3 are deferred

Both scenarios are on the InvokeAsync<T> request/reply path. With only seam 3 substituting the chain's ReturnVariableActionSource, the reply path on a Result-returning handler either:

  • Suppresses the cascade entirely on failure (no reply ever lands at the caller), or
  • Unwraps before the wire on success when the caller awaited the wrapper (Result<T> never reaches component R as the literal type Jeremy's Q5 specifies).

The clean fix is exactly what Jeremy's plan table calls seam 2 — teach HandlerChain.UseForResponse to KEEP the literal wrapper on the reply path while seam 3 keeps unwrapping for fire-and-forget. That's the natural next slice and lives alongside Phase 4 (HTTP, which has the same wrapper-vs-unwrap branching against ProblemDetails) and the C-series external-transport tests. Tracking that as the immediate follow-up.

The Phase A vs Phase B ordering trap from #2941 / #2944 doesn't bite this feature: seam 1 reads the registry from GenerationRules.Properties (set at registration time), and seam 3's ResultTypeReturnActionPolicy is an IHandlerPolicy that runs eagerly during HandlerGraph.Compile — both run before any chain's lazy attribute Modify() work fires.

Following the issue's design plan

Maps to Jeremy's phase table like this:

Phase Status
0 — Spike against SimpleResults folded into Phase 1; spike API shape validated against FluentResults instead per scope-question answer
1 — Core registration + seam 1
2 — Seam 3 (non-request/reply handler return)
3 — Component R (caller-side unwrap) ✅ for happy path; B-2 / B-3 wrapper-passthrough + failure-throws need seam 2
4 — Wolverine.Http binding follow-up
5 — Tiny adapters (UseSimpleResults(), UseFluentResults()) follow-up
6 — Diagnostics (describe-handlers annotation) follow-up
7 — Docs / tutorial / mdsnippets follow-up

🤖 Generated with Claude Code

Implements the in-process mediator portion of the design plan @jeremydmiller
posted on #2221:

  - Registry + registration (Configuration/ResultTypeRegistration.cs +
    ResultTypeRegistry.cs + IResultTypeRegistration.cs). Open-generic and
    closed-type shapes both supported; open-generic resolves against any
    closed form via GetGenericTypeDefinition match.

  - Public API on WolverineOptions (WolverineOptions.Results.cs):
      UseResultType<TResult>(stopWhen, unwrapWith, errorsFrom)         // closed
      UseResultType<TResult>(stopWhen, errorsFrom)                     // non-generic
      UseResultType(typeof(Result<>), ..., unwrappedArgumentIndex = 0) // open-generic

  - Seam 1 — Middleware/ResultTypeContinuationPolicy.cs. Mirrors
    RequirementResultContinuationPolicy. New opt-in
    IRulesAwareContinuationStrategy lets the dispatcher hand GenerationRules
    to strategies that need to consult per-host state (non-breaking; existing
    strategies keep the rules-free overload).

  - Seam 3 — Runtime/Handlers/ResultUnwrappingActionSource.cs +
    ResultTypeReturnActionPolicy.cs. IHandlerPolicy walks chains at compile
    and substitutes the chain's IReturnVariableActionSource for the unwrap-
    and-cascade variant when the handler returns a registered Result type.
    Phase A pattern, matches the MartenStoreEagerPolicy precedent.

  - Component R — Runtime/RemoteInvocation/ReplyListener<T>.Complete.
    Caller-side unwrap: when the response envelope carries a registered
    Result type, the awaited T decides what we hand back. T == wrapper:
    pass through. T == inner: unwrap on success, throw ResultFailureException
    on failure. Threaded via ReplyTracker so the same path serves in-process
    and remote callers.

  - ResultFailureException (top-level).

Tests at Testing/CoreTests/Acceptance/result_types_end_to_end.cs cover the
B-series happy path against FluentResults 3.16.0:

  Passing:
   - B-1 invokeasync_T_against_result_returning_handler_unwraps_success
   - B-4 invokeasync_void_against_result_success_cascades_inner_T
   - B-5 invokeasync_void_against_result_failure_does_not_cascade_anything
   - B-7 async_handler_returning_task_of_result_unwraps_normally
   - E-1 plain_non_result_handlers_are_unaffected_when_result_types_registered

  [Skip]'d with follow-up notes:
   - B-2 InvokeAsync<T> failure throws ResultFailureException
   - B-3 InvokeAsync<Result<T>> returns the wrapper

Why those two are deferred: with only seam 3 substituting the chain's
ReturnVariableActionSource, the request/reply path on a failure either
suppresses the cascade entirely (no reply gets sent) or unwraps before the
wire (caller awaiting Result<T> never sees the wrapper). Both need seam 2
(HandlerChain.UseForResponse) to be taught to KEEP the literal wrapper on
the reply path while seam 3 unwraps for fire-and-forget. That refinement is
the next slice (Phase 3 polish), naturally bundled with Phase 4 (HTTP) and
the external-transport request/reply C-series tests since they share the
same wire-format-is-literal-Result<T> contract Jeremy specified in Q5.

The Phase A vs Phase B ordering trap from #2941 / #2944 doesn't bite this
feature: the seam-1 continuation strategy uses GenerationRules.Properties
to read the registry without depending on attribute Modify() having run,
and the seam-3 handler policy runs eagerly during HandlerGraph.Compile.

Local: full wolverine.slnx -c Release builds clean (0 warnings, 0 errors).
CoreTests result_types_end_to_end suite: 5/5 active tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jeremydmiller jeremydmiller merged commit b1e08fc into main May 28, 2026
23 checks passed
jeremydmiller added a commit that referenced this pull request May 28, 2026
Bug-fix + feature release on top of 6.1.0 — 13 PRs.

Notable additions:
- Custom Result<T> handler-return-value support (Phases 0+1+2+3, #2952, refs #2221)
- DbContext abstractions for EF Core transaction middleware (#2919 + docs/tests #2954)
- Outgoing Envelope pooling at MessageRouter.RouteForPublish (#2956, closes #2955)
  — ~-504 B/op on transport-bound sends per the CritterStackScalability
  WolverineTransportBenchmarks harness

Bug fixes: scheduled-cascade loss from [ReadAggregate]/[DocumentExists]
handlers (#2941), ancillary-store inbox routing (#2944), Postgres queue-name
length (#2942), MySQL node-record quoting (#2940), Pulsar batched-partition
ack KeyNotFoundException (#2883/#2950), remote-node agent reply timeout
(#2949), and additional resource-disposal cleanup (#2894 from
dmytro-pryvedeniuk).

Polecat bumped 4.1.1 -> 4.2.1 (#2947); Marten + JasperFx families unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant