.NET Minimal API Validation Fails Across Assemblies
Why .NET Minimal API validation breaks with generic endpoints in different assemblies. Source generator limits, resolver scanning issues, and workarounds like custom filters and FluentValidation for modular CQRS setups.
.NET Minimal API built-in validation fails when endpoint mapping is in a different assembly from the DTO
I’m using .NET Minimal API validation with AddValidation() and data annotations/generated resolvers in a modular setup across multiple assemblies:
- Host.Api: Entrypoint with DI wiring in
Program.cs. - Feature assemblies (e.g.,
Feature.Users): Contain endpoint definitions and DTOs. - Shared.Endpoints: Contains generic
MapPostwrappers (CQRS-style).
Scenario 1: Direct mapping in feature assembly (validation works)
// FeatureA
public static class FeatureAEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
app.MapPost("/items", (CreateItemRequest request) => Results.Ok());
}
}
public sealed class CreateItemRequest
{
[Required]
public string? Name { get; set; }
}
In Host.Api, aggregate resolvers from feature assemblies:
builder.Services.AddValidation(options =>
{
foreach (var resolver in DiscoverResolvers(typeof(FeatureAEndpoints).Assembly))
options.Resolvers.Add(resolver);
});
static IEnumerable<IValidatableInfoResolver> DiscoverResolvers(Assembly assembly) =>
assembly.GetTypes()
.Where(t => typeof(IValidatableInfoResolver).IsAssignableFrom(t)
&& !t.IsAbstract
&& t.GetConstructor(Type.EmptyTypes) != null)
.Select(t => (IValidatableInfoResolver)Activator.CreateInstance(t)!);
Feature assemblies also need a ValidationCodegenTrigger to run source generation:
internal static class ValidationCodegenTrigger
{
public static IServiceCollection Trigger(this IServiceCollection services)
=> services.AddValidation();
}
Scenario 2: Mapping via shared generic extension (validation breaks)
Feature assembly:
public static class FeatureAEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
app.MapCommand<CreateItemRequest>("/items");
}
}
Shared assembly:
public static class CommandEndpointExtensions
{
public static RouteHandlerBuilder MapCommand<TRequest>(
this IEndpointRouteBuilder app,
string pattern)
where TRequest : class
{
return app.MapPost(pattern, (TRequest request) => Results.Ok());
}
}
Observed behavior:
- JSON binding works.
- OpenAPI generation works.
- Endpoint executes.
- Validation does not run (no 400 for invalid requests), even with resolver scanning.
Questions
- Why does built-in Minimal API validation stop working when the endpoint mapping uses a generic extension from a different assembly?
- Why isn’t scanning and registering resolvers from other assemblies sufficient?
- What are the correct workarounds for this issue, and when is each required?
.NET Minimal API validation relies on source generators that analyze endpoint handler signatures at compile time to create type-specific resolvers, but it breaks down with generic extensions like MapCommand<TRequest> from a different assembly because the generator can’t resolve concrete DTO types like CreateItemRequest across boundaries. Manual resolver scanning from feature assemblies registers the metadata, yet the validation pipeline still skips checks since the endpoint’s compile-time handler doesn’t directly reference the concrete type. Reliable workarounds include custom endpoint filters for runtime validation or FluentValidation with assembly scanning, keeping your modular CQRS-style setup intact.
Contents
- How .NET Minimal API Validation Works
- Why Generics Across Assemblies Break It
- Limitations of Manual Resolver Scanning
- Workaround 1: Stick to Direct Mappings
- Workaround 2: Custom Endpoint Filters
- Workaround 3: FluentValidation Integration
- Best Practices for Modular Minimal APIs
- Sources
- Conclusion
How .NET Minimal API Validation Works
Picture this: you’re building a lean API in .NET 10, and validation just… works. No extra middleware, no custom filters. That’s the promise of built-in Minimal API validation. You call builder.Services.AddValidation() in Program.cs, slap some [Required] attributes on your DTOs, and boom—invalid requests get a tidy 400 Bad Request with ProblemDetails.
Under the hood? Source generators. At compile time, they scan your endpoint handlers for parameters marked with data annotations (think System.ComponentModel.DataAnnotations). For each concrete type like your CreateItemRequest, they spit out ValidatableTypeInfo and ValidatablePropertyInfo classes, plus an IValidatableInfoResolver implementation unique to that assembly. The official ASP.NET Core docs confirm this automatic hookup for class/record params.
But here’s the catch—it needs your .csproj to include <InterceptorsNamespaces>Microsoft.AspNetCore.Http.Validation.Generated</InterceptorsNamespaces> for the magic to trigger. And a dummy ValidationCodegenTrigger class in feature assemblies ensures generation runs. In Scenario 1, with direct app.MapPost("/items", (CreateItemRequest request) => ...), the generator sees the concrete type right there. Validation fires perfectly.
Why does this matter in modular setups? Feature assemblies define endpoints and DTOs, but sharing generic helpers? That’s where it unravels.
Why Generics Across Assemblies Break It
Generics sound great for DRY code—MapCommand<TRequest> keeps your CQRS endpoints clean. But swap direct mapping for that shared extension from Shared.Endpoints, and validation ghosts you. JSON binds fine. OpenAPI docs generate. The handler runs. Yet no validation errors on bad payloads.
The culprit: compile-time discovery. The source generator in the Shared assembly only sees TRequest : class. No concrete CreateItemRequest from Feature.Users. It generates nothing useful for your DTO. Meanwhile, Feature.Users generates its own resolver for CreateItemRequest, but the endpoint handler signature—buried in the generic extension—doesn’t link back to it.
A detailed StackOverflow thread nails this: generators produce per-assembly IValidatableInfoResolvers based on handler params visible at build time in that assembly. Cross-assembly generics hide the concrete type. GitHub issue #61971 shows the generated metadata sticking to what’s directly observable.
Even weirder edge cases pop up, like passing DbContext as params triggering 500s (GitHub #62173). Your setup hits the generic boundary perfectly.
Limitations of Manual Resolver Scanning
You tried scanning: DiscoverResolvers grabs IValidatableInfoResolvers from feature assemblies and adds them to ValidationOptions.Resolvers. Smart move! It works for direct mappings because the pipeline matches the endpoint’s handler type to a resolver.
But with generics? The validation middleware checks the handler’s parameter types at runtime, cross-referencing against registered resolvers. Since the generic MapCommand<TRequest> resolves to a handler expecting object or unbound generic, no match for your concrete DTO’s resolver. Scanning collects the metadata, but the pipeline ignores it without a direct compile-time tie.
Captainsafia’s demo repo breaks it down: generators intercept AddValidation to inject assembly-specific resolvers, but only for types discovered in-handler. Nikola Tech’s guide echoes this—manual addition helps, but not for indirect generics.
Short version: scanning is necessary but insufficient solo.
Workaround 1: Stick to Direct Mappings
Simplest fix? Ditch the generic for critical paths. In FeatureAEndpoints.Map, write:
app.MapPost("/items", (CreateItemRequest request) => Results.Ok());
Pros: Zero code changes beyond that. Keeps built-in validation humming. Your aggregator scanning stays golden.
Cons: Violates DRY if you have dozens of endpoints. Fine for prototypes or low-volume features. Use DisableValidation() on endpoints that don’t need it, per MS docs.
When? Small apps or when refactoring generics isn’t urgent.
Workaround 2: Custom Endpoint Filters
Endpoint filters to the rescue—runtime validation that’s modular and generic-friendly. Define a ValidationFilter<T>:
public class ValidationFilter<T> : IEndpointFilter where T : class
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var request = context.Arguments.OfType<T>().FirstOrDefault();
if (request == null) return Results.BadRequest("Invalid request");
var contextValidator = context.HttpContext.RequestServices.GetRequiredService<IValidator<T>>();
var result = await contextValidator.ValidateAsync(request);
if (!result.IsValid)
return Results.ValidationProblem(result.ToDictionary());
var response = await next(context);
return response;
}
}
Register validators (DataAnnotations or custom) via DI, then apply:
app.MapCommand<CreateItemRequest>("/items")
.AddEndpointFilter<ValidationFilter<CreateItemRequest>>();
Make it generic-er with reflection, or use AddRequestValidation<T>() extensions (Ivan Gechev’s blog). Habr article shows a fully generic version moving logic out of handlers.
When? You want lightweight, no third-parties, full control.
Workaround 3: FluentValidation Integration
For robust, battle-tested validation across assemblies, FluentValidation shines. Define validators in feature assemblies:
// Feature.Users
public class CreateItemRequestValidator : AbstractValidator<CreateItemRequest>
{
public CreateItemRequestValidator()
{
RuleFor(x => x.Name).NotEmpty();
}
}
In Host.Api:
builder.Services.AddValidatorsFromAssemblyContaining<CreateItemRequestValidator>();
// Or scan multiple: AddValidatorsFromAssembly(typeof(FeatureAEndpoints).Assembly)
Hook via endpoint filter or Validated<T> pattern (Khalid Abuhakmeh):
app.MapPost("/items", (Validated<CreateItemRequest> validated) => Results.Ok(validated.Value));
Supports IValidatableObject, nested objects, async rules. Official docs cover assembly scanning. Handles your modular setup seamlessly—no source gen woes.
When? Complex rules, CQRS with commands/queries, teams preferring Fluent.
Best Practices for Modular Minimal APIs
Hybrid approach: Use built-in for simple DTOs with direct maps. Filters/Fluent for generics/shared libs. Emit generated files (<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>) to debug source gen (Tim Deschryver).
Add builder.Services.AddProblemDetails() for consistent errors. Group endpoints: var users = app.MapGroup("/users"); users.MapCommand<...>();. Test with invalid JSON early.
.NET 10 validation overview stresses opt-in per feature. Future previews might fix cross-assembly generics—watch GitHub.
Sources
- StackOverflow: .NET Minimal APIs built-in validation stops working
- GitHub #61971: Minimal API Validation with types
- Microsoft Learn: Minimal APIs (ASP.NET Core 10.0)
- Nikola Tech: Minimal API Validation in .NET 10
- Captainsafia/minapi-validation-support
- FluentValidation Docs: ASP.NET Core
- Habr: Validation with Minimal API filters
- Ivan Gechev: Endpoint Validation Filters
- Development with a Dot: .NET 10 Validation
Conclusion
Built-in Minimal API validation excels for straightforward setups but stumbles on generic cross-assembly endpoint mappings due to source generator limits—your resolver scanning can’t bridge that gap alone. Opt for custom endpoint filters if you crave simplicity, or FluentValidation for scalable modular validation that scans assemblies effortlessly. Either way, your CQRS architecture stays clean; just pick the tool that fits your scale and never skip those invalid request tests.