In modern ASP.NET Core development, validation is a critical, non-negotiable part of building robust APIs. The default approach often involves using data annotations and checking ModelState within controller actions or using filters. While this works, it can lead to scattered validation logic and boilerplate code, especially in larger applications.

A more powerful and centralized approach is to use custom middleware for request validation. This strategy intercepts requests early in the pipeline, validates them, and rejects them before they even reach your API endpoints. This not only cleans up your endpoint logic but also creates a single, consistent validation gate for your entire application.

In this post, we’ll build a custom validation middleware from scratch, integrating the popular FluentValidation library to create a clean, efficient, and scalable validation system that completely bypasses ModelState.

The Problem with Traditional Validation

The standard [ApiController] attribute in ASP.NET Core automatically triggers model validation and returns a 400 Bad Request if ModelState is invalid. This is convenient but has a few drawbacks:

  • Coupling: The validation logic is tightly coupled to the MVC/API Controller framework. It’s less straightforward to apply the same automatic behavior consistently in Minimal APIs without extra boilerplate.
  • Scattered Logic: Validation rules defined via attributes can become unwieldy and are spread across numerous DTOs.
  • Repetitive Code: Custom validation scenarios often require creating repetitive custom attributes or logic inside action methods.

A middleware-based approach solves these issues by creating a single, reusable component that handles all incoming request validation.

Setting Up the Project

Let’s start with a new ASP.NET Core Web API project. First, we need to install the necessary NuGet package for FluentValidation.

dotnet add package FluentValidation.AspNetCore

Next, define a simple DTO and a corresponding validator. We’ll use a CreateProductRequest model.

// Models/CreateProductRequest.cs
public record CreateProductRequest(string Sku, string Name, decimal Price);

Now, let’s create a validator for this record using FluentValidation.

// Validators/CreateProductRequestValidator.cs
using FluentValidation;
using YourApp.Models;

public class CreateProductRequestValidator : AbstractValidator<CreateProductRequest>
{
    public CreateProductRequestValidator()
    {
        RuleFor(x => x.Sku)
            .NotEmpty()
            .Length(5, 10)
            .WithMessage("SKU must be between 5 and 10 characters.");

        RuleFor(x => x.Name)
            .NotEmpty()
            .MaximumLength(100);

        RuleFor(x => x.Price)
            .GreaterThan(0);
    }
}

Finally, register FluentValidation’s services in Program.cs.

// Program.cs
var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// Register all validators from the assembly
builder.Services.AddValidatorsFromAssemblyContaining<Program>();

var app = builder.Build();

// ... rest of the pipeline

AddValidatorsFromAssemblyContaining<Program>() automatically scans the specified assembly and registers all classes that inherit from AbstractValidator.

Building the Custom Validation Middleware

This is where the magic happens. We’ll create a middleware component that intercepts HTTP requests, identifies if the request has a body that needs validation, performs the validation, and either passes the request on or short-circuits it with an error response.

// Middleware/ValidationMiddleware.cs
using FluentValidation;
using Microsoft.AspNetCore.Http.Extensions;
using System.Net;

public class ValidationMiddleware
{
    private readonly RequestDelegate _next;

    public ValidationMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context, IServiceProvider serviceProvider)
    {
        // We only validate POST and PUT requests
        if (context.Request.Method != HttpMethods.Post && context.Request.Method != HttpMethods.Put)
        {
            await _next(context);
            return;
        }
        
        // The endpoint gives us access to metadata, including the request DTO type
        var endpoint = context.GetEndpoint();
        if (endpoint is null)
        {
            await _next(context);
            return;
        }

        // Find the DTO type from the endpoint's metadata
        var requestType = endpoint.Metadata
            .OfType<IAcceptsMetadata>()
            .FirstOrDefault()?
            .RequestType;

        if (requestType is null)
        {
            await _next(context);
            return;
        }

        // Enable buffering so the stream can be read multiple times
        context.Request.EnableBuffering();
        
        // De-serialize the request body
        var requestBody = await context.Request.ReadFromJsonAsync(requestType);
        
        if (requestBody is null)
        {
            await _next(context);
            return;
        }

        // Construct the generic validator type and resolve it
        var validatorType = typeof(IValidator<>).MakeGenericType(requestType);
        var validator = serviceProvider.GetService(validatorType) as IValidator;

        if (validator is null)
        {
            await _next(context);
            return;
        }

        // Validate the request object
        var validationResult = await validator.ValidateAsync(new ValidationContext<object>(requestBody));

        if (!validationResult.IsValid)
        {
            var errors = validationResult.Errors
                .GroupBy(e => e.PropertyName)
                .ToDictionary(k => k.Key, v => v.Select(e => e.ErrorMessage).ToArray());
            
            context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
            await context.Response.WriteAsJsonAsync(new {
                Title = "Validation Failed",
                Status = (int)HttpStatusCode.BadRequest,
                Errors = errors
            });
            return;
        }

        // Rewind the stream for the next middleware/endpoint
        context.Request.Body.Position = 0;

        await _next(context);
    }
}

