Email copiado — support@tuurt.com
Cargando experiencia
dotnet · September 01, 2026 · 5 min

Validation in .NET Minimal APIs: why we replaced DataAnnotations with FluentValidation

DataAnnotations doesn't handle conditional or cross-field rules well. How we structured FluentValidation validators and wired them into a .NET Minimal API pipeline.

By Tuurt Team

Validation in .NET Minimal APIs: why we replaced DataAnnotations with FluentValidation

When we moved several endpoints of a project from traditional controllers to Minimal API in .NET, model validation was the first place the migration stopped being mechanical. In a controller decorated with [ApiController], the framework validates the model against its DataAnnotations automatically and returns a 400 with a ProblemDetails payload before the action code ever runs. Minimal API doesn't ship with that implicit behavior: there's no [ApiController] behind the scenes, so validation, if it exists at all, has to be wired in by hand. That difference forced us to decide what to replace it with, and that's where DataAnnotations stopped being enough.

Where DataAnnotations falls short

DataAnnotations works fine for per-property rules that don't depend on anything else: [Required], [MaxLength(100)], [EmailAddress]. The trouble shows up with two kinds of rules that, in our endpoints, were the norm rather than the exception.

The first is the conditional rule: a field is only required if another field has a specific value. In a shipment-creation endpoint, TrackingNumber is required when Status is Shipped, but makes no sense to demand while the order is still Pending. DataAnnotations doesn't express that natively; the fallback is implementing IValidatableObject and writing the condition by hand inside Validate, which in practice ends up as imperative code sitting alongside declarative attributes — two styles mixed in the same class.

The second is the cross-field rule: comparing two properties against each other. EndDate has to be later than StartDate, or DiscountAmount can't exceed TotalAmount. Again, IValidatableObject is the only reasonable way out, and again the result is two validation paradigms coexisting in the same model, with neither one ending up genuinely readable.

public class ShipmentRequest : IValidatableObject
{
    public string Status { get; set; } = string.Empty;
    public string? TrackingNumber { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext context)
    {
        if (Status == "Shipped" && string.IsNullOrWhiteSpace(TrackingNumber))
        {
            yield return new ValidationResult(
                "Tracking number is required when status is Shipped.",
                new[] { nameof(TrackingNumber) });
        }
    }
}

That works for one isolated case. It doesn't scale once a model has six or eight rules like this: the Validate method grows until it becomes the place where all the validation business logic lives, with no structure, no way to test a single rule in isolation, and no reuse across models that share the same condition.

FluentValidation as a replacement

FluentValidation separates the validation rule from the model. Instead of decorating properties, you define an AbstractValidator<T> class that describes the rules through a fluent API:

public class ShipmentRequestValidator : AbstractValidator<ShipmentRequest>
{
    public ShipmentRequestValidator()
    {
        RuleFor(x => x.Status)
            .NotEmpty()
            .Must(s => new[] { "Pending", "Shipped", "Delivered" }.Contains(s))
            .WithMessage("Status must be Pending, Shipped, or Delivered.");

        RuleFor(x => x.TrackingNumber)
            .NotEmpty()
            .When(x => x.Status == "Shipped")
            .WithMessage("Tracking number is required when status is Shipped.");

        RuleFor(x => x.EstimatedDeliveryDate)
            .GreaterThan(x => x.CreatedAt)
            .WithMessage("Estimated delivery date must be later than the creation date.");
    }
}

The conditional rule (.When) and the cross-field rule (GreaterThan comparing two properties of the same object) are first-class citizens of the API, not a special case that forces you outside the attribute system. Each RuleFor is an independent unit you can test without instantiating a ValidationContext, which is more awkward with IValidatableObject since that method mixes several rules inside a single block.

Wiring the validator into the Minimal API pipeline

Having the validator isn't enough: it has to run before the endpoint handler does, and return a consistent 400 when it fails. What we settled on is an IEndpointFilter, which in Minimal API plays a role equivalent to an action filter in controllers.

public class ValidationFilter<T> : IEndpointFilter
{
    private readonly IValidator<T> _validator;

    public ValidationFilter(IValidator<T> validator)
    {
        _validator = validator;
    }

    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var entity = context.Arguments.OfType<T>().FirstOrDefault();
        if (entity is null)
        {
            return Results.Problem("Could not find the model to validate.", statusCode: 500);
        }

        var result = await _validator.ValidateAsync(entity);
        if (!result.IsValid)
        {
            return Results.ValidationProblem(result.ToDictionary());
        }

        return await next(context);
    }
}

Results.ValidationProblem produces the same ProblemDetails shape that [ApiController] produces in traditional controllers, so clients see no contract change during the migration. Registering the endpoint looks like this:

app.MapPost("/shipments", (ShipmentRequest request, IShipmentService service) =>
{
    var shipment = service.Create(request);
    return Results.Created($"/shipments/{shipment.Id}", shipment);
})
.AddEndpointFilter<ValidationFilter<ShipmentRequest>>();

And registering the validators in the dependency injection container, using the FluentValidation.DependencyInjectionExtensions package:

builder.Services.AddValidatorsFromAssemblyContaining<ShipmentRequestValidator>();

This scans the assembly and registers every AbstractValidator<T> as IValidator<T>, so there's no need to register each validator by hand.

What didn't work: validating inside the handler

The first version we tried skipped IEndpointFilter entirely. We injected IValidator<T> directly into the handler and called ValidateAsync as the first line of every endpoint:

app.MapPost("/shipments", async (ShipmentRequest request, IValidator<ShipmentRequest> validator, IShipmentService service) =>
{
    var result = await validator.ValidateAsync(request);
    if (!result.IsValid)
    {
        return Results.ValidationProblem(result.ToDictionary());
    }

    var shipment = service.Create(request);
    return Results.Created($"/shipments/{shipment.Id}", shipment);
});

Functionally this is correct, but in practice it turned into a consistency problem: with fifteen endpoints written by different people, some called ValidateAsync at the top, others halfway through the function after some prior query, and at least two endpoints shipped without the validation call at all because nobody added it and nothing in the pipeline demanded it. There was no way to confirm, in a quick code review, that a new endpoint actually validated its input — you had to read the entire handler body to be sure.

Moving it to an IEndpointFilter fixed that at the root: validation is now declared in the endpoint's signature, alongside its other filters, visible without opening the handler body. An endpoint that receives a ShipmentRequest without .AddEndpointFilter<ValidationFilter<ShipmentRequest>>() is an omission that stands out during review, instead of a detail you have to verify by reading the internal logic.

What's still open

FluentValidation doesn't replace business validation that depends on external state, like checking that a TrackingNumber isn't already assigned to another shipment in the database. That kind of rule can live inside a validator through MustAsync, injecting the relevant repository, but we chose to keep it out of the validator and as an explicit part of the application service instead. Mixing format validation with a database query inside the same AbstractValidator complicates unit testing the validator: it suddenly needs a repository mock to test a rule that's really about the field's format. That separation — format and structure in the validator, rules that depend on system state in the service — is the criterion we keep applying as new endpoints get added.

dotnet minimal-api fluentvalidation backend
← Back to blog