Table of Contents

Results to HTTP responses

Arc4u.AspNetCore.Results converts a Result or Result<T> into the response of an endpoint, so that every action does not repeat the same success and failure handling. This page lists the methods for controllers and minimal APIs, the status code of every case, and the ProblemDetails JSON your clients receive. Every response shown here was captured from a running application. Read Fluent result chains first for the error types.

What you would otherwise write

For each action you would return a success status, a ProblemDetails with a 4xx status for a failure the caller can act on, and a generic 500 when an exception happened:

ActionResult response = new BadRequestResult();
var result = await bl.DeleteAsync(id, cancellationToken);
result
    .OnSuccess(() => response = new NoContentResult())
    .OnFailed(_ => response = new ObjectResult(result.ToProblemDetails()));
return response;

The extension methods do exactly this. The same action becomes:

return await bl.DeleteAsync(id, cancellationToken).ToActionOkResultAsync();

Choose the method for your endpoint

Three families exist, one per return type. Each has an Ok method (200 or 204) and a Created method (201), and each comes in a synchronous form (on Result) and an Async form (on Result, Task<Result> and ValueTask<Result>, with the generic equivalents).

Endpoint style Return type Ok Created Class
Controller action ActionResult or ActionResult<T> ToActionOkResult, ToActionOkResultAsync ToActionCreatedResult, ToActionCreatedResultAsync FromResultToActionResultExtension
Minimal API, untyped IResult ToHttpOkResult, ToHttpOkResultAsync ToHttpCreatedResult, ToHttpCreatedResultAsync FromResultToHttpResultExtension
Minimal API, typed Results<..., ProblemHttpResult, ValidationProblem> ToTypedOkResult, ToTypedOkResultAsync ToTypedCreatedResult, ToTypedCreatedResultAsync FromResultToTypedResultExtension
using Arc4u.AspNetCore.Results;
using Arc4u.Results;
using FluentResults;
using Microsoft.AspNetCore.Mvc;

public record OrderDto(int Id, string Name);

public interface IOrderService
{
    Task<Result<OrderDto>> GetAsync(int id);
    Task<Result> DeleteAsync(int id);
}

[ApiController]
[Route("api/orders")]
public class OrdersController(IOrderService orders) : ControllerBase
{
    [HttpGet("{id:int}")]
    public Task<ActionResult<OrderDto>> Get(int id) => orders.GetAsync(id).ToActionOkResultAsync();

    [HttpDelete("{id:int}")]
    public Task<ActionResult> Delete(int id) => orders.DeleteAsync(id).ToActionOkResultAsync();
}

Typed results describe the possible responses to OpenAPI. The library always returns failures as a ProblemHttpResult; ValidationProblem is part of the type but is never the runtime type.

How successes map

Result Method Status Body
Result succeeded To...OkResult 204 No Content None
Result<T> succeeded with a value To...OkResult 200 OK The value, or the value returned by the mapper
Result<T> succeeded with null To...OkResult Depends on the endpoint style, see null values
Result succeeded To...CreatedResult(location) 201 Created, Location header when a location is passed None
Result<T> succeeded with a value To...CreatedResult(location) 201 Created, Location header The value, or the value returned by the mapper
Result<T> succeeded with null To...CreatedResult(location) 201 Created, no Location header Empty

Map the value

Every method with a value has an overload taking a mapper, so that the business type does not leave the API. Use any mapper: a lambda, AutoMapper, Mapster. There is an asynchronous form too (Func<TResult, Task<T>>). The mapper is not called when the value is null.

public record OrderSummary(string Name);

// in the controller of the previous sample
[HttpGet("{id:int}/summary")]
public Task<ActionResult<OrderSummary>> Summary(int id)
    => orders.GetAsync(id).ToActionOkResultAsync(order => new OrderSummary(order.Name));

Null values

A Result<T> can succeed with a null value, for example a query that finds nothing. What the client sees depends on the endpoint style:

Endpoint style Response
Controller 204 No Content with an empty body. OkObjectResult with a null value is written as 204 by MVC's HttpNoContentOutputFormatter.
Minimal API (IResult or typed) 200 OK with an empty body

Some generated clients (NSwag, for example) treat a 204 on an operation declared to return a value as an error. To answer 200 with the JSON null from controllers, remove the formatter:

using Microsoft.AspNetCore.Mvc.Formatters;

builder.Services.AddControllers(options => options.OutputFormatters.RemoveType<HttpNoContentOutputFormatter>());

If you prefer an explicit answer, handle the case before converting: OnSuccessNull sets what you want, or return a ProblemDetailError with status 404 when a missing entity is an error.

Return 201 Created with a Location

The Location depends on the value the business layer returns (the new identifier). Compute it in OnSuccessNotNull, then convert in a second step. If you wrote the whole chain in one expression, the URI would be evaluated before the result exists.

using Arc4u.AspNetCore.Results;
using Arc4u.Results;
using Microsoft.AspNetCore.Mvc;

// in a controller with an action named Get
[HttpPost]
public async Task<ActionResult<OrderDto>> Create(OrderDto dto)
{
    Uri? location = null;

    var result = await orders.CreateAsync(dto)
                             .OnSuccessNotNull(created => location = new Uri(Url.ActionLink("Get", "Orders", new { id = created.Id })!))
                             .ConfigureAwait(false);

    return await result.ToActionCreatedResultAsync(location).ConfigureAwait(false);
}

OnSuccessNotNull is used because OnSuccess also runs when the value is null. IOrderService.CreateAsync returns Task<Result<OrderDto>>. A minimal API uses the same two steps:

// Program.cs (excerpt)
app.MapPost("/orders", async (OrderDto dto, IOrderService orders) =>
{
    Uri? location = null;

    var result = await orders.CreateAsync(dto);
    result.OnSuccessNotNull(created => location = new Uri($"/orders/{created.Id}", UriKind.Relative));

    return await result.ToHttpCreatedResultAsync(location);
});

How errors map to a response

A failed result is converted by ToProblemDetails, which looks at the errors in this order and stops at the first match:

# The result contains Status type Body
1 An IExceptionalError (an exception) 500 .../StatusCodes#unexpected-error Generic message with the activity id. Only the first exception is used. The exception is logged.
2 At least one ValidationError 422 .../StatusCodes#validation-error ValidationProblemDetails, messages grouped by severity. Other errors are ignored.
3 At least one ProblemDetailError Its StatusCode, or 500 Its Type, or about:blank Its title, detail, instance, severity and metadata. Only the first is used.
4 Any other error 400 about:blank Title Error., detail is the message of the first error.

Every response is served as application/problem+json (controllers add ; charset=utf-8; the JSON blocks below show the controller headers). Requests with a successful result never go through this mapping.

The type URIs of rows 1 and 2 are constants in the library that point to the page https://github.com/Arc4u-org/Arc4u/wiki/StatusCodes of the Arc4u wiki. Treat them as identifiers. The expected-error URI is used only when ToGenericMessage is called with unexpectedType: false. Keep the wiki page (or at least its headings) available while responses carry these URIs.

Unexpected error: an exception

500, no exception detail in the response. The detail carries the W3C activity id (Activity.Current.Id) that the support team searches in the logs:

HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json; charset=utf-8

{"type":"https://github.com/Arc4u-org/Arc4u/wiki/StatusCodes#unexpected-error","title":"A technical error occurred!","status":500,"detail":"Contact the application owner. A message has been logged with id: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-00."}

The exception is written to the log only if you registered the result logger (see Configuration).

Business problem: ProblemDetailError

The title, status, type, instance, severity and metadata you set are copied. Metadata entries become top-level members. The severity is always present:

HTTP/1.1 404 Not Found
Content-Type: application/problem+json; charset=utf-8

{"type":"https://example.com/problems/not-found","title":"Not found","status":404,"detail":"Order 2 does not exist.","instance":"/orders/2","Severity":"Error","orderId":2}

With only Create("no status set"), the response is a 500 with "type":"about:blank" and "title":"Error.".

Validation error

Status 422 with a ValidationProblemDetails. The errors member groups the messages by severity name; the keys are sorted alphabetically (Error, Info, Warning). The error codes are not part of the response:

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json; charset=utf-8

{"type":"https://github.com/Arc4u-org/Arc4u/wiki/StatusCodes#validation-error","title":"Error from validation.","status":422,"errors":{"Error":["'Name' must not be empty.","'Quantity' must be greater than '0'."]}}

