ResponseResultHandler 16.0.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package ResponseResultHandler --version 16.0.0
                    
NuGet\Install-Package ResponseResultHandler -Version 16.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="ResponseResultHandler" Version="16.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="ResponseResultHandler" Version="16.0.0" />
                    
Directory.Packages.props
<PackageReference Include="ResponseResultHandler" />
                    
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 ResponseResultHandler --version 16.0.0
                    
#r "nuget: ResponseResultHandler, 16.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 ResponseResultHandler@16.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=ResponseResultHandler&version=16.0.0
                    
Install as a Cake Addin
#tool nuget:?package=ResponseResultHandler&version=16.0.0
                    
Install as a Cake Tool

ResponseResultHandler

A Result-Pattern library for .NET (net8.0–net10.0). Wraps the outcome of an operation — success or failure, an HTTP-mappable status, a title/detail, and optional data — in an immutable object instead of throwing exceptions or returning bare booleans.

This repository is two NuGet packages sharing this README:

Package Project Purpose
ResponseResultHandler src/ResultHandler Core library — framework-agnostic
ResponseResultHandler.AspNetCore src/ResultHandler.AspNetCore Converts results to IActionResult / RFC 9457 Problem Details
ResponseResultHandler.FluentValidation src/ResultHandler.FluentValidation FluentValidation to field-keyed failures; request validation for Minimal APIs and MVC
dotnet add package ResponseResultHandler
dotnet add package ResponseResultHandler.AspNetCore   # optional: IActionResult / RFC 9457 adapter
dotnet add package ResponseResultHandler.FluentValidation   # optional: FluentValidation integration

See CHANGELOG.md for what changed between versions.

Everything below is demonstrated through one running example: a ProductService that looks up a product by id, and a ProductsController that exposes it over HTTP.

Contents: 1. Core contract · 2. ResultStatus · 3. Building results directly · 4. Result facade · 5. ASP.NET Core (IActionResult) · 6. Minimal APIs · 7. Functional composition · 8. Result.Combine · 9. Async composition · 10. Generic short-circuiting · 11. Errors by field · 12. Serialization · 13. Equality & debugging · 14. Validation with FluentValidation


1. The core contract

Every result implements IOperationResult (ResultHandler.Core.Abstractions):

Member Meaning
bool IsSuccessful did the operation succeed
ResultStatus Status outcome status — an enum, not HttpStatusCode, see §2
string Title short summary, e.g. "Not Found"
string? Detail optional extra context, e.g. "Product 42 does not exist."
IReadOnlyDictionary<string, IReadOnlyList<string>> Errors error messages by field; "" holds messages that belong to no field (§11)

IOperationResult<T> : IOperationResult adds T Data — guaranteed non-null when IsSuccessful is true (the compiler enforces this via [MemberNotNullWhen], so result.Data is safe to use right after an if (result.IsSuccessful) check without a null-forgiving operator).


2. ResultStatus

An enum covering every standard 1xx–5xx HTTP status (ResultHandler.Core.Enums), kept independent of System.Net.HttpStatusCode so the library has no hard dependency on ASP.NET Core. Convert either direction with the extension methods in ResultHandler.Mapping:

using ResultHandler.Core.Enums;
using ResultHandler.Mapping;

HttpStatusCode code = ResultStatus.NotFound.ToHttpStatusCode();   // 404
ResultStatus status = HttpStatusCode.Conflict.ToResultStatus();  // Conflict

3. Building results directly

SuccessResult / SuccessDataResult<T> and ErrorResult / ErrorDataResult<T> (ResultHandler.Implementations.Success / .Error) pin IsSuccessful for you:

using ResultHandler.Core.Enums;
using ResultHandler.Implementations.Error;
using ResultHandler.Implementations.Success;

public IOperationResult<ProductDto> GetById(int id)
{
    var product = _products.Find(id);

    if (product is null)
    {
        return new ErrorDataResult<ProductDto>(
            "Not found.", ResultStatus.NotFound, $"Product {id} does not exist.");
    }

    return new SuccessDataResult<ProductDto>(ToDto(product), "Product found.", ResultStatus.Ok);
}

Validation errors use the IReadOnlyList<string> errors overload instead of detail:

if (request.Name is { Length: 0 })
{
    return new ErrorResult("Validation failed.", ResultStatus.UnprocessableContent,
        new[] { "Name is required.", "Price must be greater than zero." });
}

OperationResult / OperationDataResult<T> (ResultHandler.Core.Base) are the base classes both of the above inherit from — construct them directly only for a custom result shape that isn't a plain success/error; SuccessResult/ErrorResult cover the normal cases.


ResultHandler.Facade.Result is a static class with one factory pair per ResultStatus (non-generic and <T>), named after the status, with sensible default titles baked in. It's what the example above looks like using it instead:

Every factory returns the common base type: OperationResult for the non-generic form and OperationDataResult<T> for <T> (since 13.0). Success and failure branches therefore share one type, so cond ? Result.Success(x) : Result.NotFound<T>(...) and lambdas that return both compile without casts or explicit type arguments. The runtime objects are still SuccessResult/ErrorDataResult<T> and so on, so is checks keep working.

using ResultHandler.Facade; // Result

