Nexo

Documentação

Validação

FluentValidation antes do handler, com a falha chegando na camada web sem arrastar FluentValidation com ela.

Configuração

Shell
dotnet add package Nexo.FluentValidation
C#
services.AddNexo<CatalogDbContext>(nexo => nexo
    .AddHandlersFromAssemblyOf<Program>()
    .AddValidatorsFromAssemblyOf<Program>());

AddValidatorsFromAssemblyOf<T> registra os validators e adiciona o pipeline, se ele ainda não estiver lá. Registrar validators que nunca rodam seria uma armadilha silenciosa.

Os validators são scoped por padrão, como os handlers. Um validator que consulta o banco — "este SKU já existe?" — depende de serviços scoped, e registrá-lo como singleton o prenderia ao primeiro escopo que o resolveu. Passe outro ServiceLifetime só para validators sem dependência nenhuma.

Escrevendo validators

São validators normais de FluentValidation, sobre a mensagem. Uma mensagem pode ter vários: todos rodam, e as falhas de todos chegam juntas na mesma ValidationFailedException — útil quando uma regra vem de um módulo e outra de outro.

C#
public sealed class CreateProductValidator : AbstractValidator<CreateProduct>
{
    public CreateProductValidator()
    {
        RuleFor(c => c.CategoryId).NotEmpty();
        RuleFor(c => c.Sku).NotEmpty().MaximumLength(32);
        RuleFor(c => c.Name).NotEmpty().MaximumLength(256);
        RuleFor(c => c.Price).GreaterThan(0);
    }
}

public sealed class ImportProductsValidator : AbstractValidator<ImportProducts>
{
    public ImportProductsValidator()
    {
        RuleFor(c => c.Products).NotEmpty();
        RuleForEach(c => c.Products).ChildRules(product =>
        {
            product.RuleFor(p => p.Sku).NotEmpty().MaximumLength(32);
            product.RuleFor(p => p.Price).GreaterThan(0);
        });
    }
}

O que é validado

TipoPadrãoPor quê
ComandosvalidadosMudam estado.
QueriesvalidadasMantém filtros e paginação sãos.
Eventosnão validadosUm evento descreve algo que já aconteceu; rejeitar depois do fato raramente ajuda.
C#
nexo.AddValidationPipeline(validation =>
{
    validation.ValidateCommands = true;
    validation.ValidateQueries = true;
    validation.ValidateEvents = false;

    // Off por padrão: você adiciona validator onde precisa e ignora o resto.
    // Ligue quando o time decidir que todo comando tem de ter um.
    validation.FailIfValidatorNotFound = false;
});

Queries em stream seguem ValidateQueries: a validação roda antes do primeiro item. Uma mensagem específica pode pular a validação com [SkipValidation] — um comando interno, montado por código que já garantiu o que o validator checaria.

Sem vazar FluentValidation

A falha é traduzida para ValidationFailedException, que vive em Nexo.Abstractions. É a peça que mantém a camada web independente da biblioteca de validação escolhida.

C#
public sealed class ValidationFailedException : NexoException
{
    public Type MessageType { get; }
    public IReadOnlyList<ValidationError> Errors { get; }

    // No formato que ValidationProblemDetails espera.
    public IDictionary<string, string[]> ToDictionary();
}

public sealed record ValidationError(string Member, string Message, string? Code = null);
C#
// No IExceptionHandler. Nenhum using de FluentValidation aqui.
ValidationFailedException validation => new ProblemDetails
{
    Status = StatusCodes.Status400BadRequest,
    Title = "One or more validation errors occurred.",
    Extensions = { ["errors"] = validation.ToDictionary() },
},
JSON
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Sku": ["'Sku' must not be empty."],
    "Price": ["'Price' must be greater than '0'."]
  },
  "traceId": "00-008c5d9d4a1c041daf1bc2aebae4998b-697e6b4d10f2c804-00"
}

Antes da transação, de propósito

O pipeline de validação é registrado antes do unit of work. Um comando inválido nunca faz o banco abrir transação — e o teste do sample confirma que nada é gravado quando a validação recusa.

C#
[Fact]
public async Task Rejects_an_invalid_command_before_the_handler_runs()
{
    await using var host = await TestHost.StartAsync(configureNexo: options =>
        options.AddValidatorsFromAssemblyOf<TestHost>());

    await Should.ThrowAsync<ValidationFailedException>(() =>
        host.SendAsync(new CreateProduct { Code = "", Price = -1m }));

    var count = await host.QueryDatabaseAsync(db => db.Products.CountAsync());
    count.ShouldBe(0);
}

Validação de forma e regra de negócio

Um validator é bom para forma: obrigatório, tamanho, faixa, formato. Ele não é o lugar para regra que precisa do banco.

Regra que consulta o banco vai no handler, onde já existe transação e onde a checagem e a escrita são atômicas. Um validator que faz SELECT cria um intervalo entre a checagem e a gravação — e é aí que dois requests simultâneos passam os dois.

C#
public async Task<Guid> HandleAsync(CreateProduct command, CancellationToken ct)
{
    // Regra de negócio: no handler, dentro da transação.
    if (await products.AnyAsync(p => p.Sku == command.Sku, ct))
    {
        throw new DuplicateSkuException(command.Sku);
    }

    var product = await create.CreateAsync(new Product(command.Sku, command.Price), ct);
    return product.ExternalId;
}

Usando outra biblioteca

O pipeline de validação não tem nada de especial — é um IPipeline. Para usar DataAnnotations, ou um validador próprio, escreva um e lance ValidationFailedException. Toda a camada de erro continua funcionando.

C#
public sealed class DataAnnotationsPipeline : Pipeline
{
    public override Task<TResult> OnCommandAsync<TCommand, TResult>(
        Func<TCommand, CancellationToken, Task<TResult>> next,
        TCommand command,
        CancellationToken ct)
    {
        var results = new List<System.ComponentModel.DataAnnotations.ValidationResult>();

        if (!Validator.TryValidateObject(command, new ValidationContext(command), results, true))
        {
            throw new ValidationFailedException(
                typeof(TCommand),
                results.Select(r => new ValidationError(
                    r.MemberNames.FirstOrDefault() ?? string.Empty,
                    r.ErrorMessage ?? "Inválido")));
        }

        return next(command, ct);
    }
}