Nexo

Documentação

Erros e HTTP

As exceções do Nexo e como mapeá-las para status HTTP num lugar só.

Um handler para tudo

Um IExceptionHandler concentra o mapeamento. Note o que o arquivo não importa.

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,
            },

            // 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,
        });
    }
}
C#
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.

C#
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:

Texto
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:

Texto
'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:

C#
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.
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,   // a primeira escrita já aconteceu
        }));

    var count = await host.QueryDatabaseAsync(db => db.Products.CountAsync());
    count.ShouldBe(0);   // o rollback é o que garante isso
}