I added a sort direction to a product search endpoint last week, and when I went to type the parameter I realized the handler already had five in front of it. Search, category, sort field, page, page size, and now a descending flag. The signature had become one of those declarations that wraps onto three lines and makes you scroll sideways to find the closing brace. Nobody decided it should look like that. It grew one feature at a time, the way these things do.

Here's roughly where it landed:

app.MapGet("/products/manual", ( string? search, string? category, string sortBy, bool descending, int page, int pageSize, ProductCatalog catalog) => { var result = catalog.Query(search, category, sortBy, descending, page, pageSize); return Results.Ok(result); }); 

The injected ProductCatalog is stuck at the end, lost in the crowd of query values. If I add a minPrice next week it goes somewhere in the middle, and the call to catalog.Query(...) grows a seventh positional argument that I have to line up by counting commas. That's the part I don't love. Six positional values where a swapped pair compiles fine and then returns the wrong page at runtime.

Grouping without moving anything

[AsParameters] lets you take those loose parameters and hand them to a single type. They don't move to a request body or change how they bind. They just live in a struct now.

public record struct ProductQuery( [FromQuery] string? Search, [FromQuery] string? Category, [FromQuery] string SortBy, [FromQuery] bool Descending, [FromQuery] int Page, [FromQuery] int PageSize); 

And the endpoint:

app.MapGet("/products", ([AsParameters] ProductQuery query, ProductCatalog catalog) => { var result = catalog.Query( query.Search, query.Category, query.SortBy, query.Descending, query.Page, query.PageSize); return Results.Ok(result); }); 

Each property still declares where it binds from with [FromQuery], exactly like the loose version did. The service injection is unambiguous now: query is the request, catalog is the dependency. Two arguments instead of seven.

Proving it's a no-op for callers

The thing I wanted to be sure about was whether this changes anything a client can see. So I ran both endpoints against the same query string and diffed the responses.

curl ".../products?category=peripherals&sortBy=price&descending=true&pageSize=3" curl ".../products/manual?category=peripherals&sortBy=price&descending=true&pageSize=3" 
{"page":1,"pageSize":3,"count":3,"items":[ {"id":5,"name":"Webcam","category":"peripherals","price":99.00}, {"id":1,"name":"Keyboard","category":"peripherals","price":79.00}, {"id":4,"name":"USB-C hub","category":"peripherals","price":45.00}]} 

Identical, byte for byte. Then I checked the generated OpenAPI document, because that's what Swagger and client generators actually read, and I wanted to know whether the grouped version hides the parameters or flattens them back out.

/products -> Search, Category, SortBy, Descending, Page, PageSize (all in: query) /products/manual -> search, category, sortBy, descending, page, pageSize (all in: query) 

Same six query parameters on both, all flat. A caller can't tell which endpoint used a struct. The only visible difference is casing: the grouped version takes its names from the record's properties, so they come out PascalCase. That sounds like it should break the lowercase query I sent, and it doesn't, because query binding is case-insensitive. ?category= fills a Category property without complaint. Worth knowing if you care about how the names read in your published docs.

I didn't reach for a stopwatch on this one. It compiles down to the same binding the loose parameters would, and the request contract is identical, so a benchmark here would show me nothing but noise. This is a readability change wearing no disguise.

When I'd skip it

This earns its place once a handler crosses about four query parameters, or when two endpoints share the same filter shape and you'd otherwise paste the list twice. Below that it's ceremony. A GET /users/{id} does not need a struct wrapped around one route value, and writing one would be the kind of abstraction that looks tidy today and reads as pointless in six months.

The honest cost is that it's now a type you have to go find. The parameters left the signature, which is the whole point, but it also means a reader has to open ProductQuery to see what the endpoint accepts. For a three-parameter handler that trade is bad. For the one that was wrapping onto three lines, I'll take it every time.

My rule of thumb is boring: if the positional argument list is long enough that I'd have to count commas to trust it, it's long enough to name. [AsParameters] is just the mechanism for naming it without telling the client anything moved.

Since GitHub was giving me trouble the day I wrote this, here's the whole runnable thing. New web project on .NET 10, add the Microsoft.AspNetCore.OpenApi package, drop this in Program.cs, dotnet run, and hit both paths:

using Microsoft.AspNetCore.Mvc; var builder = WebApplication.CreateBuilder(args); builder.Services.AddOpenApi(); builder.Services.AddSingleton<ProductCatalog>(); var app = builder.Build(); app.MapOpenApi(); app.MapGet("/products/manual", ( string? search, string? category, string sortBy, bool descending, int page, int pageSize, ProductCatalog catalog) => Results.Ok(catalog.Query(search, category, sortBy, descending, page, pageSize))); app.MapGet("/products", ([AsParameters] ProductQuery query, ProductCatalog catalog) => Results.Ok(catalog.Query( query.Search, query.Category, query.SortBy, query.Descending, query.Page, query.PageSize))); app.Run(); public record struct ProductQuery( [FromQuery] string? Search, [FromQuery] string? Category, [FromQuery] string SortBy, [FromQuery] bool Descending, [FromQuery] int Page, [FromQuery] int PageSize); public sealed class ProductCatalog { private readonly List<Product> _products = [ new(1, "Keyboard", "peripherals", 79.00m), new(2, "Mouse", "peripherals", 29.00m), new(3, "Monitor", "displays", 219.00m), new(4, "USB-C hub", "peripherals", 45.00m), new(5, "Webcam", "peripherals", 99.00m), new(6, "Desk lamp", "displays", 39.00m), ]; public object Query(string? search, string? category, string sortBy, bool descending, int page, int pageSize) { IEnumerable<Product> q = _products; if (!string.IsNullOrWhiteSpace(search)) q = q.Where(p => p.Name.Contains(search, StringComparison.OrdinalIgnoreCase)); if (!string.IsNullOrWhiteSpace(category)) q = q.Where(p => p.Category == category); q = sortBy.ToLowerInvariant() switch { "price" => descending ? q.OrderByDescending(p => p.Price) : q.OrderBy(p => p.Price), _ => descending ? q.OrderByDescending(p => p.Name) : q.OrderBy(p => p.Name), }; page = page < 1 ? 1 : page; pageSize = pageSize < 1 ? 10 : pageSize; var items = q.Skip((page - 1) * pageSize).Take(pageSize).ToList(); return new { page, pageSize, count = items.Count, items }; } } public record Product(int Id, string Name, string Category, decimal Price); 

Where do you draw the line? Curious whether other people's threshold sits above or below four parameters.

— still refactoring signatures nobody asked me to touch