public IOperationResult<ProductDto> GetById(int id)
{
    var product = _products.Find(id);

    return product is null
        ? Result.NotFound<ProductDto>($"Product {id} does not exist.")
        : Result.Success(ToDto(product), "Product found.");
}

public OperationResult? ValidateCreate(CreateProductRequest request)
{
    var errors = new List<string>();
    if (string.IsNullOrEmpty(request.Name)) errors.Add("Name is required.");
    if (request.Price <= 0) errors.Add("Price must be greater than zero.");

    return errors.Count > 0 ? Result.Invalid(errors.ToArray()) : null;
}

public OperationResult MoveResource(int id, string newLocation)
    => Result.MovedPermanently(newLocation); // 3xx redirects — location gets interpolated into the title

// Escape hatch for anything not covered by a named factory:
public OperationResult CustomFailure()
    => Result.Failure("Payment declined.", "The card was rejected by the issuer.", ResultStatus.PaymentRequired);

Location. A 201 should say where the new resource is, a 202 where to follow the work, and a redirect where to go. WithLocation(uri) attaches it, and the redirect factories (MovedPermanently, Found, SeeOther, TemporaryRedirect, PermanentRedirect) set it from their argument; ToResult/ToActionResult and the enveloped variants write it as the Location header. It is HTTP metadata, so it is not part of the serialized result:

return Result.Created(order.Id).WithLocation($"/api/v1/orders/{order.Id}");

There are no factories for 1xx statuses: they are interim responses and can't be the final response of a request, so mapping one to HTTP (built by hand, new SuccessResult(title, ResultStatus.Continue)) throws instead of leaving the client waiting.

string data needs the type argument. A single string is the title: Result.Success("Basket cleared.") is a success with that title and no data. When the data itself is a string, say so explicitly with Result.Success<string>("R-1"), or pass both, Result.Success("R-1", "Receipt found.") (the same applies to Created/Accepted).


5. ResultHandler.AspNetCore — converting to IActionResult

Add the ResponseResultHandler.AspNetCore package and call one extension method (ResultHandler.AspNetCore.Extensions) at the end of a controller action:

using ResultHandler.AspNetCore.Extensions;

[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
    private readonly ProductService _products;

    [HttpGet("{id}")]
    public IActionResult GetById(int id)
        => _products.GetById(id).ToActionResult(HttpContext);

    [HttpPost]
    public IActionResult Create(CreateProductRequest request)
    {
        var validation = _products.ValidateCreate(request);
        if (validation is not null)
        {
            return validation.ToActionResult(); // 422 Problem Details with the two validation errors
        }

        return Result.Created(_products.Create(request)).ToActionResult();
    }
}
  • ToActionResult() — for non-generic IOperationResult (commands with no data to return). Success is always bodyless — a bare status code matching the result's actual Status (201 for Created, 204 for NoContent, etc.), never a payload; failure returns an RFC 9457 ProblemDetails body.
  • ToActionResult<T>() — for IOperationResult<T>. Success returns the raw T payload with the result's actual status code (201 { ... } for Created, 202 { ... } for Accepted, etc. — not hardcoded to 200), or a bodyless status for NoContent/3xx; failure is the same ProblemDetails body.
  • ToEnvelopedActionResult() — success returns the whole result object (status/title/data) as the body instead of just the payload, with the same status-code-preserving behavior — useful when clients want metadata alongside the data.
  • ToProblemDetails(HttpContext? httpContext = null): builds the ProblemDetails yourself (for example in exception middleware). Pass HttpContext to also get instance (the request path, RFC 9457 §3.1.4) and traceId. A result with field errors or error messages becomes an HttpValidationProblemDetails (§11).
  • Your CustomizeProblemDetails (AddProblemDetails() or AddResultProblemDetails(), below) applies to failures as well. Minimal API results are written through IProblemDetailsService; MVC results apply the customization and then use normal content negotiation, like MVC's own ValidationProblem(), because MVC's Problem Details writer drops the validation errors. instance and traceId are added when the response is written, so the httpContext parameter is optional.
  • Every 4xx/5xx ResultStatus maps ProblemDetails.Type to the actual RFC section that defines it (RFC 9110, RFC 6585, RFC 4918, RFC 7725, RFC 8470); 1xx/2xx/3xx use about:blank per RFC 9457 §4.2.1, since those aren't "problems".
  • These IActionResult-returning methods are for MVC controllers. In a Minimal API endpoint delegate, use the IResult-returning siblings from §6 instead — returning IActionResult from a delegate triggers analyzer warning ASP0004 and hides the response shape from OpenAPI/Swagger generation at compile time.

A failed GetById(999) call above produces:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
  "title": "Not Found",
  "status": 404,
  "detail": "Product 999 does not exist.",
  "instance": "/api/products/999",
  "traceId": "00-3f1c...-01"
}

traceId is Activity.Current?.Id ?? HttpContext.TraceIdentifier, the same value ASP.NET Core uses, so a client can quote it and you can find the request in your logs.

Error codes. Two failures can share a status (409 for "slug taken" and "email taken") and differ only in detail, which is text for people. WithCode (ResultHandler.Facade) attaches a stable, machine-readable code that clients can switch on; it is written as the "code" extension and carried through Map/Bind/ToErrorDataResult:

