Minimal APIs the Right Way: Binding, Validation & Filters in ASP.NET Core

Building APIs Without the Controller Overhead

If you've ever built a simple REST API and felt like controllers added too much ceremony for basic CRUD operations, minimal APIs solve exactly that problem. You map routes directly to handler functions without the class structure, attribute routing, and base controller inheritance. For microservices or focused APIs, this reduces boilerplate significantly.

But minimal APIs aren't just about fewer lines of code. They bring challenges around parameter binding, validation, and reusability. Without the controller infrastructure, you need to handle these concerns explicitly. Inline lambda handlers work for demos but become unmaintainable in production. You need patterns for validation, error handling, and organization.

You'll learn how parameter binding works in minimal APIs, how to validate requests with filters, how to organize endpoints as they grow, and when to choose minimal APIs over traditional controllers.

Understanding Parameter Binding

Minimal APIs bind parameters automatically based on parameter type and route template. Simple types like int, string, or Guid bind from route values or query strings. Complex types bind from the JSON request body. You can make binding explicit with attributes like [FromRoute], [FromQuery], [FromHeader], or [FromBody].

The binding logic examines each parameter in your handler method. If the parameter name matches a route template placeholder, it binds from the route. Otherwise, it checks the query string. For complex objects, the framework assumes JSON body binding unless you specify otherwise.

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

// Route parameter binding
app.MapGet("/products/{id:int}", (int id) =>
{
    return Results.Ok(new { ProductId = id, Name = "Sample Product" });
});

// Query string binding
app.MapGet("/search", (string query, int page = 1, int pageSize = 10) =>
{
    return Results.Ok(new
    {
        Query = query,
        Page = page,
        PageSize = pageSize
    });
});

// Body binding for complex types
app.MapPost("/products", (CreateProductRequest request) =>
{
    // request is automatically deserialized from JSON body
    return Results.Created($"/products/{request.Id}", request);
});

app.Run();

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

The first endpoint binds id from the route template. The second endpoint binds query, page, and pageSize from query string parameters with default values. The third endpoint deserializes the entire JSON body into a CreateProductRequest record.

Making Binding Explicit with Attributes

When automatic binding isn't clear or you have naming conflicts, use binding source attributes. These attributes tell the framework exactly where to find each parameter. This improves readability and prevents bugs when parameter names change.

You can also bind from headers, services, or HTTP context. The [FromServices] attribute resolves dependencies from the DI container. The HttpContext parameter gives you access to the full request and response.

Program.cs
app.MapPost("/orders",
    async ([FromBody] CreateOrderRequest order,
           [FromHeader(Name = "X-User-Id")] string userId,
           [FromServices] IOrderService orderService,
           HttpContext context) =>
{
    if (string.IsNullOrEmpty(userId))
    {
        return Results.Unauthorized();
    }

    var result = await orderService.CreateOrderAsync(order, userId);

    context.Response.Headers["X-Order-Id"] = result.OrderId.ToString();

    return Results.Created($"/orders/{result.OrderId}", result);
});

This endpoint explicitly binds the order from the request body, the user ID from a custom header, and the order service from dependency injection. The HttpContext parameter provides access to response headers. Being explicit makes the binding logic obvious to future maintainers.

Adding Validation with Endpoint Filters

Minimal APIs don't have built-in validation like MVC controllers. You need to add validation manually or use endpoint filters. Endpoint filters intercept requests before they reach your handler, making them perfect for cross-cutting concerns like validation, logging, or authorization.

A validation filter receives the endpoint context and arguments, validates them, and either short-circuits with an error response or continues to the handler. This pattern keeps validation logic separate from business logic and makes it reusable across endpoints.

ValidationFilter.cs
public class ValidationFilter : IEndpointFilter where T : class
{
    public async ValueTask InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var argument = context.Arguments
            .OfType()
            .FirstOrDefault();

        if (argument is null)
        {
            return Results.BadRequest(new { error = "Request body is required" });
        }

        var validationErrors = ValidateObject(argument);

        if (validationErrors.Any())
        {
            return Results.ValidationProblem(validationErrors);
        }

