Minimal APIs are popular in ASP.NET Core because they keep API endpoints small and easy to follow.

A typical endpoint can be as simple as:

app.MapGet("/products/{id}", (int id) =>
{
    return Results.Ok(new
    {
        Id = id
    });
});

ASP.NET Core takes care of binding the id value from the route and converting it to an int.

But what happens when the value cannot be converted?

For example:

GET /products/abc

The endpoint handler cannot receive "abc" as an int.

Before .NET 11, this created an important limitation for endpoint filters. Parameter binding happened before the endpoint filter pipeline, so a filter could not reliably inspect and customize a response when parameter binding failed.

ASP.NET Core 11 changes this behavior.

When an endpoint has endpoint filters or filter factories configured, the filter pipeline can now run even when parameter binding fails. The filter can inspect the 400 Bad Request response and replace the response body with an application-specific format.

This is useful when an API needs consistent error responses across both validation failures and binding failures.

What Is Parameter Binding?

Parameter binding is the process ASP.NET Core uses to obtain values for endpoint parameters.

Consider:

app.MapGet("/products/{id}", (int id) =>
{
    return Results.Ok(id);
});

For this request:

GET /products/25

ASP.NET Core performs roughly this operation:

Route value
    |
    v
"25"
    |
    v
Convert to int
    |
    v
25
    |
    v
Endpoint handler

The handler receives:

int id

and can continue normally.

But this request is different:

GET /products/abc

The framework cannot convert "abc" into an integer.

The problem happens before the handler can execute.

What Happens When Binding Fails?

Minimal APIs can bind parameters from several sources, including:

  • Route values

  • Query strings

  • Headers

  • Request bodies

  • Services

  • Custom binding logic

For example:

app.MapGet(
    "/products/{id}",
    (int id, string? category) =>
    {
        return Results.Ok();
    });

Here:

id
↓
Route

category
↓
Query string

A request such as:

/products/25?category=laptop

can be bound successfully.

But:

/products/not-a-number?category=laptop

causes the id binding to fail.

ASP.NET Core normally returns 400 Bad Request for this type of binding failure.

The Problem Before .NET 11

Suppose you created an endpoint filter to standardize API errors:

app.MapGet(
    "/products/{id}",
    (int id) =>
    {
        return Results.Ok(id);
    })
    .AddEndpointFilter(async (context, next) =>
    {
        try
        {
            return await next(context);
        }
        catch (Exception ex)
        {
            return Results.Problem(
                "Something went wrong.");
        }
    });

It is easy to assume that this filter can handle every endpoint failure.

It cannot.

Parameter binding occurs before the endpoint handler gets its arguments.

So when:

/products/abc

fails to bind to:

int id

the handler is never called.

Historically, the endpoint filter pipeline also did not get an opportunity to process that binding failure.

That made it difficult to create a single filter-based strategy for all endpoint errors.

What Changes in ASP.NET Core 11?

ASP.NET Core 11 changes the order of what filters can observe.

If an endpoint has a filter or filter factory configured, the filter pipeline now runs even when parameter binding fails. The filter can check:

context.HttpContext.Response.StatusCode == 400

and replace the response with its own result.

The important part is that the filter does not magically make the failed parameter available.

The parameter is still invalid.

What changes is that your filter gets an opportunity to handle the resulting 400 response.

The flow becomes:

Request
   |
   v
Parameter Binding
   |
   X
Binding fails
   |
   v
400 response
   |
   v
Endpoint Filter
   |
   v
Custom error response

This is particularly useful for APIs that need a consistent error format.

A Simple Example

Start with a normal Minimal API:

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.MapGet("/products/{id}", (int id) =>
{
    return Results.Ok(new
    {
        Id = id,
        Name = "Laptop"
    });
});

app.Run();

A valid request:

GET /products/10

returns:

{
  "id": 10,
  "name": "Laptop"
}

Now send:

GET /products/abc

The framework cannot bind "abc" to int.

The result is:

400 Bad Request

The endpoint handler never receives an id.

