Skip to content

ASP.NET Core Integration

Preview Results Reviewed 2026-09-30 purview-dev/results Star on GitHub 1 aspnetcore c-sharp csharp discriminated-unions dotnet error-handling minimal-apis nuget problem-details result-type roslyn source-generator union-types zodsharp

Purview.Results.AspNetCore maps a Result<TValue, TError> onto an ASP.NET Core response, so an endpoint can return a result and let the host decide what each error case looks like on the wire.

Terminal window
dotnet add package Purview.Results.AspNetCore
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddResultsHttp(options => options
.Map<TenantNotFound>(error => TypedResults.NotFound())
.Map<TenantDisabled>(error => TypedResults.Problem(statusCode: StatusCodes.Status403Forbidden))
.Map<TenantError>(error => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict)) // every unmapped case
.AddFallback((error, context) => error is null ? null : TypedResults.Problem())
);
var app = builder.Build();
app.MapGet("/tenants/{id:int}", (int id) => GetTenant(id)).WithResultsHttp();

AddResultsHttp also registers problem-details services (AddProblemDetails()), so the unmapped-failure and uninitialized-result paths work without further host setup. It uses TryAddSingleton for IResultsHttpMapper and ResultsEndpointFilter, so a host can register its own mapper first and replace the defaults.

WithResultsHttp() exists on both RouteHandlerBuilder and RouteGroupBuilder. The endpoint filter is non-invasive: a handler that returns something other than an IResultValue is left untouched.

Success — the value is serialized with SuccessStatusCode (200 OK by default). A result whose successful value is itself an IResult is passed through untouched, and SuccessMapper overrides both when set.

Failure — the error is resolved to its case value (the active case of a union error, or the error itself for a non-union error), then mapped in this order:

  1. a mapping registered for the case type — Map<TenantNotFound>(...)
  2. a mapping registered for the error type — Map<TenantError>(...), which handles every case without its own mapping
  3. the fallback stage, in registration order: AddFallback(...) delegates and AddFailureMapper<TMapper>() mappers share one ordered list; a fallback or mapper returns null to defer to the next entry
  4. a ProblemDetails response using UnmappedStatusCode (500), UnmappedTitle, and an errorType extension naming the unmapped case — or an InvalidOperationException when ThrowOnUnmappedFailure is set

An uninitialized result (default) takes the same path and is logged, because an endpoint returning default is a host bug rather than a domain outcome.

OptionDefaultPurpose
SuccessStatusCode200Status code for a serialized successful value
SuccessMappernullReplaces the default success handling entirely
UnmappedStatusCode500Status code for a failure with no mapping
UnmappedTitle“The operation failed with an error that is not mapped to an HTTP response.”Title of the unmapped ProblemDetails
ThrowOnUnmappedFailurefalseThrow instead of producing a problem response; useful during development
IncludeTraceIdtrueWhether problem responses this package writes itself carry the request trace identifier
Map<TCase>(Func<TCase, IResult>)—Maps a case (or the error itself) to a response
Map<TCase>(Func<TCase, HttpContext, IResult>)—Same, with access to the request
AddFallback(Func<object?, HttpContext, IResult?>)—Consulted in order for unmapped failures, with the case value
AddFailureMapper<TMapper>()—Same stage, for a mapper class resolved from dependency injection

Registering the same type twice replaces the earlier mapping.

The rule needs…Use
One answer per error or case typeMap<TCase>(...)
The value the failure carries — a validation code, a category, a fieldIResultsFailureMapper via AddFailureMapper<TMapper>()
A quick inline rule, with no dependenciesAddFallback((error, context) => ...)
To replace the whole pipelineYour own IResultsHttpMapper

A failure mapper is a shape rule, not a catch-all:

public sealed class BlankIdentifierMapper : IResultsFailureMapper
{
public IResult? Map(ResultsFailureContext context) =>
context.Case is ITenantFailure { TenantId.Value: var id } && string.IsNullOrWhiteSpace(id)
? TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "An identifier is required.")
: null; // defer: the case mappings and the other fallbacks still apply
}
builder.Services.AddSingleton<BlankIdentifierMapper>();
builder.Services.AddResultsHttp(options => options
.Map<TenantNotFound>(_ => TypedResults.NotFound())
.AddFailureMapper<BlankIdentifierMapper>());

ResultsFailureContext carries the failure’s case (the active case of a union error, or the error itself), the error the result carries, and the request. A mapper is resolved from the failing request’s services the first time it is needed, so it may take its own dependencies in its constructor; register it before the first request. A failure that reaches an unregistered mapper throws an InvalidOperationException naming the registration that is missing.

Answering every failure in a mapper — with a generic problem or a 202, for example — turns a mapping gap in the host into a plausible-looking response, which is exactly what the unmapped-failure path exists to expose. Map the shapes you can name and return null for the rest.

app.MapGet("/tenants/{id:int}", (int id, HttpContext context) =>
GetTenant(id).ToHttpResult(context));

ToHttpResult(HttpContext) resolves IResultsHttpMapper from the request services; the ToHttpResult(IResultsHttpMapper, HttpContext) overload takes one directly.

IResultsHttpMapper is registered with AddResultsHttp as DefaultResultsHttpMapper via TryAddSingleton, so a host can register its own implementation first to replace the defaults entirely. A host that replaces it also bypasses ResultsHttpOptions — including the failure mappers — so prefer Map, AddFallback and AddFailureMapper unless the pipeline itself has to change.

Running Examples.AspNetCore:

RequestResponse
GET /tenants/acme200 OK with the tenant
GET /tenants/initech404 Not Found — the mapping for the TenantNotFound case
GET /tenants/globex/usage403 Forbidden — the mapping for the TenantDisabled case
POST /tenants/acme409 Conflict — the mapping for the TenantError error type
GET /tenants/broken500 with an errorType extension, because an endpoint returning default is a host bug
Terminal window
dotnet run --project src/examples/Examples.AspNetCore --urls http://localhost:5215
  • ZodSharp Problem Details — validation-carrying failures rendered as HttpValidationProblemDetails. This package deliberately knows nothing about ZodSharp.
  • Getting Started — the end-to-end walkthrough.