        return await next(context);
    }

    private Dictionary ValidateObject(T obj)
    {
        var errors = new Dictionary();
        var validationContext = new ValidationContext(obj);
        var validationResults = new List();

        if (!Validator.TryValidateObject(obj, validationContext,
            validationResults, validateAllProperties: true))
        {
            foreach (var result in validationResults)
            {
                var memberName = result.MemberNames.FirstOrDefault() ?? "General";
                errors[memberName] = new[] { result.ErrorMessage ?? "Validation failed" };
            }
        }

        return errors;
    }
}

The filter extracts the typed argument from the context, validates it using DataAnnotations, and returns a validation problem response if errors exist. Otherwise, it calls the next delegate to continue to the handler. You apply this filter to specific endpoints or route groups.

Applying Filters to Endpoints

Once you create a filter, apply it to endpoints using AddEndpointFilter. You can add filters to individual endpoints or to route groups to apply them to multiple endpoints at once. Filters run in the order you add them, so place validation before authorization or other logic that depends on valid input.

Route groups let you share common configuration across related endpoints. You can add a prefix, apply filters, set metadata, or configure CORS policies for the entire group. This reduces duplication when multiple endpoints need the same setup.

Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped();
var app = builder.Build();

var products = app.MapGroup("/api/products")
    .AddEndpointFilter>()
    .RequireAuthorization();

products.MapGet("/", async (IProductService service) =>
{
    var results = await service.GetAllProductsAsync();
    return Results.Ok(results);
});

products.MapPost("/", async (CreateProductRequest request,
    IProductService service) =>
{
    var product = await service.CreateProductAsync(request);
    return Results.Created($"/api/products/{product.Id}", product);
});

products.MapPut("/{id:int}", async (int id, UpdateProductRequest request,
    IProductService service) =>
{
    await service.UpdateProductAsync(id, request);
    return Results.NoContent();
})
.AddEndpointFilter>();

app.Run();

public record CreateProductRequest(
    [Required] string Name,
    [Range(0.01, 999999)] decimal Price);

public record UpdateProductRequest(
    [Required] string Name,
    [Range(0.01, 999999)] decimal Price);

The route group applies a validation filter and requires authorization for all product endpoints. The POST endpoint inherits the group's validation filter for CreateProductRequest. The PUT endpoint adds an additional filter for UpdateProductRequest. This keeps validation logic centralized while allowing per-endpoint customization.

Organizing Endpoints for Maintainability

As your API grows, keeping all endpoints in Program.cs becomes messy. Move handler logic to static methods in separate classes. Create extension methods that encapsulate related endpoint mappings. This keeps Program.cs clean while preserving the minimal API programming model.

Use a consistent pattern where each feature gets its own static class with a Map extension method. These extension methods register all related endpoints and their dependencies. This approach scales to large applications while maintaining discoverability.

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

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

        return group;
    }

    private static async Task GetAllProducts(IProductService service)
    {
        var products = await service.GetAllProductsAsync();
        return Results.Ok(products);
    }

    private static async Task GetProductById(int id,
        IProductService service)
    {
        var product = await service.GetByIdAsync(id);
        return product is not null
            ? Results.Ok(product)
            : Results.NotFound();
    }

    private static async Task CreateProduct(
        CreateProductRequest request,
        IProductService service)
    {
        var product = await service.CreateProductAsync(request);
        return Results.Created($"/api/products/{product.Id}", product);
    }

    private static async Task UpdateProduct(int id,
        UpdateProductRequest request,
        IProductService service)
    {
        await service.UpdateProductAsync(id, request);
        return Results.NoContent();
    }

    private static async Task DeleteProduct(int id,
        IProductService service)
    {
        await service.DeleteProductAsync(id);
        return Results.NoContent();
    }
}

// In Program.cs
app.MapProductEndpoints();

Each endpoint handler is a static method that receives its dependencies via parameters. The Map extension method groups related endpoints and applies shared configuration. Program.cs becomes a simple list of feature mappings, making the application structure immediately clear.

Production Integration Tips

When deploying minimal APIs to production, configure proper OpenAPI documentation with Swashbuckle or NSwag. Add endpoint descriptions, parameter documentation, and response examples using WithOpenApi and other metadata methods. This makes your API self-documenting and easier for consumers to use.

Enable proper error handling with app.UseExceptionHandler or custom exception middleware. Return consistent problem details responses using Results.Problem or Results.ValidationProblem. Configure CORS policies, rate limiting, and authentication middleware in the correct order before mapping endpoints.

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

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddProblemDetails();

var app = builder.Build();

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

