Documentação
Erros e HTTP
As exceções do Nexo e como mapeá-las para status HTTP num lugar só.
As exceções
Todas herdam de NexoException e vivem em Nexo.Abstractions. Sua camada web depende só desse pacote, nunca de FluentValidation nem de EF Core.
| Exceção | Quando | Status | O que fazer |
|---|---|---|---|
ValidationFailedException | O validator recusou, antes do handler | 400 | ToDictionary() já sai no formato de ValidationProblemDetails. |
EntityNotFoundException | ReadRequiredAsync não achou | 404 | Traz EntityType e Key. |
ConcurrencyConflictException | A linha mudou desde a leitura | 409 | O cliente recarrega e tenta de novo. |
BusinessRuleException | Uma regra de negócio recusou a operação | 409 (ou 422) | A mensagem é escrita para o cliente; Code dá um identificador estável para ele decidir. |
DuplicateKeyException | O banco recusou um valor que tem de ser único | 409 | Reconhecida pelo código de cada provider (2601/2627, 1062, 23505, E11000, SQLite). A mensagem do provider fica no log. |
PartialCommitException | Com vários bancos, um já tinha comitado quando outro falhou | 500 | CommittedStores e UncommittedStores dizem quais. Não é repetida por retry; reconcilie. |
TimeoutException | A mensagem passou do [MessageTimeout] | 503 ou 504 | O token do handler é cancelado no prazo. AddNexo<TContext> já inclui o pipeline de timeout. |
HandlerNotFoundException | Nenhum handler registrado para a mensagem | 500 | Erro de configuração. A mensagem traz o tipo exato que faltou registrar. |
DuplicateHandlerException | Dois handlers no mesmo comando ou query | — | Estoura no start-up, não em runtime. |
Um handler para tudo
Um IExceptionHandler concentra o mapeamento. Note o que o arquivo não importa.
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,
},
// As suas regras de negócio: a mensagem foi escrita para o cliente.
BusinessRuleException rule => new ProblemDetails
{
Status = StatusCodes.Status409Conflict,
Title = "Conflict",
Detail = rule.Message,
Extensions = { ["code"] = rule.Code },
},
// Duas requisições passaram pela checagem ao mesmo tempo e o índice único barrou a
// segunda. A mensagem do provider cita tabelas e índices: fica no log.
DuplicateKeyException => new ProblemDetails
{
Status = StatusCodes.Status409Conflict,
Title = "Conflict",
Detail = "A value that must be unique is already in use.",
},
_ => null, // deixa passar: quem não é meu, vira 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();As suas próprias exceções
Regras de negócio que o validator não cobre viram exceções de domínio, lançadas no handler. Herde de BusinessRuleException: o mapeamento acima já as trata, sem uma linha a mais por exceção. Lançar aborta a transação, então nada fica gravado pela metade.
public sealed class DuplicateSkuException(string sku)
: BusinessRuleException("sku-taken", $"A product with SKU '{sku}' already exists.")
{
public string Sku { get; } = sku;
}
// No handler, dentro da transação. O índice único vale também para as linhas com soft
// delete, então a checagem olha além do filtro — senão o insert passa e bate no índice.
var taken = await products
.IgnoreQueryFilters([NexoQueryFilters.SoftDelete])
.AnyAsync(p => p.Sku == command.Sku, ct);
if (taken)
{
throw new DuplicateSkuException(command.Sku);
}Erros de configuração são explícitos
As duas falhas mais comuns de montagem dizem exatamente o que falta. HandlerNotFoundException nomeia a interface que o container procurou:
No handler registered for 'Catalog.Products.ArchiveProduct'. Register an
implementation of 'ICommandHandler<ArchiveProduct>', or call
AddHandlersFromAssemblyOf<T>() for the assembly that contains it.E DuplicateHandlerException nomeia os dois candidatos, no start-up:
'ICommandHandler<CreateProduct>' is implemented by both
'Catalog.Products.CreateProductHandler' and 'Catalog.Legacy.CreateProductHandler'.
Commands and queries accept exactly one handler.Se o duplicado for intencional — um handler legado que você quer ignorar — filtre na varredura em vez de apagar a classe:
nexo.AddHandlersFromAssemblyOf<Program>(
filter: type => !type.Namespace!.Contains("Legacy", StringComparison.Ordinal));O que acontece quando algo lança
- A exceção sai pelo pipeline, de dentro para fora.
- O unit of work faz rollback: nada foi gravado, nem o que o handler já tinha criado antes da falha.
- O pipeline de logging registra o erro com o nome e o id da mensagem, e o tempo até falhar.
- A exceção chega ao seu
IExceptionHandler, que decide o 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, // a primeira escrita já aconteceu
}));
var count = await host.QueryDatabaseAsync(db => db.Products.CountAsync());
count.ShouldBe(0); // o rollback é o que garante isso
}