return Result.Conflict("That slug is already taken.").WithCode("Tenant.SlugTaken");
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
  "title": "Conflict",
  "status": 409,
  "detail": "That slug is already taken.",
  "code": "Tenant.SlugTaken",
  "traceId": "00-3f1c...-01"
}

Framework errors in the same shape. Some failures never reach your code: 401/403 from authentication, 404 for unknown routes, 405, 415, 429 from rate limiting, unhandled exceptions. AddResultProblemDetails() registers ASP.NET Core's Problem Details services with a customization that gives those responses the same type URIs, title, instance and traceId as failed results. Your own CustomizeProblemDetails still runs, after it:

builder.Services.AddResultProblemDetails(options =>
    options.CustomizeProblemDetails = context => context.ProblemDetails.Extensions["tenant"] = "acme");

var app = builder.Build();
app.UseStatusCodePages(); // bodyless 4xx/5xx responses get a Problem Details body

Exceptions in the same shape. AddResultExceptionHandler() registers an IExceptionHandler for app.UseExceptionHandler() (it also calls AddResultProblemDetails()). Map the exceptions your code throws to failed results; the closest mapped base type wins, so a Map<Exception> catch-all and a more specific mapping can coexist. Without a mapping:

  • BadHttpRequestException keeps its status code and names the field it could not read (below);
  • HttpRequestException is a 502 and a timeout (TimeoutException, or an HttpClient timeout) a 504, because the failure is in a service this one depends on;
  • everything else is a 500.

Messages of unmapped exceptions reach the client only in Development (ExposeExceptionMessages). ResultExceptionHandlerOptions.Resolve(exception, httpContext) returns the result an exception maps to, for tests or a logging handler. Logging stays with ASP.NET Core's exception handler middleware: on .NET 10, which stops logging exceptions an IExceptionHandler handles, server errors (5xx) are logged again and client errors (4xx) are not; on .NET 8 and 9 the middleware logs every exception:

builder.Services.AddResultExceptionHandler(options => options
    .Map<DbUpdateConcurrencyException>(_ => Result.Conflict("This record was changed by someone else.").WithCode("Concurrency"))
    .Map<KeyNotFoundException>(exception => Result.NotFound(exception.Message)));

var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();

Requests that can't be read. When ASP.NET Core can't bind a request, the 400 names the field, keyed like FluentValidation property names, with fixed messages that never echo .NET type names:

Request errors
GET /orders/abc for {id} bound to Guid id "Id": ["The value is missing or has an invalid format."]
GET /orders?page=abc, or no page, for int page "Page": [...] / "Page": ["This field is required."]
{ "serviceId": "abc" } for Guid ServiceId "ServiceId": ["The value is missing or has an invalid format."]
{ "lines": [{ "quantity": "two" }] } "Lines[0].Quantity": [...]
{} for a type with required members one "This field is required." per missing member
malformed JSON / no body "": ["The request body is not valid JSON."] / "": ["A request body is required."]

Minimal APIs only throw for these in Development; to get the same response in every environment, set builder.Services.Configure<RouteHandlerOptions>(options => options.ThrowOnBadRequest = true). Route parameters should not use type constraints for this ({id:guid}): a constraint that doesn't match makes the route not match, so the client gets a 404 instead of this 400. Keep constraints for telling similar routes apart.

For MVC, AddControllers().AddResultModelStateResponses() gives [ApiController]'s automatic model-state response the same keys and messages: 400 when the request could not be read, 422 when it failed validation attributes.


6. Minimal APIs

Minimal API endpoint delegates can return IActionResult (via an MVC compatibility shim), but don't — it triggers analyzer warning ASP0004, and it's opaque to the endpoint metadata pipeline that Swashbuckle/Microsoft.AspNetCore.OpenApi use to infer response types, so your OpenAPI document ends up missing or wrong for those endpoints.

Use the IResult-returning siblings instead — same shapes, same status-code-preserving behavior, but native to Minimal APIs and fully visible to OpenAPI generation:

  • ToResult(HttpContext? httpContext = null) — IOperationResult → IResult. Success returns a bodyless status (204 for NoContent, 304 for NotModified, plain status code for 3xx/Ok); failure returns an RFC 9457 ProblemDetails JSON body via ToProblemResult.
  • ToResult<T>(HttpContext? httpContext = null) — IOperationResult<T> → IResult. Success returns the raw payload as JSON with the result's actual status code (201 for Created, 202 for Accepted, etc.); failure is the same ProblemDetails JSON body.
  • ToEnvelopedResult(HttpContext? httpContext = null): success returns an envelope, { "resultData", "isSuccessful", "statusCode", "statusMessage" } plus detail/code when set, instead of just the payload (ResultEnvelope/ResultEnvelope<T>, ResultHandler.AspNetCore.Envelopes).
  • ToResultAsync() / ToEnvelopedResultAsync() (and ToActionResultAsync() / ToEnvelopedActionResultAsync() for MVC): the same mappings for a Task of a result, so an endpoint can return a service call directly: (int id, ProductService products, CancellationToken ct) => products.GetByIdAsync(id, ct).ToResultAsync().
  • ToProblemResult(HttpContext? httpContext = null) — maps a failed result straight to an IResult carrying RFC 9457 ProblemDetails JSON; the same building block ToResult/ToResult<T>/ ToEnvelopedResult use internally for their failure branch.
