Zenvoy.Results.Mediator
10.0.0
dotnet add package Zenvoy.Results.Mediator --version 10.0.0
NuGet\Install-Package Zenvoy.Results.Mediator -Version 10.0.0
<PackageReference Include="Zenvoy.Results.Mediator" Version="10.0.0" />
<PackageVersion Include="Zenvoy.Results.Mediator" Version="10.0.0" />
<PackageReference Include="Zenvoy.Results.Mediator" />
paket add Zenvoy.Results.Mediator --version 10.0.0
#r "nuget: Zenvoy.Results.Mediator, 10.0.0"
#:package Zenvoy.Results.Mediator@10.0.0
#addin nuget:?package=Zenvoy.Results.Mediator&version=10.0.0
#tool nuget:?package=Zenvoy.Results.Mediator&version=10.0.0
Zenvoy.Results
A high-performance, zero-allocation Result pattern for .NET 10 — return failures as values instead of throwing.
- Zero allocations:
Result<T>is a smallreadonly struct; success and failures with a cachedErrorallocate nothing. - Fast: returning
Result<int>costs well under a nanosecond more than returning anint; reporting a failure is ~950× faster than throwing and catching an exception. - Safe:
default(Result<T>)is never a success, and the bundled analyzer reports ignored results and unchecked.Valuereads. - Native AOT and trimming compatible: no reflection, no
MakeGenericType, no assembly scanning. - Simple API: implicit conversions,
Match,Map,Bind,Ensure, async variants and LINQ query syntax. - Integrations: ASP.NET Core (RFC 9457 problem details, OpenAPI metadata) and the Zenvoy mediator (validation and exception behaviors that never throw).
Contents
- Installation
- Quick start
- Errors
- Results
- Functional API
- Async API
- Avoiding allocations: static lambdas and state
- ASP.NET Core
- Zenvoy mediator integration
- Analyzer diagnostics
- Native AOT
- Benchmarks
- Migrating from ErrorOr or FluentResults
- Limitations
- Building from source
- CI/CD
- License
Installation
| Package | Contents | Dependencies |
|---|---|---|
Zenvoy.Results |
Result, Result<T>, Error, functional and async extensions, the analyzer |
none |
Zenvoy.Results.AspNetCore |
Result → Minimal API IResult / MVC IActionResult, RFC 9457 problem details |
Zenvoy.Results, Microsoft.AspNetCore.App |
Zenvoy.Results.Mediator |
Zenvoy integration: ICommand/IQuery, validation and exception behaviors |
Zenvoy.Results, Zenvoy.Abstractions (≥ 10.0.0, < 11.0.0) |
dotnet add package Zenvoy.Results
dotnet add package Zenvoy.Results.AspNetCore # web projects
dotnet add package Zenvoy.Results.Mediator # projects that use the Zenvoy mediator
Quick start
using Zenvoy.Results;
public static class UserErrors
{
public static readonly Error NotFound = Error.NotFound("User.NotFound", "User was not found.");
public static readonly Error Inactive = Error.Forbidden("User.Inactive", "User is inactive.");
}
public Result<User> GetUser(Guid id) =>
_users.TryGetValue(id, out var user) ? user : UserErrors.NotFound; // implicit conversions
Result<string> name = GetUser(id)
.Ensure(static u => u.IsActive, UserErrors.Inactive)
.Map(static u => u.Name); // no allocations
string text = name.Match(static n => $"Hello, {n}!", static e => e.Message);
Errors
Error is an immutable class with a Code, a Message, a Type (ErrorType) and optional Metadata.
A Result<T> only stores a pointer to it, so declare errors once as static readonly fields: failures that use them
do not allocate.
public static class OrderErrors
{
public static readonly Error NotFound = Error.NotFound("Order.NotFound", "Order was not found.");
public static readonly Error AlreadyShipped = Error.Conflict("Order.AlreadyShipped", "Order has already been shipped.");
}
ErrorType |
Meaning | Default HTTP status |
|---|---|---|
Failure |
General business rule failure | 400 (configurable) |
Validation |
Invalid input | 400 |
NotFound |
Resource does not exist | 404 |
Conflict |
Conflicts with the current state | 409 |
Unauthorized |
Not authenticated | 401 |
Forbidden |
Not allowed | 403 |
Unexpected |
Unexpected failure, usually from an exception | 500 |
Unavailable |
A dependency is unavailable | 503 |
Custom |
Application defined; map it yourself | 500 (configurable) |
- Equality compares
Code(ordinal) andType; the message is ignored because it may be localized. Error.FromException(ex)creates anExceptionError(Type = Unexpected) that keeps the exception for logging.ValidationErrorholds one or moreFieldError(Field, Code, Message)values.Erroris not sealed, so domain errors can carry data:
public sealed class InsufficientFundsError(decimal missing)
: Error("Account.InsufficientFunds", $"{missing} is missing.", ErrorType.Conflict)
{
public decimal Missing { get; } = missing;
}
Results
Result<int> ok = 42; // Result<int>.Success(42)
Result<int> failed = OrderErrors.NotFound; // Result<int>.Failure(error)
Result done = Result.Success();
Result notDone = OrderErrors.AlreadyShipped;
if (ok.IsSuccess) Console.WriteLine(ok.Value); // Value throws on a failure
if (failed.TryGetValue(out var value, out var error)) { } // no exceptions
int fallback = failed.GetValueOrDefault(-1);
Result all = Result.Combine(Validate(a), Validate(b)); // first failure
Result every = Result.CombineAll(Validate(a), Validate(b)); // all failures (ValidationError or AggregateError)
Result<int> parsed = Result.Try("42", static s => int.Parse(s, CultureInfo.InvariantCulture)); // exception -> failure
default(Result<T>)andnew Result<T>()are failures withError.Uninitialized, never successes.Valueon a failure andErroron a success throwInvalidOperationException.Result<T>is the value plus one pointer and a flag (Result<int>andResultare 16 bytes on 64-bit).- The common interfaces are
IResultBase(named to avoid a clash with ASP.NET Core'sIResult) andIResultFactory<TSelf>(static abstract FromError). Using a result throughIResultBaseboxes it; in hot paths use a generic constraint:void Log<TResult>(TResult result) where TResult : IResultBase.
Functional API
| Method | Purpose |
|---|---|
Match(onSuccess, onFailure) |
Convert both cases to one value |
Switch(onSuccess, onFailure) |
Run one of two actions |
Map(map) |
Transform the value |
Bind(bind) |
Chain an operation that returns a result |
Tap(action) / TapError(action) |
Side effects on success / failure, result unchanged |
Ensure(predicate, error) |
Fail when the value does not satisfy a condition |
MapError(map) |
Replace the error |
Compensate(fallback) |
Recover from a failure |
GetValueOrDefault(fallback) / GetValueOrElse(func) |
Value or fallback |
ToResult() |
Result<T> → Result |
Select / SelectMany |
LINQ query syntax |
var total =
from order in GetOrder(orderId)
from customer in GetCustomer(order.CustomerId)
select order.Total * customer.Discount;
Async API
MapAsync, BindAsync, TapAsync, EnsureAsync and MatchAsync work on Result<T>/Result with asynchronous
delegates, and on ValueTask<Result<T>>/Task<Result<T>> (and the non-generic Result) with synchronous or
asynchronous delegates:
Result<OrderDto> dto = await repository.FindAsync(id, ct) // ValueTask<Result<Order>>
.EnsureAsync(static o => o.IsVisible, OrderErrors.NotFound)
.BindAsync(static o => LoadLinesAsync(o)) // Func<Order, ValueTask<Result<Order>>>
.MapAsync(static o => o.ToDto());
- Asynchronous delegates return
ValueTask/ValueTask<T>;asynclambdas target them directly. - When the source and the delegate complete synchronously, no async state machine is created and nothing is allocated.
- All awaits use
ConfigureAwait(false); cancellation and exceptions propagate unchanged.
Avoiding allocations: static lambdas and state
A lambda that captures a local, a parameter or this allocates a closure and a delegate on every call. Every
operation therefore has a state-passing overload: pass the captured value as an argument and mark the lambda
static.
var name = GetUser(id).Map(static u => u.Name); // nothing captured: 0 B
var label = GetUser(id).Map(prefix, static (p, u) => p + u.Name); // state instead of a closure: 0 B (plus the string)
var bad = GetUser(id).Map(u => prefix + u.Name); // closure: allocates; reported by ZRS003
Results are passed by value. A micro-benchmark with non-inlined calls measured in about 0.3 ns faster; the
extensions are small and inlined, so by-value parameters were kept (see PassingBenchmarks in
benchmarks/RESULTS.md).
ASP.NET Core
builder.Services.AddZenvoyResults(o => o
.MapStatus(ErrorType.Failure, 422) // default 400
.MapStatus(ErrorType.Custom, 418));
// Minimal APIs
app.MapGet("/users/{id}", async (Guid id, Mediator m, CancellationToken ct) => (await m.Send(new GetUser(id), ct)).ToHttpResult());
app.MapPost("/users", async (CreateUser cmd, Mediator m, CancellationToken ct) =>
(await m.Send(cmd, ct)).ToCreatedHttpResult(static id => $"/users/{id}"));
// MVC
public IActionResult Get(Guid id) => _service.GetUser(id).ToActionResult();
| Result | Response |
|---|---|
Result<T> success |
200 OK with the value (ToHttpResult), 201 Created (ToCreatedHttpResult) or a custom IResult (ToHttpResult(onSuccess)) |
Result success |
204 No Content |
| Failure | RFC 9457 problem details with the mapped status code |
ValidationError |
HttpValidationProblemDetails: errors grouped by field |
- The problem details carry
extensions["code"] = error.Codeand the error'sMetadata. Unexpectederrors hide their message outsideDevelopment(ExposeUnexpectedErrorDetailsoverrides it).- The HTTP result types implement
IEndpointMetadataProvider, so OpenAPI documents (Microsoft.AspNetCore.OpenApi) list the success and problem details responses automatically. - Native AOT: everything uses
TypedResultsand works with the Request Delegate Generator.AddZenvoyResultscallsAddProblemDetails(), which registers the JSON metadata ofProblemDetails. Register your own DTOs (and the types of anyError.Metadatavalues) in yourJsonSerializerContext.
Zenvoy mediator integration
builder.Services.AddZenvoy(); // generated by Zenvoy
builder.Services.AddZenvoyResultsMediator(o =>
{
o.EnableValidation = true; // default true
o.EnableExceptionToResult = false; // default false
});
builder.Services.AddValidator<CreateUser, CreateUserValidator>(); // no assembly scanning
builder.Services.AddAsyncValidator<CreateUser, UniqueEmailValidator>();
public sealed record CreateUser(string Email, string Name) : ICommand<Guid>;
public sealed class CreateUserValidator : IRequestValidator<CreateUser>
{
public ValidationResult Validate(CreateUser r)
{
var b = new ValidationBuilder();
b.NotEmpty(nameof(r.Email), r.Email, "Email.Required", "Email is required.")
.MaximumLength(nameof(r.Name), r.Name, 100, "Name.TooLong", "Name must be at most 100 characters.");
if (r.Name.Contains('@')) b.Add(nameof(r.Name), "Name.Invalid", "Name must not contain '@'.");
return b.Build();
}
}
public sealed class CreateUserHandler(IUserRepository repo) : ICommandHandler<CreateUser, Guid>
{
public async ValueTask<Result<Guid>> Handle(CreateUser cmd, CancellationToken ct)
{
if (await repo.EmailExists(cmd.Email, ct)) return UserErrors.EmailTaken; // Error -> Result<Guid>
return await repo.Add(cmd.Email, cmd.Name, ct); // Guid -> Result<Guid>
}
}
ICommand,ICommand<T>,IQuery<T>and their handlers areIRequest<Result>/IRequest<Result<T>>shortcuts.ValidationBehavior([PipelineOrder(-1000)]) runs the validators; on failure it returnsTResponse.FromError(validationError)— the handler is not called and nothing is thrown. Without validators, or when validation succeeds, it adds no allocations.ValidationResultis a struct andValidationBuilderis aref structthat rents its buffer fromArrayPoolonly when an error is added.ExceptionToResultBehavior([PipelineOrder(int.MinValue)], opt-in) turns unexpected exceptions intoError.FromException(ex)failures and logs them with a source-generatedLoggerMessage.OperationCanceledExceptionis always rethrown.- Both behaviors are declared
where TResponse : IResultFactory<TResponse>.IResultFactory<TSelf>has astatic abstract FromErrormethod, so the behaviors create failures without reflection, and the Zenvoy source generator closes them at compile time only for requests whose response isResult/Result<T>. A snapshot test (tests/Zenvoy.Results.Mediator.Tests) runs the Zenvoy generator and records the generated pipeline. - Lifetimes: validators default to singletons, matching Zenvoy's default behavior lifetime. Use scoped validators only
when the behaviors are scoped too (
[assembly: ZenvoyOptions(DefaultLifetime = ServiceLifetimeKind.Scoped)]).
Analyzer diagnostics
The analyzer and its code fixes ship inside the Zenvoy.Results package.
| ID | Severity | Description | Code fix |
|---|---|---|---|
| ZRS001 | Warning | A returned Result is ignored |
Discard with _ = |
| ZRS002 | Warning | Value/Error is read without checking IsSuccess/IsFailure |
Rewrite to TryGetValue |
| ZRS003 | Info | A capturing lambda is passed where a state-passing overload exists | — |
| ZRS004 | Warning | default(Result<T>) or new Result<T>() |
Failure(Error.Uninitialized) |
ZRS001
DoSomething(); drops the outcome of an operation that can fail. Awaited tasks are covered too
(await SaveAsync(); when SaveAsync returns Task<Result>/ValueTask<Result<T>>). Check the result or discard it
explicitly with _ = DoSomething();. Tap, TapError and TapAsync used as statements are not reported.
ZRS002
result.Value throws when the result failed. The check is intentionally simple: the diagnostic is reported when no
IsSuccess, IsFailure, TryGetValue, TryGetError or property pattern (result is { IsSuccess: true }) on the same
variable appears earlier in the member. The code fix rewrites var value = result.Value; to
if (!result.TryGetValue(out var value, out var error)) { return error; } when the member returns a result.
ZRS003
result.Map(v => v + offset) allocates a closure. Use result.Map(offset, static (o, v) => v + o).
ZRS004
default(Result<T>) is a failure with Error.Uninitialized. Use Result.Success/Result.Failure; the code fix
makes the failure explicit so it can be replaced with a real error.
Native AOT
All packages are annotated IsAotCompatible. tests/Zenvoy.Results.AotTests
is a Minimal API app (CreateSlimBuilder) that uses Zenvoy and every Zenvoy.Results package, calls its own endpoints
and exits with 0. Publishing it must produce zero IL2xxx/IL3xxx warnings:
scripts/aot-check.sh # current machine
scripts/aot-check.sh linux-x64 # on Linux (cross-OS Native AOT publishing is not supported by the SDK)
On Windows without the C++ build tools, run it in the SDK container:
docker run --rm -v "$PWD:/src" -w /src mcr.microsoft.com/dotnet/sdk:10.0-noble-aot bash scripts/aot-check.sh linux-x64
Benchmarks
Intel Core i9-14900KF, .NET 10.0.13, BenchmarkDotNet 0.15.7. Full results, methodology and the targets that were not
met: benchmarks/RESULTS.md.
| Scenario | Zenvoy.Results | ErrorOr 2.1.1 | FluentResults 4.0.0 | Ardalis.Result 10.1.0 | CSharpFunctionalExtensions 3.7.0 |
|---|---|---|---|---|---|
| Success | 1.43 ns / 0 B | 1.15 ns / 0 B | 24.6 ns / 160 B | 7.6 ns / 64 B | 1.40 ns / 0 B |
| Failure, cached error | 2.40 ns / 0 B | 16.0 ns / 88 B | 37.3 ns / 208 B | 8.8 ns / 64 B | 1.24 ns / 0 B |
| Failure, new error | 5.56 ns / 48 B | 9.7 ns / 88 B | 57.1 ns / 360 B | ||
| Ensure → Map → Bind → Match | 3.6–6.4 ns / 0 B | 16.9 ns / 0 B | 194.8 ns / 1104 B | 13.3 ns / 0 B |
- Returning a failure: 2.16 ns versus 2,043 ns for
throw/catch(~950×). ValueTask<Result<T>>.MapAsyncon a completed task: 0.55 ns / 0 B.- Zenvoy request through
ValidationBehavior+ExceptionToResultBehavior: 5.2 ns / 0 B without validators, 5.9 ns / 0 B when validation succeeds (a plain request without behaviors: 1.0 ns).
Migrating from ErrorOr or FluentResults
| ErrorOr | FluentResults | Zenvoy.Results |
|---|---|---|
ErrorOr<T> |
Result<T> (class) |
Result<T> (struct) |
ErrorOr<Success> / Result.Success |
Result |
Result / Result.Success() |
Error.NotFound(code, description) |
new Error(message) |
Error.NotFound(code, message) |
result.IsError, result.Errors, result.FirstError |
result.IsFailed, result.Errors |
result.IsFailure, result.Error (one error; ValidationError/AggregateError hold many) |
result.Value |
result.Value |
result.Value (throws on failure) / TryGetValue |
Then, ThenAsync |
Map, Bind |
Map, Bind, MapAsync, BindAsync |
FailIf, Else |
— | Ensure, Compensate / GetValueOrElse |
Match, Switch |
Match |
Match, Switch |
ToErrorOr() |
Result.Ok(value) |
implicit conversion or Result.Success(value) |
Errors list |
Reasons/Errors list |
ValidationError with FieldErrors, Result.CombineAll |
Steps:
- Replace the package and the namespace (
using Zenvoy.Results;). - Turn error factories into
static readonly Errorfields (Error.NotFound("Code", "Message")). - Replace
ThenwithMap(value) orBind(result),FailIfwithEnsure(inverted predicate),IsErrorwithIsFailureandFirstErrorwithError. - Multiple errors: return a
ValidationError(field errors) or useResult.CombineAll. - Prefer
staticlambdas; the analyzer (ZRS003) points out the capturing ones.
Limitations
- One
Errorper result. Multiple errors are represented byValidationError(field errors) andAggregateError. - C# does not allow user-defined conversions from interfaces: when
Tis an interface, useResult.Success<T>(value)instead of the implicit conversion. Result<T>is not a wire format: there is noSystem.Text.Jsonconverter in v1. Convert results to DTOs or problem details at the API boundary.- Asynchronous delegates must return
ValueTask/ValueTask<T>(method groups returningTaskneed a lambda). - ZRS002 uses a simple lexical check, not full flow analysis.
- Not in v1: source-generated validator registration, a FluentValidation adapter,
Result<T, TError>. - Targets .NET 10 only.
Building from source
dotnet build -c Release -warnaserror
dotnet test -c Release
scripts/aot-check.sh
dotnet run -c Release --project benchmarks/Zenvoy.Results.Benchmarks -- --filter '*'
scripts/pack.sh # artifacts/packages
scripts/verify-packages.sh
scripts/test-local-package.sh # builds the sample against the local .nupkg files
- Public API changes are tracked in
PublicAPI.*.txt;scripts/update-public-api.sh <project dir>adds new symbols toPublicAPI.Unshipped.txt. - The
ValueTask/Taskasync overloads are generated:dotnet run scripts/generate-async-extensions.cs. - Snapshot tests store the Zenvoy generator output in
tests/Zenvoy.Results.Mediator.Tests/Snapshots. When it changes intentionally, review the.received.csfiles or run the tests withZENVOY_RESULTS_ACCEPT_SNAPSHOTS=1. - Versions come from git tags via MinVer: tag
v10.0.0produces packages10.0.0. The major version follows .NET and Zenvoy (10.xtargets .NET 10).
CI/CD
CI runs on GitLab (.gitlab-ci.yml):
| Trigger | Jobs |
|---|---|
Commits land on main (a merge request from master is merged) |
build: build with -warnaserror, tests, Native AOT check on linux-x64, pack, package verification, sample built from the local packages |
v* tag, when the NUGET_API_KEY variable is available |
release: verifies the tagged commit is on main, then packs and pushes to nuget.org |
Pushes to master (or any other branch) and merge request pipelines do not run CI.
License
MIT © Maxsud Mutalov
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.0)
- Zenvoy.Abstractions (>= 10.0.0 && < 11.0.0)
- Zenvoy.Results (>= 10.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 10.0.0 | 85 | 11/9/2026 |