Callout: Why We Use Endpoint Metadata

A key part of this middleware is context.GetEndpoint(). Instead of hardcoding logic for specific DTOs, we dynamically inspect the endpoint metadata to discover the expected request type (IAcceptsMetadata). This makes the middleware incredibly generic and reusable. It automatically adapts to any endpoint that declares what type of data it accepts, which is standard practice for Minimal APIs defined with MapPost<TRequest>(...) or controllers with [FromBody] attributes.

Key Steps in the Middleware

  1. Filter Requests: The middleware only acts on POST and PUT requests, as these are typically the ones with bodies that require validation.
  2. Get Request Type: It inspects the endpoint metadata to find the DTO type. This is a clean, modern way to make the middleware generic.
  3. Enable Buffering: To read the request body, we must call context.Request.EnableBuffering(). This allows the body stream to be read and then “rewound” for the actual endpoint handler to read later.
  4. Deserialize and Validate: It reads the JSON body, resolves the appropriate IValidator from the DI container, and executes the validation.
  5. Short-Circuit on Failure: If validation fails, it constructs a standard error response (similar to a ValidationProblemDetails object) and writes it to the response, ending the request processing.
  6. Rewind and Continue: If validation succeeds, it rewinds the request body stream (context.Request.Body.Position = 0;) and calls _next(context) to pass control down the pipeline.

Registering the Middleware

Now, we just need to add our ValidationMiddleware to the request pipeline in Program.cs. It should be placed before the endpoint routing middleware (UseRouting and UseEndpoints or Map... calls).

// Program.cs

// ... after builder.Build()

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

// Add our custom middleware here
app.UseMiddleware<ValidationMiddleware>();

app.UseAuthorization();

app.MapControllers();

// Our clean Minimal API endpoint
app.MapPost("/products", (CreateProductRequest request) => {
    // If we reach here, the request is guaranteed to be valid.
    return Results.Created($"/products/1", request);
})
.Accepts<CreateProductRequest>("application/json") // This metadata is crucial
.Produces<CreateProductRequest>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest);


app.Run();

Notice how clean the Minimal API endpoint is. There’s no validation logic, no if (!ModelState.IsValid), and no attributes. The .Accepts<CreateProductRequest>("application/json") call is what provides the metadata our middleware needs to discover the DTO type.

Testing the Result

Now, run your application and send some requests to the /products endpoint.

Invalid Request: POST /products

{
  "sku": "123",
  "name": "",
  "price": -10
}

Response (Status: 400 Bad Request):

{
  "title": "Validation Failed",
  "status": 400,
  "errors": {
    "Sku": [
      "SKU must be between 5 and 10 characters."
    ],
    "Name": [
      "'Name' must not be empty."
    ],
    "Price": [
      "'Price' must be greater than '0'."
    ]
  }
}

Valid Request: POST /products

{
  "sku": "ABCDE123",
  "name": "Super Widget",
  "price": 99.99
}

Response (Status: 201 Created): The request successfully passes through the middleware and is processed by the endpoint.

Conclusion

By moving validation logic into a custom middleware, you create a cleaner, more maintainable, and highly reusable system. This approach centralizes your application’s validation rules, decouples them from your endpoint logic, and ensures that all relevant requests are validated consistently before they consume valuable system resources. It’s a powerful pattern that scales beautifully for both simple services and complex enterprise applications.

References