using ResultHandler.AspNetCore.Extensions;
using ResultHandler.Facade; // Result

var app = WebApplication.Create(args);
app.Services.GetRequiredService<IServiceCollection>(); // ...DI setup omitted

app.MapGet("/api/products/{id:int}", (int id, ProductService products, HttpContext httpContext)
    => products.GetById(id).ToResult(httpContext));

app.MapPost("/api/products", (CreateProductRequest request, ProductService products) =>
{
    var validation = products.ValidateCreate(request);
    return validation is not null
        ? validation.ToProblemResult() // 422 Problem Details with the two validation errors
        : Result.Created(products.Create(request)).ToResult();
});

app.Run();

Documenting the error responses. A result-returning endpoint can fail with Problem Details, but OpenAPI generators only see what the endpoint declares. ProducesResultProblems() declares the common failures for a whole route group in one call: 500 always, 401/403 when the endpoint requires authorization, and 400/422 when it reads a request body. ProducesResultProblem(...) adds endpoint-specific statuses such as 404 or 409. Status codes an endpoint already declares are left alone, and it works with both Microsoft.AspNetCore.OpenApi and Swashbuckle (it only adds standard endpoint metadata, no extra package):

var api = app.MapGroup("/api").ProducesResultProblems();

api.MapGet("/products/{id:int}", (int id, ProductService products) => products.GetById(id).ToResult())
    .ProducesResultProblem(StatusCodes.Status404NotFound);

The responses are described with two schemas, so Scalar, Swagger UI and generated clients show the real body: ResultProblemDetails (RFC 9457 members plus code and traceId) and, for 400/422, ResultValidationProblemDetails, which adds the errors map (ResultHandler.AspNetCore.OpenApi).

Documenting the success body. ToEnvelopedResult() writes an envelope on success, but an endpoint returning IResult gives OpenAPI no type to describe it. ProducesEnveloped<T>() declares it as ResultEnvelope<T> (or ResultEnvelope with ProducesEnveloped() when there is no data), the same type the body is written as, so docs and generated clients see resultData with its real type and statusCode as an integer:

api.MapGet("/products/{id:int}", (int id, ProductService products) => products.GetById(id).ToEnvelopedResult())
    .ProducesEnveloped<ProductResponse>();

api.MapPost("/products", (CreateProduct command, ProductService products) => products.Create(command).ToEnvelopedResult())
    .ProducesEnveloped<Guid>(StatusCodes.Status201Created);

7. Functional composition

ResultHandler.Functional adds chaining helpers so callers don't have to repeat if (!result.IsSuccessful) return ... at every step:

using ResultHandler.Functional;

string message = _products.GetById(id)
    .Map(p => p.Name.ToUpperInvariant())
    .Match(
        onSuccess: name => $"Found: {name}",
        onFailure: failure => $"Error: {failure.Title}");

_products.GetById(id)
    .OnSuccess(product => _logger.LogInformation("Fetched {Name}", product.Name)) // typed: product is ProductDto
    .OnFailure(failure => _logger.LogWarning("Lookup failed: {Title}", failure.Title));

IOperationResult<OrderDto> order = _products.GetById(id)
    .Bind(product => _orders.CreateDraftOrder(product)); // chains into another IOperationResult<T>-returning call

IOperationResult<ProductDto> validated = _products.GetById(id)
    .Ensure(product => product.Stock > 0, "Product is out of stock."); // guard clause: 422 if the predicate fails

Map/Bind short-circuit automatically: if the source result failed, the mapper/binder never runs and the failure (title/status/detail/errors) is carried over into the new result type. Ensure turns a still-successful result into a failure when a business-rule predicate rejects the data — the shortcut overload above defaults to "Validation Failed" / 422 Unprocessable Content and puts your message in Detail (Errors stays empty, unlike Result.Invalid, which fills Errors; Result.Combine falls back to Detail, so the message is never lost when you aggregate); pass your own (title, detail, status) when a different outcome fits better:

_products.GetById(id)
    .Ensure(product => product.OwnerId == currentUserId, "Forbidden.", "You do not own this product.", ResultStatus.Forbidden);

8. Combining independent checks — Result.Combine

Ensure (and Bind/Map) stop at the first failure — the rest of the chain never runs. That's the right behavior for a pipeline of dependent steps, but wrong for independent checks where you want to report every problem at once (e.g. every invalid field on a form). Result.Combine(...) (ResultHandler.Facade) covers that case instead — it runs every result to completion and merges their outcomes:

using ResultHandler.Facade; // Result

IOperationResult? ValidateCreate(CreateProductRequest request)
{
    var nameCheck = string.IsNullOrEmpty(request.Name)
        ? Result.Invalid("Name is required.")
        : Result.Success();

    var priceCheck = request.Price <= 0
        ? Result.Invalid("Price must be greater than zero.")
        : Result.Success();

    var combined = Result.Combine(nameCheck, priceCheck);
    return combined.IsSuccessful ? null : combined;
}

If both checks fail, combined.Errors[""] contains both messages, ["Name is required.", "Price must be greater than zero."], not just the first one. Every failed result's Errors are merged by field, and a failure without errors adds its Detail (or Title) under ""; if every result succeeds, Combine returns Result.Success(). Combining a "Name" failure and a "Price" failure gives you both keys back in one result.

