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.
| Exception | When | Status | What to do |
|---|---|---|---|
ValidationFailedException | The validator refused, before the handler | 400 | ToDictionary() already comes out in the ValidationProblemDetails shape. |
EntityNotFoundException | ReadRequiredAsync found nothing | 404 | Carries EntityType and Key. |
ConcurrencyConflictException | The row changed since it was read | 409 | The client reloads and tries again. |
BusinessRuleException | A business rule refused the operation | 409 (or 422) | The message is written for the client; Code gives it a stable identifier to decide on. |
DuplicateKeyException | The database refused a value that has to be unique | 409 | Recognised by each provider's code (2601/2627, 1062, 23505, E11000, SQLite). The provider's message stays in the log. |
PartialCommitException | With several databases, one had already committed when another failed | 500 | CommittedStores and UncommittedStores say which. It is not retried; reconcile. |
TimeoutException | The message ran past its [MessageTimeout] | 503 or 504 | The handler's token is cancelled at the deadline. AddNexo<TContext> already includes the timeout pipeline. |
HandlerNotFoundException | No handler registered for the message | 500 | A configuration error. The message names the exact type you forgot to register. |
DuplicateHandlerException | Two 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.
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,
});
}
}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.
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:
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:
'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:
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.
[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
}