A result that contains only warnings is still failed, so it also answers 422. See Validation errors.

Other error

An error that is neither an exception, a ValidationError nor a ProblemDetailError, for example Result.Fail("plain FluentResults error"), gives 400:

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8

{"type":"about:blank","title":"Error.","status":400,"detail":"plain FluentResults error","Severity":"Error"}

Unauthorized and forbidden

The result mapping never produces 401 or 403 by itself. Authentication and authorization are done by the ASP.NET Core middleware before your endpoint runs (see the server authentication guide). To report a denied action from the business layer, return a ProblemDetailError with WithStatusCode(403).

The generic 500 message

ToGenericMessage builds the 500 (or 400) generic body. ToProblemDetails calls it for exceptions. It also calls LogIfFailed on the result.

All four overloads (Result or Result<T>, with or without an explicit activityId) honor unexpectedType. With the default true you get the "unexpected" 500 message; with unexpectedType: false you get 400 and the expected-error type.

Calling ToProblemDetails on a result that succeeded does not make sense. The library returns the generic 500, built from an ExceptionalError, so that the mistake is visible. The result itself is not modified and stays successful.

Change the mapping

FromResultToProblemDetailExtension.SetFromErrorFactory replaces the function that converts errors to a ProblemDetails, for all endpoints and for the whole process. Call it once at startup. Your function receives the errors of the failed result and returns the ProblemDetails; set its Status, because the response status code is read from ProblemDetails.Status.

To change the mapping of some errors only, use the overload whose function receives a second argument: the default mapping described above. Call it for the errors you do not handle yourself. This sample maps its own NotFoundError to a 404 and keeps the default mapping, including the exception protection, for every other error:

using Arc4u.AspNetCore.Results;
using FluentResults;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

FromResultToProblemDetailExtension.SetFromErrorFactory((errors, defaultFromError) =>
{
    if (errors.OfType<NotFoundError>().FirstOrDefault() is { } notFound)
    {
        return new ProblemDetails()
            .WithTitle("Not found.")
            .WithDetail(notFound.Message)
            .WithStatusCode(StatusCodes.Status404NotFound);
    }

    return defaultFromError(errors);
});

To add information to every response instead, decorate the default result: (errors, defaultFromError) => defaultFromError(errors).WithMetadata("application", "billing"). To restore the default mapping, pass (errors, defaultFromError) => defaultFromError(errors).

To replace the whole mapping, pass a function that takes only the errors: SetFromErrorFactory(errors => ...).

Caution

Do not call FromError from inside your factory to reuse the default mapping. FromError calls the factory that is currently registered, which is yours, so the call recurses until the process dies with a stack overflow. Use the defaultFromError argument instead.

Build a ProblemDetails by hand

ProblemDetailsExtensions adds fluent methods to Microsoft.AspNetCore.Mvc.ProblemDetails. They are what the default factory uses.

Method Sets
WithTitle(string), WithDetail(string) Title, Detail
WithStatusCode(int) Status
WithType(Uri) Type (as a string)
WithInstance(string?) Instance, ignored when empty
WithCode(string), WithSeverity(string) Extension members Code and Severity, ignored when empty
WithMetadata(string key, object value) An extension member, replaced when it exists
using Arc4u.AspNetCore.Results;
using Arc4u.Results.Validation;
using Microsoft.AspNetCore.Mvc;

var problem = new ProblemDetails()
    .WithTitle("Some title")
    .WithDetail("Some detail")
    .WithStatusCode(StatusCodes.Status400BadRequest)
    .WithType(new Uri("about:blank"))
    .WithSeverity(Severity.Error.ToString());

Troubleshooting

I get a 500 where I expected a 4xx

The error is a ProblemDetailError without WithStatusCode. Its default status is 500.

My other errors are not in the response

The mapping stops at the first matching row. If a result contains a ValidationError and a ProblemDetailError, only the validation errors are returned; with an exception, only a generic 500 is returned. Return one kind of error per result.

The response says "A message has been logged" but I cannot find it

The result logger is not configured. See Configuration.

A controller returns 204 when the value is null

That is ASP.NET Core's default, see null values.

See also