Minimal APIs in .NET 8: Endpoint Filters, Route Groups & Binding Updates

Building APIs That Scale

Imagine building a product catalog API where every endpoint needs consistent validation, caching headers, and rate limiting. Without proper organization, you'll repeat the same code across dozens of endpoints. Route groups and filters in .NET 8 solve this by letting you apply common logic once and inherit it everywhere.

Minimal APIs already gave you lightweight endpoints without controllers. .NET 8 adds endpoint filters for request/response interception, route groups for organizing related endpoints, and improved parameter binding that reduces boilerplate. These features bring minimal APIs closer to MVC functionality while keeping the code concise.

You'll build a working API with grouped routes, validation filters, and custom parameter binding. By the end, you'll know when to use filters versus middleware, how to organize routes logically, and how to test your endpoints properly.

Endpoint Filters for Request Pipeline Logic

Endpoint filters run after routing but before your handler executes. They can inspect or modify the request, short-circuit execution, or transform the response. Unlike middleware, filters are scoped to specific endpoints or route groups and have access to the endpoint's metadata.

Use filters for validation, caching, logging, or any logic that applies to a subset of endpoints. They implement IEndpointFilter with a single InvokeAsync method that receives the context and a next delegate. Call next to continue the pipeline or return early to short-circuit.

ValidationFilter.cs
public class ValidationFilter : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        // Check if any arguments are null or invalid
        foreach (var arg in context.Arguments)
        {
            if (arg is null)
            {
                return Results.BadRequest("Request contains null values");
            }
        }

        // Continue to next filter or handler
        return await next(context);
    }
}

public record CreateProductRequest(string Name, decimal Price, int Stock);

// Apply filter to endpoint
app.MapPost("/api/products", (CreateProductRequest product) =>
{
    // Validation already happened in filter
    return Results.Created($"/api/products/{Guid.NewGuid()}", product);
})
.AddEndpointFilter<ValidationFilter>();

The filter runs before the handler, checking for null arguments. If validation fails, it returns BadRequest without calling the handler. This pattern keeps validation logic separate from business logic and makes it reusable across multiple endpoints.

Organizing Endpoints with Route Groups

Route groups let you apply common prefixes, filters, metadata, and policies to multiple endpoints. Instead of repeating /api/products on every route, define it once on the group. Add authorization or rate limiting at the group level and all child routes inherit it.

Groups support nesting, so you can create hierarchies like /api/v1/products and /api/v1/orders. Each group can add its own middleware, filters, or requirements. This organizational structure makes your Program.cs much cleaner as your API grows.

Program.cs - Route Groups
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Create route group with common prefix and filters
var productsGroup = app.MapGroup("/api/products")
    .AddEndpointFilter<ValidationFilter>()
    .WithTags("Products");

// All endpoints inherit /api/products prefix and validation
productsGroup.MapGet("/", () =>
    Results.Ok(new[] { "Product1", "Product2" }));

productsGroup.MapGet("/{id:int}", (int id) =>
    Results.Ok($"Product {id}"));

productsGroup.MapPost("/", (CreateProductRequest product) =>
    Results.Created($"/api/products/{Guid.NewGuid()}", product));

productsGroup.MapPut("/{id:int}", (int id, CreateProductRequest product) =>
    Results.Ok($"Updated product {id}"));

productsGroup.MapDelete("/{id:int}", (int id) =>
    Results.NoContent());

// Create orders group with different configuration
var ordersGroup = app.MapGroup("/api/orders")
    .RequireAuthorization()
    .WithTags("Orders");

ordersGroup.MapGet("/", () => Results.Ok("Orders list"));
ordersGroup.MapGet("/{id:int}", (int id) => Results.Ok($"Order {id}"));

app.Run();

The products group applies ValidationFilter to all five endpoints automatically. The orders group requires authorization on every route without repeating the attribute. Tags help with OpenAPI documentation, grouping endpoints logically in Swagger UI.

Improved Parameter Binding with AsParameters

Minimal API endpoints bind parameters from route values, query strings, headers, and the request body automatically. .NET 8 adds AsParameters, which lets you group multiple parameters into a single type. This reduces the parameter count and makes your handler signatures cleaner.

Decorate your parameter class with [AsParameters] and the framework binds its properties from the appropriate sources. Route values match by name, query strings bind to properties, and you can use [FromHeader] or [FromServices] on individual properties for explicit binding.

AsParametersExample.cs
// Without AsParameters - messy signature
app.MapGet("/products/search", (
    string? name,
    int? minPrice,
    int? maxPrice,
    int page,
    int pageSize,
    string? sortBy) =>
{
    return Results.Ok("Search results");
});

