Documentação
Validação
FluentValidation antes do handler, com a falha chegando na camada web sem arrastar FluentValidation com ela.
Configuração
dotnet add package Nexo.FluentValidationservices.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.
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
| Tipo | Padrão | Por quê |
|---|---|---|
| Comandos | validados | Mudam estado. |
| Queries | validadas | Mantém filtros e paginação sãos. |
| Eventos | não validados | Um evento descreve algo que já aconteceu; rejeitar depois do fato raramente ajuda. |
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.
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);// 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() },
},{
"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.
[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.
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.
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);
}
}