app.UseExceptionHandler();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();

app.MapProductEndpoints();

app.Run();

This configuration adds Swagger documentation for development, problem details for consistent errors, and the standard middleware pipeline. Minimal APIs integrate seamlessly with existing ASP.NET Core infrastructure while reducing the ceremony around endpoint definition.

Build a Complete Minimal API

This example shows a full CRUD API with validation, proper status codes, and organized endpoints. You'll see how all the patterns fit together in a working application.

Steps

  1. Initialize the project: dotnet new web -n MinimalApiDemo
  2. Change directory: cd MinimalApiDemo
  3. Replace Program.cs with the code below
  4. Start: dotnet run
  5. Test: curl -X POST http://localhost:5000/tasks -H "Content-Type: application/json" -d "{\"title\":\"Buy groceries\"}"
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 tasks = new List();

app.MapGet("/tasks", () => Results.Ok(tasks));

app.MapPost("/tasks", (CreateTaskRequest request) =>
{
    if (string.IsNullOrWhiteSpace(request.Title))
    {
        return Results.BadRequest(new { error = "Title is required" });
    }

    var task = new TodoTask
    {
        Id = tasks.Count + 1,
        Title = request.Title,
        IsCompleted = false
    };

    tasks.Add(task);
    return Results.Created($"/tasks/{task.Id}", task);
});

app.MapPut("/tasks/{id:int}/complete", (int id) =>
{
    var task = tasks.FirstOrDefault(t => t.Id == id);
    if (task is null)
    {
        return Results.NotFound();
    }

    task.IsCompleted = true;
    return Results.Ok(task);
});

app.Run();

public record CreateTaskRequest(string Title);
public class TodoTask
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public bool IsCompleted { get; set; }
}

Run result

POST /tasks -> 201 Created
{
  "id": 1,
  "title": "Buy groceries",
  "isCompleted": false
}

GET /tasks -> 200 OK
[
  {
    "id": 1,
    "title": "Buy groceries",
    "isCompleted": false
  }
]

Choosing the Right Approach

Choose minimal APIs when you want reduced ceremony, faster startup time, or you're building microservices with focused responsibilities. They excel at simple CRUD services, API gateways, or specialized endpoints that don't need the full controller infrastructure. The programming model is more functional, which some teams prefer for its directness.

Choose controllers when you have complex model binding requirements, need extensive filter pipelines, or your team prefers class-based organization. Controllers provide more structure out of the box, which helps larger teams maintain consistency. They also integrate better with existing MVC patterns if you're migrating from older ASP.NET versions.

You can mix both approaches in the same application. Use controllers for complex features and minimal APIs for simple endpoints. Both compile to the same routing and middleware infrastructure, so there's no performance difference in .NET 8. Let the complexity of each feature guide your choice rather than forcing one style everywhere.

Reader Questions

How does parameter binding work in minimal APIs?

Minimal APIs bind parameters automatically from route values, query strings, headers, and request body. Use [FromRoute], [FromQuery], [FromHeader], or [FromBody] attributes to be explicit. Complex types bind from JSON body by default. Simple types like int or string bind from route or query based on the route template.

What's the best way to validate requests in minimal APIs?

Use endpoint filters with FluentValidation or DataAnnotations. Create a validation filter that runs before your handler, validates the request, and returns a 400 response with errors if validation fails. This keeps validation logic separate from business logic and makes it reusable across endpoints.

Can I use filters like in MVC controllers?

Yes, minimal APIs support endpoint filters that work similarly to MVC filters. Implement IEndpointFilter and add logic before or after the endpoint handler executes. You can short-circuit execution, modify arguments, or wrap responses. Register filters with AddEndpointFilter on specific endpoints or globally via route groups.

How do I organize minimal APIs as they grow?

Use route groups to organize related endpoints and apply shared filters or prefixes. Move handler logic to static methods in separate classes rather than inline lambdas. Create extension methods like MapProductEndpoints that encapsulate all product-related routes. This keeps Program.cs clean while maintaining minimal API benefits.

Should I use minimal APIs or controllers for my project?

Use minimal APIs for microservices, simple CRUD services, or when you want reduced ceremony and faster startup. Use controllers for complex applications with many shared filters, model binding requirements, or when your team prefers the class-based structure. Both approaches are equally capable in .NET 8, so choose based on team preference and project complexity.

Back to Articles