// With AsParameters - clean signature
public record ProductSearchParameters(
    string? Name,
    int? MinPrice,
    int? MaxPrice,
    int Page = 1,
    int PageSize = 20,
    string? SortBy = "name"
);

app.MapGet("/products/search", ([AsParameters] ProductSearchParameters search) =>
{
    // All query parameters bound to search object
    var results = $"Searching: {search.Name}, Page {search.Page}";
    return Results.Ok(results);
});

// Mixing route, query, and services
public record OrderDetailsRequest(
    [FromRoute] int Id,
    [FromQuery] bool IncludeItems,
    [FromServices] IOrderRepository Repository
);

app.MapGet("/orders/{id}", async ([AsParameters] OrderDetailsRequest request) =>
{
    var order = await request.Repository.GetByIdAsync(request.Id);
    return order is not null ? Results.Ok(order) : Results.NotFound();
});

AsParameters makes your intent clear and reduces noise in method signatures. The framework handles binding from route, query, headers, and DI container automatically. This pattern works especially well with many optional query parameters like filters and pagination.

Combining Filters, Groups, and Binding

Real APIs combine all these features. You'll have route groups with common prefixes, filters for cross-cutting concerns, and AsParameters for clean signatures. Organizing this properly from the start prevents sprawling Program.cs files.

Consider creating extension methods that configure entire feature areas. A products API might include CRUD endpoints, search, and batch operations. Group them together, apply common filters, and extract the registration logic into a static method.

ProductEndpoints.cs
public static class ProductEndpoints
{
    public static RouteGroupBuilder MapProductEndpoints(this IEndpointRouteBuilder routes)
    {
        var group = routes.MapGroup("/api/products")
            .WithTags("Products")
            .AddEndpointFilter<ValidationFilter>()
            .AddEndpointFilter<LoggingFilter>();

        group.MapGet("/", GetAllProducts);
        group.MapGet("/{id:int}", GetProductById);
        group.MapGet("/search", SearchProducts);
        group.MapPost("/", CreateProduct);
        group.MapPut("/{id:int}", UpdateProduct);
        group.MapDelete("/{id:int}", DeleteProduct);

        return group;
    }

    private static IResult GetAllProducts()
    {
        return Results.Ok(new[] { "Product1", "Product2" });
    }

    private static IResult GetProductById(int id)
    {
        return Results.Ok($"Product {id}");
    }

    private static IResult SearchProducts([AsParameters] ProductSearchParameters search)
    {
        return Results.Ok($"Search: {search.Name}");
    }

    private static IResult CreateProduct(CreateProductRequest product)
    {
        return Results.Created($"/api/products/{Guid.NewGuid()}", product);
    }

    private static IResult UpdateProduct(int id, CreateProductRequest product)
    {
        return Results.Ok($"Updated {id}");
    }

    private static IResult DeleteProduct(int id)
    {
        return Results.NoContent();
    }
}

// In Program.cs
var app = builder.Build();
app.MapProductEndpoints();
app.Run();

This extension method encapsulates the entire products API. Your Program.cs stays clean with a single line per feature area. Filters apply to all endpoints in the group, and you can easily add more endpoints or change group-level configuration without touching Program.cs.

Integration with ASP.NET Core Services

Minimal APIs integrate seamlessly with ASP.NET Core's service container. Inject dependencies directly into your handlers or use [FromServices] explicitly. The framework resolves services from the DI container automatically when you add them as parameters.

Register your services in the builder like any other ASP.NET Core app. Scoped services last for the request lifetime, singletons persist for the app's lifetime, and transient services create new instances each time. The same DI patterns from MVC controllers apply here.

ServiceIntegration.cs
var builder = WebApplication.CreateBuilder(args);

// Register services
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddSingleton<ICacheService, MemoryCacheService>();

var app = builder.Build();

// Implicit service injection
app.MapGet("/products", (IProductRepository repo) =>
{
    var products = repo.GetAll();
    return Results.Ok(products);
});

// Explicit with FromServices
app.MapGet("/products/{id}",
    (int id, [FromServices] IProductRepository repo) =>
{
    var product = repo.GetById(id);
    return product is not null ? Results.Ok(product) : Results.NotFound();
});

// Multiple services with AsParameters
public record OrderRequest(
    [FromRoute] int Id,
    [FromServices] IOrderRepository OrderRepo,
    [FromServices] ICacheService Cache
);

