Documentation
Validation
FluentValidation before the handler, with the failure reaching the web layer without dragging FluentValidation along with it.
Setup
dotnet add package Nexo.FluentValidationservices.AddNexo<CatalogDbContext>(nexo => nexo
.AddHandlersFromAssemblyOf<Program>()
.AddValidatorsFromAssemblyOf<Program>());AddValidatorsFromAssemblyOf<T> registers the validators and adds the pipeline, if it is not there already. Registering validators that never run would be a silent trap.
Validators are scoped by default, like handlers. A validator that queries the database — "does this SKU already exist?" — depends on scoped services, and registering it as a singleton would pin it to the first scope that resolved it. Pass a different ServiceLifetime only for validators with no dependencies at all.
Writing validators
They are ordinary FluentValidation validators, over the message. A message can have several: all of them run, and all their failures arrive together in the same ValidationFailedException — useful when one rule comes from one module and another from another.
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);
});
}
}What gets validated
| Kind | Default | Why |
|---|---|---|
| Commands | validated | They change state. |
| Queries | validated | It keeps filters and paging sane. |
| Events | not validated | An event describes something that already happened; rejecting it after the fact rarely helps. |
nexo.AddValidationPipeline(validation =>
{
validation.ValidateCommands = true;
validation.ValidateQueries = true;
validation.ValidateEvents = false;
// Off by default: you add a validator where you need one and ignore the rest.
// Turn it on when the team decides every command must have one.
validation.FailIfValidatorNotFound = false;
});Streaming queries follow ValidateQueries: validation runs before the first item. One specific message can skip validation with [SkipValidation] — an internal command, built by code that already guaranteed what the validator would check.
Without leaking FluentValidation
The failure is translated into ValidationFailedException, which lives in Nexo.Abstractions. It is the piece that keeps the web layer independent of whichever validation library you chose.
public sealed class ValidationFailedException : NexoException
{
public Type MessageType { get; }
public IReadOnlyList<ValidationError> Errors { get; }
// In the shape ValidationProblemDetails expects.
public IDictionary<string, string[]> ToDictionary();
}
public sealed record ValidationError(string Member, string Message, string? Code = null);// In the IExceptionHandler. No FluentValidation using here.
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"
}Before the transaction, on purpose
The validation pipeline is registered before the unit of work. An invalid command never makes the database open a transaction — and the sample's test confirms that nothing is written when validation refuses.
[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);
}Shape validation and business rules
A validator is good at shape: required, length, range, format. It is not the place for a rule that needs the database.
A rule that queries the database belongs in the handler, where a transaction already exists and where the check and the write are atomic. A validator that runs a SELECT opens a gap between the check and the write — and that is where two simultaneous requests both get through.
public async Task<Guid> HandleAsync(CreateProduct command, CancellationToken ct)
{
// Business rule: in the handler, inside the transaction.
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;
}Using another library
There is nothing special about the validation pipeline — it is an IPipeline. To use DataAnnotations, or a validator of your own, write one and throw ValidationFailedException. The whole error layer keeps working.
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 ?? "Invalid")));
}
return next(command, ct);
}
}