Nexo

Documentation

Errors and HTTP

Nexo's exceptions and how to map them to HTTP status codes in one place.

The exceptions

They all derive from NexoException and live in Nexo.Abstractions. Your web layer depends on that package alone, never on FluentValidation or EF Core.

ExceptionWhenStatusWhat to do
ValidationFailedExceptionThe validator refused, before the handler400ToDictionary() already comes out in the ValidationProblemDetails shape.
EntityNotFoundExceptionReadRequiredAsync found nothing404Carries EntityType and Key.
ConcurrencyConflictExceptionThe row changed since it was read409The client reloads and tries again.
BusinessRuleExceptionA business rule refused the operation409 (or 422)The message is written for the client; Code gives it a stable identifier to decide on.
DuplicateKeyExceptionThe database refused a value that has to be unique409Recognised by each provider's code (2601/2627, 1062, 23505, E11000, SQLite). The provider's message stays in the log.
PartialCommitExceptionWith several databases, one had already committed when another failed500CommittedStores and UncommittedStores say which. It is not retried; reconcile.
TimeoutExceptionThe message ran past its [MessageTimeout]503 or 504The handler's token is cancelled at the deadline. AddNexo<TContext> already includes the timeout pipeline.
HandlerNotFoundExceptionNo handler registered for the message500A configuration error. The message names the exact type you forgot to register.
DuplicateHandlerExceptionTwo handlers for the same command or query—It blows up at start-up, not at run time.

One handler for everything

One IExceptionHandler concentrates the mapping. Note what the file does not import.

NexoExceptionHandler.cs
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;

public sealed class NexoExceptionHandler(IProblemDetailsService problemDetails) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        var problem = exception switch
        {
            ValidationFailedException validation => new ProblemDetails
            {
                Status = StatusCodes.Status400BadRequest,
                Title = "One or more validation errors occurred.",
                Extensions = { ["errors"] = validation.ToDictionary() },
            },

            EntityNotFoundException notFound => new ProblemDetails
            {
                Status = StatusCodes.Status404NotFound,
                Title = "Not found",
                Detail = notFound.Message,
            },

            ConcurrencyConflictException concurrency => new ProblemDetails
            {
                Status = StatusCodes.Status409Conflict,
                Title = "Concurrent modification",
                Detail = concurrency.Message,
            },

            // Your business rules: the message was written for the client.
            BusinessRuleException rule => new ProblemDetails
            {
                Status = StatusCodes.Status409Conflict,
                Title = "Conflict",
                Detail = rule.Message,
                Extensions = { ["code"] = rule.Code },
            },

            // Two requests passed the check at once and the unique index stopped the
            // second. The provider's message names tables and indexes: it stays in the log.
            DuplicateKeyException => new ProblemDetails
            {
                Status = StatusCodes.Status409Conflict,
                Title = "Conflict",
                Detail = "A value that must be unique is already in use.",
            },

            _ => null,   // let it through: anything not mine becomes a 500
        };

        if (problem is null)
        {
            return false;
        }

        httpContext.Response.StatusCode = problem.Status!.Value;

        return await problemDetails.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = httpContext,
            Exception = exception,
            ProblemDetails = problem,
        });
    }
}
C#
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<NexoExceptionHandler>();

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

Your own exceptions

Business rules the validator does not cover become domain exceptions, thrown in the handler. Derive from BusinessRuleException: the mapping above already handles them, with no extra line per exception. Throwing aborts the transaction, so nothing is left half written.

C#
public sealed class DuplicateSkuException(string sku)
    : BusinessRuleException("sku-taken", $"A product with SKU '{sku}' already exists.")
{
    public string Sku { get; } = sku;
}

// In the handler, inside the transaction. The unique index covers soft-deleted rows too,
// so the check looks past the filter — otherwise the insert gets through and hits the index.
var taken = await products
    .IgnoreQueryFilters([NexoQueryFilters.SoftDelete])
    .AnyAsync(p => p.Sku == command.Sku, ct);

if (taken)
{
    throw new DuplicateSkuException(command.Sku);
}

Configuration errors are explicit

The two most common wiring failures say exactly what is missing. HandlerNotFoundException names the interface the container looked for:

Text
No handler registered for 'Catalog.Products.ArchiveProduct'. Register an
implementation of 'ICommandHandler<ArchiveProduct>', or call
AddHandlersFromAssemblyOf<T>() for the assembly that contains it.

And DuplicateHandlerException names both candidates, at start-up:

Text
'ICommandHandler<CreateProduct>' is implemented by both
'Catalog.Products.CreateProductHandler' and 'Catalog.Legacy.CreateProductHandler'.
Commands and queries accept exactly one handler.

If the duplicate is deliberate — a legacy handler you want to ignore — filter it out of the scan instead of deleting the class:

C#
nexo.AddHandlersFromAssemblyOf<Program>(
    filter: type => !type.Namespace!.Contains("Legacy", StringComparison.Ordinal));

What happens when something throws

  • The exception travels out through the pipeline, from the inside out.
  • The unit of work rolls back: nothing was written, not even what the handler had created before the failure.
  • The logging pipeline records the error with the message name and id, and how long it took to fail.
  • The exception reaches your IExceptionHandler, which decides the status.
C#
[Fact]
public async Task A_failing_handler_leaves_nothing_behind()
{
    await using var host = await TestHost.StartAsync();

    await Should.ThrowAsync<InvalidOperationException>(() =>
        host.SendAsync(new CreateProductPair
        {
            FirstCode = "A1",
            SecondCode = "A2",
            ThrowAfterFirst = true,   // the first write already happened
        }));

    var count = await host.QueryDatabaseAsync(db => db.Products.CountAsync());
    count.ShouldBe(0);   // the rollback is what guarantees this
}