When the independent checks each carry data you actually need afterward, the 2–4 arity generic overloads combine both the outcome and the payloads into a named tuple:

IOperationResult<(CustomerDto Customer, ShippingAddressDto Address)> ValidateOrder(int customerId, int addressId)
    => Result.Combine(_customers.GetById(customerId), _addresses.GetById(addressId));

If both succeed, Data is (CustomerDto Customer, ShippingAddressDto Address); if either (or both) fail, you get the same aggregated Result.Invalid(...) as above, re-projected into IOperationResult<(...)>.


9. Async composition

Real handlers usually chain calls that are themselves async — a repository hit, an email send, a downstream API call. ResultHandler.Functional mirrors every method from §7 with an ...Async counterpart so those chains read the same way, without an await breaking up the fluent chain at every step:

using ResultHandler.Functional;

public Task<IOperationResult<ProductDto>> ActivateAsync(int id)
    => _products.GetByIdAsync(id)                       // Task<IOperationResult<Product>>
        .BindAsync(product => ValidateCanActivateAsync(product)) // async business rule
        .MapAsync(product => ToDto(product))                     // sync mapper
        .OnSuccessAsync(dto => _email.SendActivationEmailAsync(dto.OwnerEmail)); // async side effect

If GetByIdAsync returns NotFound, or ValidateCanActivateAsync fails, the chain short-circuits immediately — MapAsync and OnSuccessAsync never run, and the original failure (title/status/detail) is what the caller gets back, exactly like the sync Map/Bind in §7.

Each method comes in three shapes so you can start the chain from either a Task<IOperationResult<T>> or a plain IOperationResult<T>, and pass either a sync or an async delegate — mix and match freely in the same chain:

Shape Example
Task<IOperationResult<T>> source, sync delegate .MapAsync(p => p.Name)
Task<IOperationResult<T>> source, async delegate .BindAsync(p => _orders.CreateDraftOrderAsync(p))
IOperationResult<T> source, async delegate existingResult.OnSuccessAsync(p => _email.SendAsync(p))

Concrete result tasks (MediatR Send). Task<T> is invariant, so a Task<OperationDataResult<T>> (what a MediatR handler returns) is not a Task<IOperationResult<T>>, and a Task<OperationResult> is not a Task<IOperationResult>. Every async operator has extra overloads for these concrete sources (MapAsync/BindAsync/EnsureAsync for data results; MatchAsync/OnSuccessAsync/OnFailureAsync for both), so you can chain Send calls without casting:

app.MapPut("/me/profile", async (UpdateProfileCommand command, ISender sender, HttpContext httpContext) =>
    (await sender.Send(new GetCurrentUserQuery())                           // Task<OperationDataResult<UserDto>>
        .BindAsync(user => sender.Send(command with { UserId = user.Id }))  // Task<OperationDataResult<ProfileDto>>
        .MapAsync(profile => profile.DisplayName))
    .ToEnvelopedResult(httpContext));

The result type follows the source: a Task<OperationDataResult<T>> chained into another concrete task (or through MapAsync) stays OperationDataResult<TOut>, so a handler can return it directly. Once an interface (IOperationResult<TOut>) enters the chain, it stays an interface.

Binding into a command that returns no data. When the next step returns a non-generic OperationResult (a delete, a status change, a password change), Bind/BindAsync return a non-generic result as well, so the same one-line chain works:

app.MapDelete("/addresses/{id:guid}", async (Guid id, ISender sender, HttpContext httpContext) =>
    (await sender.Send(new GetCurrentUserQuery())                                  // Task<OperationDataResult<UserDto>>
        .BindAsync(user => sender.Send(new DeleteUserAddressCommand(id, user.Id)))) // Task<OperationResult>
    .ToEnvelopedResult(httpContext));

A failed source is re-projected into an ErrorResult with its title, status, detail, errors and field errors intact, exactly like the data-returning Bind.

Async lambdas that return both a success and a failure. Facade factories return the common base type (§4), so the lambda's return type is inferred as Task<OperationDataResult<T>> and binds without type arguments:

.BindAsync(async user =>
{
    if (!user.IsActive)
    {
        return Result.Forbidden<OrderDto>("Inactive user.");
    }

    return Result.Success(await _orders.CreateAsync(user.Id));
})

Only a lambda that returns nothing but hand-built subclasses of different kinds (new SuccessDataResult<T>(...) in one branch, new ErrorDataResult<T>(...) in another) has no common data type to infer. It still compiles, but through the no-data overload above, so the chain's static type becomes IOperationResult and .Data is no longer available. Use the facade for at least one branch, or name the types once with .BindAsync<UserDto, OrderDto>(...).

A lambda that only ever returns success is a mapping, not a bind: use MapAsync with an async mapper.

