Table of Contents

Results and errors

Arc4u business code reports failure by returning a Result instead of throwing an exception or filling a list of messages. The REST layer turns that result into an HTTP response, and turns every failure into a ProblemDetails body (RFC 9457, previously RFC 7807). This guide covers the three packages that do this. It replaces the Message and Messages types of Arc4u 8 (see the migration guide).

What it solves

A use case can succeed, fail in a way the caller can fix (invalid input, missing entity, business rule) or break in a way nobody planned (I/O error, bug). With plain .NET you get an if/else on every call, an exception for the unplanned case, and a hand-written switch in each controller action that picks the status code and the body.

Arc4u builds on FluentResults and adds three things:

  • Fluent chains (OnSuccess, OnFailed, LogIfFailed, ...) so that a use case reads as a sequence of intentions, without if statements. See Fluent result chains.
  • Error types that map to HTTP: ProblemDetailError for a business problem with its own status code, ValidationError for input validation. One extension method per response style converts a result into a controller ActionResult, a minimal API IResult or a typed Results<...>. See Results to HTTP responses.
  • FluentValidation integration: validators return a Result whose errors become an HTTP 422 response. See Validation errors.

Arc4u leaves the rest to .NET: it does not replace ProblemDetails, content negotiation or the ASP.NET Core exception middleware.

flowchart LR
    Client[HTTP client] --> Endpoint[Controller or minimal API]
    Endpoint --> Facade[Business layer]
    Facade -->|"Result"| Endpoint
    Endpoint -->|"ToActionOkResultAsync()<br/>ToHttpOkResultAsync()<br/>ToTypedOkResultAsync()"| Response["200, 201, 204<br/>or ProblemDetails"]
    Response --> Client

Packages involved

Package Use it for
Arc4u.Results Fluent chain extensions on Result, ProblemDetailError, ValidationError, and FluentLogger, the logger for results. No ASP.NET Core dependency: use it in the business layer.
Arc4u.AspNetCore.Results Conversion of a result to ActionResult, IResult or typed Results<...>, and of errors to ProblemDetails. Use it in the interface layer (controllers and minimal APIs). References Arc4u.Results.
Arc4u.FluentValidation FluentValidation rules and ValidateWithResult extensions that produce a Result with ValidationErrors. The assembly is named Arc4u.Validation. References Arc4u.Results.

All three target net10.0 and net11.0.

Install

Business layer:

dotnet add package Arc4u.Results --prerelease

ASP.NET Core project that exposes the API:

dotnet add package Arc4u.AspNetCore.Results --prerelease

If you validate with FluentValidation:

dotnet add package Arc4u.FluentValidation --prerelease

Configuration

None of the three packages reads an appsettings.json section. Two things are configured in code.

What Where Default
Logger used by LogIfFailed and ToGenericMessage AddResultLogger(), or Result.Setup(cfg => cfg.Logger = ...) (FluentResults) A logger that writes nothing
Function that turns errors into a ProblemDetails FromResultToProblemDetailExtension.SetFromErrorFactory The mapping described in Results to HTTP responses

Code

Call AddResultLogger() at startup. It registers FluentLogger and gives it to FluentResults (Result.Setup) when the host starts. Without this, LogIfFailed logs nothing, and the message "A message has been logged" that Arc4u puts in a 500 response is not true.

// Program.cs
using Arc4u.Results.Logging;
using Arc4u.Security.Principal;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddApplicationContext();   // provides the Arc4u ILogger<T> that FluentLogger needs
builder.Services.AddResultLogger();         // FluentLogger, handed to Result.Setup when the host starts

var app = builder.Build();

app.Run();

AddApplicationContext is described in the diagnostics guide. The logger is set when the host starts, before the server accepts requests and before the other hosted services start. Results logged earlier are lost: if code between Build() and Run() (a database migration, a seed) uses LogIfFailed, call app.Services.UseResultLogger() right after Build(). Do the same without a generic host (a console tool, a test).

var app = builder.Build();

app.Services.UseResultLogger();   // only needed when results are logged before app.Run()
await SeedDatabaseAsync(app.Services);

app.Run();

With HostOptions.ServicesStartConcurrently = true, hosted services start in parallel: call UseResultLogger() after Build() as well. FluentLogger writes with the Arc4u business logger, so its output follows your Serilog or ILogger configuration.

Common scenarios

Return a result from a controller action

The business layer returns a Result<T>. The action converts it in one call:

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);
}

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

A success answers 200 with the value. A failure answers with a ProblemDetails body and the status code of the error. Results to HTTP responses lists every case, with the real JSON.

Return a business problem with its own status code

Create the error where the problem is detected. Create takes the detail of the problem:

using Arc4u.Results;
using FluentResults;

public static class OrderRules
{
    public static Result<OrderDto> NotFound(int id)
        => Result.Fail<OrderDto>(ProblemDetailError.Create($"Order {id} does not exist.")
                                                   .WithTitle("Not found")
                                                   .WithStatusCode(404));
}

The client receives:

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

{"type":"about:blank","title":"Not found","status":404,"detail":"Order 2 does not exist.","Severity":"Error"}

Expose the same result from a minimal API

// Program.cs (excerpt)
app.MapGet("/orders/{id:int}", (int id, IOrderService orders) => orders.GetAsync(id).ToHttpOkResultAsync());

Extensibility points

Service Default implementation Replace it to
FluentResults.IResultLogger FluentLogger (registered by AddResultLogger) Send result logs somewhere else than the Arc4u business logger.
FromResultToProblemDetailExtension.FromError (set with SetFromErrorFactory) Built-in mapping Change the status codes, the type URIs or the shape of the ProblemDetails for the whole application. Call SetFromErrorFactory once at startup.
using System.Diagnostics;
using Arc4u.AspNetCore.Results;
using FluentResults;
using Microsoft.AspNetCore.Mvc;

FromResultToProblemDetailExtension.SetFromErrorFactory(errors =>
{
    if (errors.OfType<IExceptionalError>().Any())
    {
        return Result.Fail(errors).ToGenericMessage(Activity.Current?.Id, true);   // never expose Exception.Message
    }

    return new ProblemDetails
    {
        Title = "Request failed",
        Detail = errors.First().Message,
        Status = StatusCodes.Status400BadRequest
    };
});

The factory is static and applies to the whole process. It replaces the default mapping completely, including the protection that hides exception details: do not put Exception.Message in the response. See Change the mapping.

Troubleshooting

LogIfFailed writes nothing

FluentResults uses a logger that discards everything until Result.Setup is called. Call AddResultLogger() as shown in the Code section above (or UseResultLogger() without a host).

Compile error CS0104: 'Severity' is an ambiguous reference

Arc4u.Results.Validation.Severity and FluentValidation.Severity both exist. When a file imports both namespaces, qualify the one you mean, for example Arc4u.Results.Validation.Severity.Warning.

ToActionResultAsync does not exist

Some older notes use ToActionResultAsync. The methods are ToActionOkResultAsync and ToActionCreatedResultAsync (controllers), ToHttpOkResultAsync and ToHttpCreatedResultAsync (IResult), ToTypedOkResultAsync and ToTypedCreatedResultAsync (typed results).

See also