Adding an Endpoint Filter

Now add a filter:

app.MapGet("/products/{id}", (int id) =>
{
    return Results.Ok(new
    {
        Id = id,
        Name = "Laptop"
    });
})
.AddEndpointFilter(async (context, next) =>
{
    var result = await next(context);

    if (context.HttpContext.Response.StatusCode == 400)
    {
        return Results.Problem(
            statusCode: 400,
            title: "Invalid request",
            detail: "One or more request parameters are invalid.");
    }

    return result;
});

The important line is:

var result = await next(context);

The filter allows the pipeline to continue.

If parameter binding succeeds, the endpoint executes normally.

If parameter binding fails and produces a 400, the filter can recognize the status code and provide its own result.

Returning a Consistent API Error

A real API often has a standard error structure.

For example:

{
  "type": "https://example.com/errors/invalid-request",
  "title": "Invalid request",
  "status": 400,
  "detail": "One or more request parameters are invalid."
}

Instead of returning different error formats from different endpoints, a filter can help standardize them.

A simple filter could be:

static async ValueTask<object?> HandleErrors(
    EndpointFilterInvocationContext context,
    EndpointFilterDelegate next)
{
    var result = await next(context);

    if (context.HttpContext.Response.StatusCode == 400)
    {
        return Results.Problem(
            statusCode: StatusCodes.Status400BadRequest,
            title: "Invalid request",
            detail: "The request contains invalid parameters.");
    }

    return result;
}

Then:

app.MapGet(
    "/products/{id}",
    (int id) => Results.Ok(id))
    .AddEndpointFilter(HandleErrors);

This keeps the endpoint itself simple.

Checking the HTTP Status Code

The key part of the .NET 11 behavior is:

context.HttpContext.Response.StatusCode

For example:

if (context.HttpContext.Response.StatusCode == 400)
{
    // Customize the response
}

This is different from catching an exception.

A binding failure is normally represented as a bad request rather than something your endpoint handler should catch.

That distinction is important.

Do not write a filter assuming that every binding failure arrives as an exception.

Instead, inspect the response status after invoking the next filter or endpoint.

Development Environment Behavior

There is an important development-mode detail.

In the Development environment, ASP.NET Core can throw BadHttpRequestException for bad requests instead of allowing the filter to simply observe the 400 response.

To allow the filter to observe the bad request in development, set:

builder.Services.Configure<RouteHandlerOptions>(
    options =>
    {
        options.ThrowOnBadRequest = false;
    });

The default is already false outside the Development environment.

A complete setup can therefore look like:

var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<RouteHandlerOptions>(
    options =>
    {
        options.ThrowOnBadRequest = false;
    });

var app = builder.Build();

This is useful when you want local development behavior to match the behavior of a deployed application.

Why ThrowOnBadRequest Matters

Imagine developing locally with:

Development

and requesting:

/products/abc

If ThrowOnBadRequest is enabled, you may see a BadHttpRequestException and the developer exception page.

That can make it appear as though your filter is not working.

The filter needs the framework to produce the 400 response so it can observe it.

Therefore:

options.ThrowOnBadRequest = false;

allows the request to follow the response-based path.

The important point is not that exceptions are bad.

The point is that a filter designed to customize the HTTP response needs a response to inspect.

Handling Query Parameter Failures

The same idea applies to query-string binding.

Consider:

app.MapGet(
    "/products",
    (int page, int pageSize) =>
    {
        return Results.Ok(new
        {
            Page = page,
            PageSize = pageSize
        });
    })
    .AddEndpointFilter(HandleErrors);

This request works:

/products?page=1&pageSize=25

But:

/products?page=abc&pageSize=25

cannot bind page to an int.

The filter can now observe the resulting 400 and return the application's standard error format.

This is useful for APIs that have many numeric query parameters.

Handling JSON Body Binding Failures

Consider a request model:

public sealed class CreateProductRequest
{
    public string Name { get; set; } = string.Empty;

    public decimal Price { get; set; }
}

The endpoint:

app.MapPost(
    "/products",
    (CreateProductRequest request) =>
    {
        return Results.Ok(request);
    })
    .AddEndpointFilter(HandleErrors);

A valid request might be:

{
  "name": "Laptop",
  "price": 75000
}

But this request is invalid:

{
  "name": "Laptop",
  "price": "not-a-number"
}

JSON deserialization cannot convert the string into a decimal.

Minimal API binding failures for JSON body deserialization result in a 400 response.

The .NET 11 endpoint-filter behavior makes it possible for a configured filter to participate in the response handling for these failures as well.

Binding Failure vs Validation Failure

These two concepts are easy to mix up.

They are not the same.

Binding Failure

The framework cannot create the parameter value.

Example:

?page=abc

when the parameter is:

int page

The problem is conversion.

Validation Failure

The framework successfully creates the object, but the value violates a rule.

For example:

public sealed class ProductRequest
{
    [Required]
    public string Name { get; set; } = string.Empty;

    [Range(1, 1000000)]
    public decimal Price { get; set; }
}

This JSON can deserialize:

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

But validation fails.

That is a different stage.

.NET 11 also has expanded validation support for Minimal APIs, so applications can use validation attributes and related validation mechanisms.

The filter behavior discussed in this article is especially useful for the earlier binding stage.

Why This Is Useful for API Standards

Suppose an organization has a standard API error format:

{
  "code": "INVALID_REQUEST",
  "message": "The request could not be processed.",
  "errors": [
    {
      "field": "page",
      "message": "The value must be an integer."
    }
  ]
}

Without centralized handling, different endpoints may return different responses.

One endpoint might return:

{
  "detail": "Bad Request"
}

Another might return:

{
  "error": "Invalid parameter"
}

A third might expose framework-generated information.

That makes client-side error handling unnecessarily difficult.

Endpoint filters provide a place where application-specific response behavior can be centralized.

Building a Reusable Filter

Instead of adding the same inline filter everywhere, create a reusable filter.

For example:

public sealed class BadRequestFilter : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var result = await next(context);

        if (context.HttpContext.Response.StatusCode == 400)
        {
            return Results.Problem(
                statusCode: 400,
                title: "Invalid request",
                detail: "The request contains invalid values.");
        }

        return result;
    }
}

Register it:

app.MapGet(
    "/products/{id}",
    (int id) => Results.Ok(id))
    .AddEndpointFilter<BadRequestFilter>();

You can then reuse the same filter:

app.MapGet(
    "/customers/{id}",
    (int id) => Results.Ok(id))
    .AddEndpointFilter<BadRequestFilter>();

app.MapGet(
    "/orders/{id}",
    (int id) => Results.Ok(id))
    .AddEndpointFilter<BadRequestFilter>();

This is much cleaner than duplicating error-handling code.

Applying a Filter to a Route Group

If many endpoints need the same behavior, a route group is even more useful.

var api = app.MapGroup("/api")
    .AddEndpointFilter<BadRequestFilter>();

Then:

api.MapGet(
    "/products/{id}",
    (int id) => Results.Ok(id));

api.MapGet(
    "/customers/{id}",
    (int id) => Results.Ok(id));

api.MapGet(
    "/orders/{id}",
    (int id) => Results.Ok(id));

The filter can now cover multiple endpoints.

This is a good fit for APIs where error handling is part of a shared convention.

Do Not Assume the Filter Knows Which Parameter Failed

There is an important limitation.

The filter gets access to:

EndpointFilterInvocationContext

and its arguments.

But when parameter binding fails, the problematic argument may not be available in the same way as a successfully bound argument.

For example:

var id = context.GetArgument<int>(0);

should not be treated as a reliable way to obtain a value when binding itself failed.

The value could not be created in the first place.

If your API needs detailed information about exactly which input failed, consider using a validation model, custom binding, or another error-reporting mechanism rather than assuming the endpoint filter has the failed value.

Custom Binding Still Has Its Place

Endpoint filters do not replace custom binding.

