Zenvoy.Results.AspNetCore 10.0.0

dotnet add package Zenvoy.Results.AspNetCore --version 10.0.0
                    
NuGet\Install-Package Zenvoy.Results.AspNetCore -Version 10.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Zenvoy.Results.AspNetCore" Version="10.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Zenvoy.Results.AspNetCore" Version="10.0.0" />
                    
Directory.Packages.props
<PackageReference Include="Zenvoy.Results.AspNetCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Zenvoy.Results.AspNetCore --version 10.0.0
                    
#r "nuget: Zenvoy.Results.AspNetCore, 10.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Zenvoy.Results.AspNetCore@10.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Zenvoy.Results.AspNetCore&version=10.0.0
                    
Install as a Cake Addin
#tool nuget:?package=Zenvoy.Results.AspNetCore&version=10.0.0
                    
Install as a Cake Tool

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 small readonly struct; success and failures with a cached Error allocate nothing.
  • Fast: returning Result<int> costs well under a nanosecond more than returning an int; 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 .Value reads.
  • 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

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) and Type; the message is ignored because it may be localized.
  • Error.FromException(ex) creates an ExceptionError (Type = Unexpected) that keeps the exception for logging.
  • ValidationError holds one or more FieldError(Field, Code, Message) values.
  • Error is 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>) and new Result<T>() are failures with Error.Uninitialized, never successes.
  • Value on a failure and Error on a success throw InvalidOperationException.
  • Result<T> is the value plus one pointer and a flag (Result<int> and Result are 16 bytes on 64-bit).
  • The common interfaces are IResultBase (named to avoid a clash with ASP.NET Core's IResult) and IResultFactory<TSelf> (static abstract FromError). Using a result through IResultBase boxes 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>; async lambdas 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.Code and the error's Metadata.
  • Unexpected errors hide their message outside Development (ExposeUnexpectedErrorDetails overrides 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 TypedResults and works with the Request Delegate Generator. AddZenvoyResults calls AddProblemDetails(), which registers the JSON metadata of ProblemDetails. Register your own DTOs (and the types of any Error.Metadata values) in your JsonSerializerContext.

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 are IRequest<Result>/IRequest<Result<T>> shortcuts.
  • ValidationBehavior ([PipelineOrder(-1000)]) runs the validators; on failure it returns TResponse.FromError(validationError) — the handler is not called and nothing is thrown. Without validators, or when validation succeeds, it adds no allocations. ValidationResult is a struct and ValidationBuilder is a ref struct that rents its buffer from ArrayPool only when an error is added.
  • ExceptionToResultBehavior ([PipelineOrder(int.MinValue)], opt-in) turns unexpected exceptions into Error.FromException(ex) failures and logs them with a source-generated LoggerMessage. OperationCanceledException is always rethrown.
  • Both behaviors are declared where TResponse : IResultFactory<TResponse>. IResultFactory<TSelf> has a static abstract FromError method, so the behaviors create failures without reflection, and the Zenvoy source generator closes them at compile time only for requests whose response is Result/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>>.MapAsync on 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:

  1. Replace the package and the namespace (using Zenvoy.Results;).
  2. Turn error factories into static readonly Error fields (Error.NotFound("Code", "Message")).
  3. Replace Then with Map (value) or Bind (result), FailIf with Ensure (inverted predicate), IsError with IsFailure and FirstError with Error.
  4. Multiple errors: return a ValidationError (field errors) or use Result.CombineAll.
  5. Prefer static lambdas; the analyzer (ZRS003) points out the capturing ones.

Limitations

  • One Error per result. Multiple errors are represented by ValidationError (field errors) and AggregateError.
  • C# does not allow user-defined conversions from interfaces: when T is an interface, use Result.Success<T>(value) instead of the implicit conversion.
  • Result<T> is not a wire format: there is no System.Text.Json converter in v1. Convert results to DTOs or problem details at the API boundary.
  • Asynchronous delegates must return ValueTask/ValueTask<T> (method groups returning Task need 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 to PublicAPI.Unshipped.txt.
  • The ValueTask/Task async 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.cs files or run the tests with ZENVOY_RESULTS_ACCEPT_SNAPSHOTS=1.
  • Versions come from git tags via MinVer: tag v10.0.0 produces packages 10.0.0. The major version follows .NET and Zenvoy (10.x targets .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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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 88 11/9/2026