Phase 0+1+2+3 of custom Result<T> support — registry, seams 1 & 3, caller-side unwrap (refs #2221)#2952
Merged
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
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:
Wolverine/Configuration/ResultTypeRegistry.cs+ResultTypeRegistration.cs+IResultTypeRegistration.csWolverine/WolverineOptions.Results.csUseResultType<TResult>(...)(closed) +UseResultType(typeof(Result<>), ...)(open-generic). Idempotent: re-registering replaces; only registers the policies + DI singleton on first call.Wolverine/Middleware/ResultTypeContinuationPolicy.csRequirementResultContinuationPolicy. Detects aBefore-style middleware method whose return is a registered Result type and emits an early-return frame.Wolverine/Runtime/Handlers/ResultUnwrappingActionSource.cs+ResultTypeReturnActionPolicy.csIHandlerPolicywalks chains and replaces the defaultCascadingMessageActionSourcewith the unwrap-and-cascade variant whenever the handler returns a registered Result type.Wolverine/Runtime/RemoteInvocation/ReplyListener<T>.CompleteResultFailureExceptionon failure. Threaded viaReplyTrackerso the same path serves in-process and remote callers.One supporting change:
IContinuationStrategygot an opt-in extensionIRulesAwareContinuationStrategyso strategies that need to consult per-host state can receiveGenerationRules. Non-breaking — existing strategies keep the rules-free overload and aren't touched.ResultFailureExceptionis the new top-level exception type, withErrors : IReadOnlyList<string>.Tests + verification
Testing/CoreTests/Acceptance/result_types_end_to_end.csagainst FluentResults 3.16.0:InvokeAsync<OrderPlaced>(...)againstResult<OrderPlaced>successResultFailureException[Skip]— needs seam 2InvokeAsync<Result<OrderPlaced>>(...)returns wrapper on both branches[Skip]— needs seam 2InvokeAsync(...)void againstResult<T>success cascades inner TTask<Result<T>>async handler unwraps normally5/5 active tests pass; 2 deferred with clear follow-up notes inlined in the file. Full
dotnet build wolverine.slnx -c Releaseclean (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'sReturnVariableActionSource, the reply path on a Result-returning handler either: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.UseForResponseto 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 againstProblemDetails) 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'sResultTypeReturnActionPolicyis anIHandlerPolicythat runs eagerly duringHandlerGraph.Compile— both run before any chain's lazy attributeModify()work fires.Following the issue's design plan
Maps to Jeremy's phase table like this:
UseSimpleResults(),UseFluentResults())describe-handlersannotation)🤖 Generated with Claude Code