MatchAsync, OnSuccessAsync, OnFailureAsync, MapAsync, BindAsync, and EnsureAsync all follow this pattern, for both IOperationResult and IOperationResult<T> (EnsureAsync only exists for IOperationResult<T> — same reasoning as Ensure in §7, there's no data to check on the non-generic form). The controller/endpoint at the edge (§5/§6) doesn't change — just await the final result and call .ToActionResult() / .ToResult() as usual:

[HttpPost("{id}/activate")]
public async Task<IActionResult> Activate(int id)
    => (await _products.ActivateAsync(id)).ToActionResult();

10. Generic short-circuiting — IResultFailureFactory<TSelf> / ResultFailureFactory

Everything above assumes the calling code knows the concrete result type. Generic infrastructure often doesn't — a MediatR IPipelineBehavior<TRequest, TResponse>, a gRPC interceptor, any short-circuiting middleware only has TResponse as a type parameter, and you can't new TResponse(...) without knowing what it actually is. Without this, the usual fix is throwing an exception just to unwind the pipeline.

IResultFailureFactory<TSelf> (ResultHandler.Core.Abstractions) solves this with a C# 11 static-abstract-interface CRTP: it lets TSelf build its own failure instance. OperationResult and OperationDataResult<T> already implement it, so any result type built on top of this library (concrete or still generic) gets it for free:

public interface IResultFailureFactory<TSelf> where TSelf : IOperationResult
{
    static abstract TSelf Failure(IReadOnlyList<string> errors);               // validation message list
    static abstract TSelf Failure(string title, string detail, ResultStatus status); // everything else
}

ResultFailureFactory (ResultHandler.Functional) layers the same named, per-status vocabulary as Result on top of these two primitives — BadRequest, NotFound, Unauthorized, Forbidden, and every other 4xx/5xx — generically, for any TSelf. It delegates to the matching Result.XXX(detail) method internally, so titles and default messages have exactly one source of truth (Result); nothing is duplicated.

A MediatR pipeline behavior that stops throwing and starts returning:

using ResultHandler.Core.Abstractions;
using ResultHandler.Functional;

public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
    where TResponse : IOperationResult, IResultFailureFactory<TResponse>
{
    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
    {
        var errors = validators
            .Select(v => v.Validate(request))
            .SelectMany(r => r.Errors)
            .Select(f => f.ErrorMessage)
            .ToArray();

        if (errors.Length > 0)
        {
            return TResponse.Failure(errors); // short-circuits the pipeline — no throw, no exception cost
        }

        return await next(ct);
    }
}

public class AuthorizationBehavior<TRequest, TResponse>(ICurrentUser user)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>, IRequireRole
    where TResponse : IOperationResult, IResultFailureFactory<TResponse>
{
    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
    {
        if (!user.IsAuthenticated)
        {
            return ResultFailureFactory.Unauthorized<TResponse>();
        }

        if (!user.IsInRole(request.RequiredRole))
        {
            return ResultFailureFactory.Forbidden<TResponse>();
        }

        return await next(ct);
    }
}

For this to type-check, the command/query itself declares its response as an OperationDataResult<T> (or any other IResultFailureFactory implementer) instead of a bare DTO:

public record CreateProductCommand(string Name, decimal Price) : IRequest<OperationDataResult<ProductDto>>;

public class CreateProductHandler(IProductRepository repository)
    : IRequestHandler<CreateProductCommand, OperationDataResult<ProductDto>>
{
    public async Task<OperationDataResult<ProductDto>> Handle(CreateProductCommand request, CancellationToken ct)
    {
        if (await repository.ExistsByName(request.Name))
        {
            // built by a business-rule check that only knows IOperationResult, not the final DTO shape:
            var duplicate = ResultFailureFactory.Conflict<OperationResult>($"A product named '{request.Name}' already exists.");
            return duplicate.ToErrorDataResult<ProductDto>(); // re-projects into the handler's actual return type
        }

        var product = await repository.Add(request.Name, request.Price);
        return Result.Success(ToDto(product));
    }
}

ToErrorDataResult<T>() (ResultHandler.Functional) is the companion piece: it re-projects an existing failed IOperationResult (title/status/detail/errors already decided elsewhere, e.g. in a business-rule method) into the ErrorDataResult<T> shape a handler must return — a conversion, not a new factory call, which is why it reads as failure.ToErrorDataResult<T>() rather than Result.ToErrorDataResult<T>(failure).

The controller/endpoint at the edge doesn't change at all — result.ToActionResult() still works, because OperationDataResult<T> still implements IOperationResult<T>:

[HttpPost]
public async Task<IActionResult> Create(CreateProductCommand command)
    => (await mediator.Send(command)).ToActionResult();

11. Errors by field: Errors / IResultFailureFactory<TSelf>

A form UI needs to know which input each message belongs to, so Errors is a map from property name to that property's messages. Messages that belong to no field (Result.Invalid("..."), Failure(messages)) are kept under the "" key, the key ASP.NET Core uses for model-level errors:

IReadOnlyDictionary<string, IReadOnlyList<string>> Errors { get; }

Build one with OperationResult.Failure(errors) / OperationDataResult<T>.Failure(errors), or the generic IResultFailureFactory<TSelf>.Failure(errors) (implemented by the same types, for the same CRTP reason as §10):

using ResultHandler.Core.Base;

var errors = new Dictionary<string, IReadOnlyList<string>>
{
    ["Name"] = ["Name is required."],
    ["Price"] = ["Price must be greater than zero."],
};

OperationResult validation = OperationResult.Failure(errors);

Title/Status default to "Validation Failed" / 422 Unprocessable Content, the same as the Failure(messages) overload. Fields with no messages are dropped. When you need every message as one list, result.Errors.Values.SelectMany(messages => messages) gives it.

A generic pipeline behavior (mirrors §10's ValidationBehavior, keyed per field):

public class FieldValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
    where TResponse : IOperationResult, IResultFailureFactory<TResponse>
{
    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
    {
        var errors = validators
            .Select(v => v.Validate(request))
            .SelectMany(r => r.Errors)
            .GroupBy(f => f.PropertyName)
            .ToDictionary(g => g.Key, IReadOnlyList<string> (g) => [.. g.Select(f => f.ErrorMessage)]);

        return errors.Count > 0 ? TResponse.Failure(errors) : await next(ct);
    }
}

ToErrorDataResult<T>(), Map, and Bind (§7/§10) carry Errors through when re-projecting a failure into a different result type, together with Title/Status/Detail/Code; Result.Combine (§8) merges them by field.

ResultHandler.AspNetCore's ToProblemDetails() (§5/§6) writes a failure with errors as an HttpValidationProblemDetails, the same shape ASP.NET Core uses for its own validation responses, with "errors" exactly as the result holds it:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.21",
  "title": "Validation Failed",
  "status": 422,
  "errors": {
    "Name": ["Name is required."],
    "Price": ["Price must be greater than zero."]
  },
  "traceId": "00-3f1c...-01"
}

12. Serialization

System.Text.Json output uses fixed property names regardless of your JsonSerializerOptions naming policy, and ResultStatus always serializes as its numeric HTTP code:

JsonSerializer.Serialize(Result.NotFound<ProductDto>("Product 42 does not exist."));
{
  "resultData": null,
  "isSuccessful": false,
  "statusCode": 404,
  "statusMessage": "Not Found",
  "detail": "Product 42 does not exist."
}

detail and code (§5) appear only when set, and errors only when there are any. errors is the same map as in validation Problem Details and in memory: messages by field, and messages that belong to no field under "". So errors means one thing everywhere:

{
  "isSuccessful": false,
  "statusCode": 422,
  "statusMessage": "Validation Failed",
  "errors": { "Name": ["Name is required."], "": ["The catalog is closed."] }
}

This is the shape of the result object itself, for example when you cache or queue results; deserialize it as OperationResult or OperationDataResult<T>. Unknown properties are ignored, so a success cached before v16 still reads. Over HTTP, the success envelope is the smaller ResultEnvelope (§6) and failures are Problem Details (§5/§6).


13. Equality & debugging

OperationResult/OperationDataResult<T> override Equals/GetHashCode (structural, by value) and ToString():

Result.NotFound("x") == Result.NotFound("x"); // false (reference types) — use .Equals()
Result.NotFound("x").Equals(Result.NotFound("x")); // true
Result.NotFound("x").ToString(); // "NotFound (404): Not Found"

14. Validation with FluentValidation

ResponseResultHandler.FluentValidation connects FluentValidation to results without MediatR. In a service, turn a ValidationResult into a result:

public async Task<OperationDataResult<ReceiptResponse>> CreateAsync(CreateReceiptRequest request, CancellationToken ct)
{
    var validation = await validator.ValidateAsync(request, ct);
    if (!validation.IsValid)
    {
        return validation.ToFailure<OperationDataResult<ReceiptResponse>>(); // 422, errors keyed by property
    }

    // ...
}

ToFieldErrors() gives the map ("Address.City", "Items[0].Name", "" for rules without a property), ToOperationResult() a success or a 422 failure, and ToFailure<TResult>() the failure as any result type.

To validate requests before the handler runs, register validators as usual and turn validation on for an endpoint, a route group or all MVC actions. Every parameter whose type has an IValidator<T> is validated (all validators for that type, async rules included); an invalid request gets the same 422 validation Problem Details as a failed result and never reaches the handler. Endpoints without validated parameters get no filter:

builder.Services.AddValidatorsFromAssemblyContaining<CreateReceiptRequestValidator>();

// Minimal APIs
var api = app.MapGroup("/api").WithResultValidation().ProducesResultProblems();
api.MapPost("/receipts", (CreateReceiptRequest request, ReceiptService receipts, CancellationToken ct)
    => receipts.CreateAsync(request, ct).ToResultAsync());

// MVC
builder.Services.AddControllers().AddResultValidation();
Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  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.
  • net10.0

    • No dependencies.
  • net8.0

    • No dependencies.
  • net9.0

    • No dependencies.

NuGet packages (3)

Showing the top 3 NuGet packages that depend on ResponseResultHandler:

Package Downloads
ResponseResultHandler.AspNetCore

ASP.NET Core integration for ResponseResultHandler: turns results into Minimal API and MVC responses with RFC 9457 Problem Details, handles exceptions and unreadable requests in the same shape, and documents it all for OpenAPI.

Core.CrossCuttingConcernLayer

Package Description

Core.ApplicationLayer

Package Description

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
16.2.0 0 10/30/2026
16.2.0-beta.68 0 10/30/2026
16.1.1 0 10/30/2026
16.1.1-beta.66 0 10/30/2026
16.1.1-beta.65 0 10/30/2026
16.1.1-beta.63 0 10/30/2026
16.1.0 0 10/30/2026
16.1.0-beta.61 0 10/30/2026
16.0.1 0 10/30/2026
16.0.1-beta.59 0 10/30/2026
16.0.0 35 10/30/2026
16.0.0-beta.57 32 10/30/2026
15.0.1 35 10/30/2026
15.0.1-beta.55 27 10/30/2026
15.0.0 50 10/30/2026
15.0.0-beta.53 33 10/30/2026
14.0.1 42 10/30/2026
14.0.1-beta.51 31 10/30/2026
14.0.0 40 10/30/2026
14.0.0-beta.49 30 10/30/2026
Loading failed

v16.0 - Breaking (serialization): a serialized result writes "errors" as the field-to-messages map Problem Details use ("" for messages without a field) and no longer writes "fieldErrors". Breaking (API): IOperationResult.Errors is the field-to-messages map ("" for messages without a field), replacing the flat Errors list, FieldErrors, IHasFieldErrors and GetFieldErrors(); the 1xx factories and UseProxy are removed. New: IOperationResult.Location, WithLocation(uri); redirect factories set Location. Versions move together; see CHANGELOG.md.

v15.0 - No changes in this package; ResponseResultHandler.AspNetCore adds an exception handler, async endpoint adapters and a smaller success envelope, and ResponseResultHandler.FluentValidation is new. Versions move together; see CHANGELOG.md.

v14.0.1 - No changes in this package; ResponseResultHandler.AspNetCore fixes MVC validation failures losing "errors". Versions move together.

v14.0 - Breaking: IOperationResult.Code added. New WithCode(code) attaches a machine-readable error code, serialized as "code" and carried through Map/Bind/ToErrorDataResult. Versions move together; see CHANGELOG.md.

v13.3 - No changes in this package; ResponseResultHandler.AspNetCore adds ProducesEnveloped() for OpenAPI. Versions move together.

v13.2 - No changes in this package; ResponseResultHandler.AspNetCore adds ProducesResultProblems() for OpenAPI. Versions move together.

v13.1 - Bind into commands that return no data:

Added:
- Bind/BindAsync overloads whose binder returns a result with no data (IOperationResult, or Task<OperationResult> such as a MediatR command Send), so a data result chains into a command in one expression. Failures keep title, status, detail, errors and field errors. Existing overloads are unchanged.

See CHANGELOG.md for full details.

v13.0 (breaking) - Result facade returns the common base types, plus async overloads for MediatR-style concrete results:

Changed (breaking):
- Every Result facade factory now returns OperationResult (was SuccessResult/ErrorResult) or OperationDataResult<T> (was SuccessDataResult<T>/ErrorDataResult<T>). Success and failure branches share one static type, so `cond ? Result.Success(x) : Result.NotFound<T>(...)` and lambdas returning both compile without casts or explicit type arguments. Runtime types are unchanged.
- Result.Created/Result.Accepted (and their <T> forms) are explicit overload pairs instead of an optional title parameter; existing calls compile unchanged.
- Binary breaking: assemblies compiled against 12.x must be recompiled.

Added:
- MapAsync/BindAsync/MatchAsync/OnSuccessAsync/OnFailureAsync/EnsureAsync overloads for Task<OperationDataResult<T>> and Task<OperationResult> sources (for example a MediatR Send), and BindAsync binders returning Task<OperationDataResult<TOut>>.

Migration: change variables, return types or delegates declared as a concrete subclass (ErrorResult e = Result.Conflict(...), Func<string, ErrorResult>) to OperationResult/OperationDataResult<T> or use var. See CHANGELOG.md for full details.

v12.1 - Per-field validation errors, plus FieldErrors bug fixes:

Added:
- Per-field validation errors: IHasFieldErrors, IResultFailureFactory<TSelf>.Failure(fieldErrors), OperationResult.Failure(fieldErrors) (see README "Per-field validation errors"). Breaking for any external IResultFailureFactory<TSelf> implementer - see CHANGELOG.md.

Fixed:
- Map/Bind/ToErrorDataResult() dropped FieldErrors when re-projecting a failure into a different result type - now preserved, along with the original Errors list.
- Result.Combine(...) dropped FieldErrors when merging failures - field errors from every combined failure are now merged by key.
- Failure(fieldErrors) threw if the dictionary had a null value - that field's messages are skipped instead.
- GetHashCode() was order-sensitive for FieldErrors while Equals() wasn't, so equal-content dictionaries with a different key order could hash differently.
- OperationResult/OperationDataResult<T>'s errors+fieldErrors constructor is now public, so Native AOT source-generated JSON deserializers can call it.

Removed (breaking):
- net7.0 support (EOL) - net8.0/net9.0/net10.0 only; upgrade your target framework before taking this version.

See CHANGELOG.md for full details.

v12 (breaking): Removed all members previously marked [Obsolete] — StatusMessage, StatusCode, ResultData, and every HttpStatusCode-based constructor across OperationResult/OperationDataResult/SuccessResult/SuccessDataResult/ErrorResult/ErrorDataResult. Use Title/Status/Data and the ResultStatus-based constructors instead (see README "Migrating to v12"). Also includes the previously-unreleased v11.1 additions: Result.Combine, Ensure/EnsureAsync guard clauses, and Task-aware async composition helpers (MatchAsync/OnSuccessAsync/OnFailureAsync/MapAsync/BindAsync). See CHANGELOG.md for details.