ASP.NET Core Minimal APIs support custom binding through mechanisms such as:

TryParse

and:

BindAsync

For example:

public sealed class ProductId
{
    public int Value { get; init; }

    public static bool TryParse(
        string? value,
        IFormatProvider? provider,
        out ProductId? result)
    {
        if (int.TryParse(value, out var id) && id > 0)
        {
            result = new ProductId
            {
                Value = id
            };

            return true;
        }

        result = null;
        return false;
    }
}

Then:

app.MapGet(
    "/products/{id}",
    (ProductId id) =>
    {
        return Results.Ok(id.Value);
    });

If TryParse returns false, ASP.NET Core treats that as a binding failure and produces a 400 response for the appropriate nullable binding scenario.

The endpoint filter can then participate in handling that response.

BindAsync and Filter Handling

The same pattern applies to custom BindAsync implementations.

For example:

public sealed class TenantContext
{
    public string TenantId { get; init; } = string.Empty;

    public static ValueTask<TenantContext?> BindAsync(
        HttpContext context)
    {
        var tenant =
            context.Request.Headers["X-Tenant-Id"].FirstOrDefault();

        if (string.IsNullOrWhiteSpace(tenant))
        {
            return ValueTask.FromResult<TenantContext?>(null);
        }

        return ValueTask.FromResult<TenantContext?>(
            new TenantContext
            {
                TenantId = tenant
            });
    }
}

Endpoint:

app.MapGet(
    "/tenant/orders",
    (TenantContext tenant) =>
    {
        return Results.Ok(tenant.TenantId);
    })
    .AddEndpointFilter<BadRequestFilter>();

If the custom binder returns null, the framework can treat that as a binding failure and return 400.

This makes the new filter behavior useful for custom binding scenarios as well.

Using ProblemDetails

For a public API, ProblemDetails is often a better response format than returning arbitrary strings.

You can use:

return Results.Problem(
    statusCode: 400,
    title: "Invalid request",
    detail: "One or more request values could not be processed.");

The response becomes structured and easier for clients to understand.

A more complete application can also configure Problem Details services:

builder.Services.AddProblemDetails();

Then use consistent error handling throughout the application.

The exact response format should be decided at the application level rather than relying blindly on framework defaults.

Avoid Overusing Endpoint Filters

Endpoint filters are useful, but not every concern belongs in one.

Good uses include:

  • Request validation

  • Authorization-related checks

  • Logging

  • API version checks

  • Consistent response handling

  • Binding-failure customization

Microsoft's Minimal API documentation specifically lists validation, logging, and API-version checks among common endpoint-filter scenarios.

Avoid turning a filter into a large service containing business logic.

For example, this is a bad direction:

Filter
 ├── Validate request
 ├── Query database
 ├── Calculate pricing
 ├── Send email
 ├── Update order
 └── Build response

Keep the filter focused on cross-cutting behavior.

Business operations should remain in application services.

Endpoint Filter vs Middleware

It is also worth knowing when to use middleware instead.

Middleware operates at a broader application level:

Request
  |
  v
Middleware
  |
  v
Routing
  |
  v
Endpoint

Endpoint filters are closer to the selected endpoint:

Request
  |
  v
Routing
  |
  v
Endpoint Filter
  |
  v
Endpoint Handler

Use middleware when the behavior applies to the entire application or a broad section of it.

Use endpoint filters when the behavior belongs to a group of endpoints or specific endpoint contracts.

For example:

Global exception handling
        ↓
Middleware

API parameter validation
        ↓
Endpoint filter

Specific endpoint authorization rule
        ↓
Endpoint filter

The right layer makes the application easier to maintain.

Testing Binding Failures

Do not test only successful requests.

For this endpoint:

app.MapGet(
    "/products/{id}",
    (int id) => Results.Ok(id))
    .AddEndpointFilter<BadRequestFilter>();

Test:

/products/1
/products/100
/products/abc
/products/
/products/-1

Also test query parameters:

/products?page=1
/products?page=abc