app.MapGet("/orders/{id}", async ([AsParameters] OrderRequest request) =>
{
    var cacheKey = $"order-{request.Id}";
    var cached = request.Cache.Get<Order>(cacheKey);
    if (cached is not null) return Results.Ok(cached);

    var order = await request.OrderRepo.GetByIdAsync(request.Id);
    if (order is not null) request.Cache.Set(cacheKey, order, TimeSpan.FromMinutes(5));

    return order is not null ? Results.Ok(order) : Results.NotFound();
});

app.Run();

Services resolve automatically based on parameter types. The framework matches registered services to handler parameters by type. Use [FromServices] when you have ambiguous parameters or want to be explicit about service injection.

Web QuickStart

Build a working API with route groups, filters, and service injection. This example demonstrates all the features covered in this article with a runnable project you can test locally.

MinimalApiDemo.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>
Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var apiGroup = app.MapGroup("/api")
    .AddEndpointFilter(async (context, next) =>
    {
        Console.WriteLine($"Request: {context.HttpContext.Request.Method} " +
                         $"{context.HttpContext.Request.Path}");
        var result = await next(context);
        Console.WriteLine($"Response: {context.HttpContext.Response.StatusCode}");
        return result;
    });

var products = apiGroup.MapGroup("/products").WithTags("Products");

products.MapGet("/", () => Results.Ok(new[]
{
    new { Id = 1, Name = "Laptop", Price = 999.99 },
    new { Id = 2, Name = "Mouse", Price = 29.99 }
}));

products.MapGet("/{id:int}", (int id) =>
    id switch
    {
        1 => Results.Ok(new { Id = 1, Name = "Laptop", Price = 999.99 }),
        2 => Results.Ok(new { Id = 2, Name = "Mouse", Price = 29.99 }),
        _ => Results.NotFound()
    });

products.MapPost("/", (Product product) =>
{
    Console.WriteLine($"Creating product: {product.Name}");
    return Results.Created($"/api/products/{Random.Shared.Next(100)}", product);
});

app.Run();

record Product(string Name, decimal Price);

Steps

  1. Create project: dotnet new web -n MinimalApiDemo
  2. Navigate: cd MinimalApiDemo
  3. Replace Program.cs with the code above
  4. Start the API: dotnet run
  5. Test with curl: curl http://localhost:5000/api/products
  6. Create product: curl -X POST http://localhost:5000/api/products -H "Content-Type: application/json" -d '{"name":"Keyboard","price":79.99}'

Console Output

Request: GET /api/products
Response: 200
[{"id":1,"name":"Laptop","price":999.99},{"id":2,"name":"Mouse","price":29.99}]

Real-World Usage

Production minimal APIs need error handling, validation, authentication, and observability. Use ProblemDetails for consistent error responses, FluentValidation for complex validation rules, and built-in authentication middleware for security. Wire up structured logging to track request flow and performance.

Register global exception handling middleware early in the pipeline. Add authentication and authorization middleware before mapping endpoints. Use endpoint filters for request-specific concerns like rate limiting or caching headers. Keep middleware for cross-cutting concerns that apply to all requests.

For observability, emit metrics with IMeterFactory and logs with ILogger. Track request duration, response status codes, and error rates. Add health check endpoints for monitoring. Use OpenTelemetry to send traces and metrics to your observability platform.

When your API grows beyond a few dozen endpoints, organize them into separate files by feature area. Use extension methods to register each area, keeping Program.cs minimal. Add integration tests with WebApplicationFactory to verify routing, binding, and business logic together.

Common Questions

When should I use endpoint filters instead of middleware?

Use endpoint filters for logic specific to one endpoint or route group, like validation or caching. Filters run after routing and have access to endpoint metadata. Use middleware for cross-cutting concerns that apply to all requests, like authentication or logging. Filters are scoped, middleware is global.

How do route groups improve API organization?

Route groups let you apply common prefixes, filters, and policies to multiple endpoints at once. Define a group for /api/products and all child routes inherit the prefix. Add authorization or rate limiting to the group instead of repeating it on each endpoint.

Does AsParameters work with complex nested objects?

AsParameters flattens properties from a class into individual route, query, and header bindings. It doesn't handle deep nesting. For complex JSON payloads, bind from body as usual. Use AsParameters to organize multiple primitive parameters into a single type for cleaner signatures.

Can I test minimal API endpoints with WebApplicationFactory?

Yes, WebApplicationFactory works perfectly with minimal APIs. Create a factory pointing to your Program class, use CreateClient to get an HttpClient, then call your endpoints. Override services in ConfigureWebHost to inject test doubles. Integration tests catch routing and binding issues early.

Back to Articles