[AsParameters]
Group a minimal API handler's route, query, header, body and service parameters into one record with [AsParameters], validated by AddValidation.
app.MapGet("/customers/{customerId}/orders", ([AsParameters] OrderSearch search) => ...)- Added in .NET ASP.NET Core 7.0
Return value
n/aTreats each public settable property, or each parameter of the type's public constructor, as if it were a separate handler parameter, with the same binding rules and attributes.
Errors
InvalidOperationException when the endpoint is built if the type cannot be constructed (no usable public constructor) or a member's source cannot be determined.
Changelog
- PHP ASP.NET Core 10AddValidation() validates DataAnnotations on the members of [AsParameters] types and returns a 400 validation problem.
[AsParameters] is minimal-API only. It tells the request delegate generator to unpack a class or struct: each member is bound exactly as a handler parameter of that name and type would be, then the object is constructed and passed in. A search endpoint with eight parameters becomes one argument, and the parameter list becomes a type you can reuse, test and document.
How members are found and bound
- If the type has a public parameterised constructor (a positional record), its parameters are the members, and constructor default values make them optional.
- Otherwise it needs a public parameterless constructor, and every public settable property is bound.
- Members follow the normal inference rules: names in the route template bind from the route, registered services from DI, simple types from the query string, a complex type from the body. Attributes such as
[FromHeader(Name = "...")]and[FromServices]work on members (for a positional record, put them on the constructor parameter). - Nullability decides what is required: a non-nullable member without a default is a 400 when missing.
Class, record or struct
| Shape | Good for |
|---|---|
readonly record struct with a primary constructor | Most cases: no heap allocation per request, defaults in one line, immutable (a plain record struct gets settable properties). |
class with { get; set; } properties | Many optional members or attributes on properties. |
| plain parameters | Three or fewer values; an extra type adds nothing. |
Validation
With builder.Services.AddValidation() (ASP.NET Core 10), DataAnnotations on the members are validated before the handler runs, and failures come back as a 400 validation problem with member names as keys. Without it, attributes such as [Range] are ignored by minimal APIs.
Pitfalls
- Putting a
CancellationToken,HttpContextor a service in the type is allowed, but it mixes input with infrastructure; keep the type to what the client sends, plus at most a service the handler always needs. - The member names are the public query keys. Renaming a property renames the API unless
[FromQuery(Name = "...")]pins it. - OpenAPI documents every member as its own parameter, which is what you want, but only if the member types and nullability are accurate.
- Controllers do not use it; there a
[FromQuery]complex type does the same job for query values.
Example
When to use it
- Collapse a search endpoint's route id, six query filters and a correlation header into one OrderSearch record
- Reject ?page-size=500 with a 400 validation problem from [Range] on an [AsParameters] member using AddValidation
- Share a tenant id route value and request-source header across every endpoint in a /tenants/{tenantId}/invoices group
Frequently asked questions
Can I use [AsParameters] in controllers?
No, it is a minimal API feature. In a controller use a [FromQuery] complex type, or a model whose properties carry their own [FromRoute] and [FromHeader] attributes.
Why is my [Range] on an [AsParameters] member ignored?
Minimal APIs only validate when builder.Services.AddValidation() is registered. Without it, attributes are metadata for OpenAPI but nothing checks them.
Does the type have to be a record?
No. A positional record (struct or class) binds through its constructor; any concrete class or struct with a public parameterless constructor binds through its public settable properties.
More examples
A class with settable properties
With a parameterless constructor, public settable properties are bound and follow nullability: only nullable properties (or constructor parameters with defaults) are optional. The binder never sees property initialisers, so <code>bool InStockOnly</code> or <code>string Sort = "name"</code> would make <code>GET /products</code> a 400. Declare them nullable and apply the defaults in the handler.
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddValidation();
var app = builder.Build();
// Defaults are applied here: a property initialiser would not make a member optional.
app.MapGet("/products", ([AsParameters] ProductQuery query) =>
TypedResults.Ok(new
{
query.Category,
query.MinPrice,
query.MaxPrice,
InStockOnly = query.InStockOnly ?? false,
Sort = query.Sort ?? "name",
}));
app.Run();
public class ProductQuery
{
public string? Category { get; set; }
[FromQuery(Name = "min-price"), Range(0, 100_000)]
public decimal? MinPrice { get; set; }
[FromQuery(Name = "max-price"), Range(0, 100_000)]
public decimal? MaxPrice { get; set; }
[FromQuery(Name = "in-stock")]
public bool? InStockOnly { get; set; }
[AllowedValues("price", "-price", "name", null)]
public string? Sort { get; set; }
}
Route groups sharing one parameter type
A small parameter type carries what every endpoint in a group needs, while each handler adds its own extra parameters next to it. <code>[AsParameters]</code> parameters and ordinary parameters mix freely in one handler.
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var invoices = app.MapGroup("/tenants/{tenantId:guid}/invoices");
invoices.MapGet("/", ([AsParameters] TenantRequest tenant, string? status) =>
TypedResults.Ok(new { tenant.TenantId, status, invoices = Array.Empty<object>() }));
invoices.MapGet("/{invoiceId:int}", ([AsParameters] TenantRequest tenant, int invoiceId) =>
TypedResults.Ok(new { tenant.TenantId, invoiceId, total = 412.30m }));
app.Run();
// Shared by every endpoint in the group: route value plus a header.
public readonly record struct TenantRequest(
[FromRoute] Guid TenantId,
[FromHeader(Name = "X-Request-Source")] string? Source);
Unit-testing the handler without HTTP
Because the handler takes a plain record, a unit test builds the input with a constructor call instead of faking an <code>HttpContext</code>. Combined with <code>TypedResults</code>, the test asserts on a typed result without any serialisation.
using Microsoft.AspNetCore.Http.HttpResults;
using Xunit;
namespace Shop.Api.Tests;
public readonly record struct StockQuery(string Sku, string? Warehouse = null);
public static class StockEndpoints
{
public static Ok<StockLevel> GetStock([AsParameters] StockQuery query) =>
TypedResults.Ok(new StockLevel(query.Sku, query.Warehouse ?? "all", query.Sku == "MUG-WHITE" ? 40 : 0));
}
public record StockLevel(string Sku, string Warehouse, int Quantity);
public class StockEndpointsTests
{
[Fact]
public void Defaults_to_all_warehouses()
{
var result = StockEndpoints.GetStock(new StockQuery("MUG-WHITE"));
Assert.Equal("all", result.Value!.Warehouse);
Assert.Equal(40, result.Value.Quantity);
}
}
Discussion