And JSON:

{
  "price": 100
}

versus:

{
  "price": "invalid"
}

The goal is to verify that the API returns predictable responses for both valid and invalid input.

Logging Binding Failures

The framework logs binding failures at debug level.

That can be useful during troubleshooting without flooding production logs with expected bad input.

If you add your own filter logging, avoid logging sensitive request values.

For example:

logger.LogWarning(
    "Request failed parameter binding for {Path}",
    context.HttpContext.Request.Path);

is safer than logging the complete request body.

In public APIs, malformed requests are normal.

Do not treat every 400 response as an application failure.

A Complete Example

Here is a small example that puts the main idea together:

var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<RouteHandlerOptions>(
    options =>
    {
        options.ThrowOnBadRequest = false;
    });

var app = builder.Build();

app.MapGet(
    "/products/{id}",
    (int id) =>
    {
        return Results.Ok(new
        {
            Id = id,
            Name = "Laptop"
        });
    })
    .AddEndpointFilter(async (
        context,
        next) =>
    {
        var result = await next(context);

        if (context.HttpContext.Response.StatusCode == 400)
        {
            return Results.Problem(
                statusCode: 400,
                title: "Invalid request",
                detail: "The product ID must be a valid integer.");
        }

        return result;
    });

app.Run();

Now compare the two requests.

Valid:

GET /products/25

Response:

{
  "id": 25,
  "name": "Laptop"
}

Invalid:

GET /products/abc

The parameter cannot be bound to int.

The filter can now detect the 400 response and replace it with the application's custom error result.

Production Recommendations

If you plan to use this feature in a real API, keep a few things in mind.

Use One Error Contract

Decide how your API represents bad requests.

For example:

{
  "type": "invalid-request",
  "title": "Invalid request",
  "status": 400,
  "detail": "The request contains invalid values."
}

Keep the structure consistent.

Separate Binding From Validation

Do not try to solve every input problem inside one filter.

Binding answers:

Can the framework create this parameter?

Validation answers:

Is this parameter acceptable?

They are related but different stages.

Keep Filters Small

A filter should not become another business-logic layer.

Test Development and Production

The ThrowOnBadRequest setting can affect how failures appear during development.

Make sure your local testing environment represents the behavior you expect in deployment.

Protect Sensitive Information

Do not expose internal binding exceptions, stack traces, database information, or raw request content in API responses.

Common Mistakes

Expecting the Handler to Run After Binding Fails

It does not.

If an int parameter cannot be created, the handler cannot receive it.

Catching Only Exceptions

Binding failures are normally represented as 400 responses.

The new behavior specifically allows filters to observe that response.

Forgetting ThrowOnBadRequest During Development

If your filter seems to be bypassed locally, check the development setting.

Assuming the Failed Argument Is Available

A value that failed to bind should not be treated as a normal endpoint argument.

Putting Business Logic in the Filter

Filters are best for cross-cutting behavior.

Keep business operations in services.

Applying the Same Filter Everywhere Without Thinking

Some endpoints may require different error behavior.

Use route groups and targeted filters where appropriate.

Summary

ASP.NET Core 11 makes Minimal API endpoint filters more useful by allowing them to run even when parameter binding fails.

This solves a practical problem. Previously, a filter could handle many endpoint-level concerns, but it could not reliably customize a response when the framework failed to bind an endpoint parameter before the handler ran.

Now a filter can call the next delegate, inspect the response status, and handle a 400 Bad Request produced by parameter binding.

This works well for APIs that need one consistent error format. Instead of letting malformed route values, query parameters, or request bodies produce different responses, an application can use an endpoint filter to return a predictable ProblemDetails response or another standard format.

The feature does not replace custom binding or validation. Those mechanisms still have their own jobs. Binding creates the parameter, validation checks whether the value is acceptable, and endpoint filters provide a convenient place for cross-cutting behavior around the endpoint.

For larger Minimal API applications, this small .NET 11 change can make error handling much cleaner, especially when many endpoints need the same response format for invalid requests.