[AsParameters]

Group a minimal API handler's route, query, header, body and service parameters into one record with [AsParameters], validated by AddValidation.

Signature
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

ShapeGood for
readonly record struct with a primary constructorMost cases: no heap allocation per request, defaults in one line, immutable (a plain record struct gets settable properties).
class with { get; set; } propertiesMany optional members or attributes on properties.
plain parametersThree 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, HttpContext or 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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · csharp
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.

Example · csharp
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.

Example · csharp
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

  • Be the first to comment on this lesson.