ResponseResultHandler 16.0.0
See the version list below for details.
dotnet add package ResponseResultHandler --version 16.0.0
NuGet\Install-Package ResponseResultHandler -Version 16.0.0
<PackageReference Include="ResponseResultHandler" Version="16.0.0" />
<PackageVersion Include="ResponseResultHandler" Version="16.0.0" />
<PackageReference Include="ResponseResultHandler" />
paket add ResponseResultHandler --version 16.0.0
#r "nuget: ResponseResultHandler, 16.0.0"
#:package ResponseResultHandler@16.0.0
#addin nuget:?package=ResponseResultHandler&version=16.0.0
#tool nuget:?package=ResponseResultHandler&version=16.0.0
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.
4. The Result facade — the recommended way
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-genericIOperationResult(commands with no data to return). Success is always bodyless — a bare status code matching the result's actualStatus(201forCreated,204forNoContent, etc.), never a payload; failure returns an RFC 9457ProblemDetailsbody.ToActionResult<T>()— forIOperationResult<T>. Success returns the rawTpayload with the result's actual status code (201 { ... }forCreated,202 { ... }forAccepted, etc. — not hardcoded to200), or a bodyless status forNoContent/3xx; failure is the sameProblemDetailsbody.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 theProblemDetailsyourself (for example in exception middleware). PassHttpContextto also getinstance(the request path, RFC 9457 §3.1.4) andtraceId. A result with field errors or error messages becomes anHttpValidationProblemDetails(§11).- Your
CustomizeProblemDetails(AddProblemDetails()orAddResultProblemDetails(), below) applies to failures as well. Minimal API results are written throughIProblemDetailsService; MVC results apply the customization and then use normal content negotiation, like MVC's ownValidationProblem(), because MVC's Problem Details writer drops the validationerrors.instanceandtraceIdare added when the response is written, so thehttpContextparameter is optional. - Every 4xx/5xx
ResultStatusmapsProblemDetails.Typeto the actual RFC section that defines it (RFC 9110, RFC 6585, RFC 4918, RFC 7725, RFC 8470); 1xx/2xx/3xx useabout:blankper 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 theIResult-returning siblings from §6 instead — returningIActionResultfrom a delegate triggers analyzer warningASP0004and 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:
BadHttpRequestExceptionkeeps its status code and names the field it could not read (below);HttpRequestExceptionis a502and a timeout (TimeoutException, or anHttpClienttimeout) a504, 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 (204forNoContent,304forNotModified, plain status code for 3xx/Ok); failure returns an RFC 9457ProblemDetailsJSON body viaToProblemResult.ToResult<T>(HttpContext? httpContext = null)—IOperationResult<T>→IResult. Success returns the raw payload as JSON with the result's actual status code (201forCreated,202forAccepted, etc.); failure is the sameProblemDetailsJSON body.ToEnvelopedResult(HttpContext? httpContext = null): success returns an envelope,{ "resultData", "isSuccessful", "statusCode", "statusMessage" }plusdetail/codewhen set, instead of just the payload (ResultEnvelope/ResultEnvelope<T>,ResultHandler.AspNetCore.Envelopes).ToResultAsync()/ToEnvelopedResultAsync()(andToActionResultAsync()/ToEnvelopedActionResultAsync()for MVC): the same mappings for aTaskof 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 anIResultcarrying RFC 9457ProblemDetailsJSON; the same building blockToResult/ToResult<T>/ToEnvelopedResultuse 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 | Versions 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. |
-
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 |
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.