Typed Query Models for Clean REST APIs in .NET - bind, validate, and navigate query strings the sane way
10/8/2025 by Yuriy Frankiv (aka InstanceMaster)
Filtering list endpoints is one of the most common tasks in a web API: "get timesheets by date range," "orders by status," "projects by customer," etc. The trouble isn't returning the data - it's keeping the query parameters typed, consistent, and easy to evolve without sprinkling manual parsing all over your controllers.
This post introduces DevInstance.WebServiceToolkit.Http.Query (a part of WebServiceToolkit library), a tiny toolkit that lets you:
- Define a typed query model with
[QueryModel]and optional[QueryName]and[DefaultValue]. - Automatically bind a URL query string (e.g.
?from=2025-10-01&to=2025-10-31&status=Open&page=2&pageSize=50) into that model. - Support primitives, enums,
DateOnly,TimeOnly,Guid, and comma-separated arrays out of the box. - Register it with a one-liner in Program.cs.
Why this approach?
- Typed filters, not "string soup." You get compile-time property names in controllers and services.
- Consistent conventions. One way to parse dates, enums, arrays, etc.
- Less controller noise. No more hand-rolled
DateOnly.Parse(...)orEnum.TryParse(...)in every endpoint. - Safer defaults. Missing parameters pick up
[DefaultValue]; bad inputs become clear validation errors.
1) Install & wire up
Add the package reference to your project:
dotnet add package DevInstance.WebServiceToolkit
Register once in Program.cs:
// Program.cs
...
builder.Services.AddWebServiceToolkitQuery(); // <- from DevInstance.WebServiceToolkit.Http.Query
...
That's it. The binder/provider will insert itself early so it wins over the default [FromQuery] binder.
2) Define a typed query model
Before the code, here’s how the data structure works:
- A query model is a plain POCO* marked with
[QueryModel]. (POCO stands for Plain Old CLR Object (CLR = .NET’s Common Language Runtime).) - Every public settable property becomes an accepted query parameter.
- The property’s CLR type controls parsing and validation (e.g.,
int,Guid,DateOnly, enum). - Use
[QueryName("param")]to decouple wire names from property names (e.g.,From⇢from). - Make a property nullable (
DateOnly?,int?,Guid?, etc.) to indicate the parameter is optional. - Apply
[DefaultValue(...)]to supply a value when the caller omits the parameter (great for pagination/sorting). - Collections (
IEnumerable<T>/ arrays) are encoded as comma-separated lists by default (e.g.,statusIn=Open,Closed). - Prefer
DateOnlyfor calendar-style filters (date ranges without time zones). UseDateTime(ISO 8601) when the exact instant matters.
Example mapping
| Property | Query string key | Example value |
|---|---|---|
Status |
status |
Open |
[QueryName("from")] From |
from |
2025-10-01 |
[QueryName("to")] To |
to |
2025-10-31 |
CustomerId |
customerId |
5a8b1fe8-6c1b-4e2c-bd2f-7a… |
[QueryName("statusIn")] StatusIn |
statusIn |
Open,Closed (comma-separated) |
Sort (with [DefaultValue]) |
sort |
-CreatedAt (descending) |
Page / PageSize (with [DefaultValue]) |
page / pageSize |
1 / 50 |
Example URL:
/api/orders?from=2025-10-01&to=2025-10-31&status=Open&statusIn=Open,Closed&page=1&pageSize=50&sort=-CreatedAt
Code
using System;
using System.ComponentModel;
using DevInstance.WebServiceToolkit.Http.Query;
[QueryModel] // mark as a bindable query model
public class OrderListQuery
{
// ?status=Open
public string? Status { get; set; }
// ?from=2025-10-01&to=2025-10-31
[QueryName("from")] public DateOnly? From { get; set; }
[QueryName("to")] public DateOnly? To { get; set; }
// ?customerId=5a8b...
public Guid? CustomerId { get; set; }
// ?statusIn=Open,Closed (comma-separated arrays)
[QueryName("statusIn")] public string[]? StatusIn { get; set; }
// ?sort=-CreatedAt&page=1&pageSize=50
[DefaultValue("-CreatedAt")] public string Sort { get; set; } = "-CreatedAt";
[DefaultValue(1)] public int Page { get; set; } = 1;
[DefaultValue(50)] public int PageSize { get; set; } = 50;
}
What's supported out-of-the-box:
string,bool,int,long,decimal,double,Guid,DateTime(ISO 8601)DateOnly(yyyy-MM-dd),TimeOnly(HH:mm or HH:mm:ss)- Enums (
?state=Active) - case-insensitive - Arrays /
IEnumerable<T>via comma-separated values (?ids=1,2,3)
3) Use it in a controller
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
[HttpGet]
public async Task<IActionResult> List(OrderListQuery query, [FromServices] IOrderService svc, CancellationToken ct)
{
// With [ApiController], invalid query => automatic 400 ProblemDetails with ModelState errors
var result = await svc.SearchAsync(query, ct);
return Ok(new {
data = result.Items,
meta = new { query.Page, query.PageSize, total = result.Total }
});
}
}
4) Minimal APIs (optional)
Prefer Minimal APIs? You can still use the static binder directly:
app.MapGet("/api/orders", async (HttpRequest req, IOrderService svc, CancellationToken ct) =>
{
// Throws QueryModelBindException on invalid values; or swap for TryBind to return ValidationProblem.
var query = QueryModelBinder.Bind<OrderListQuery>(req);
var result = await svc.SearchAsync(query, ct);
return Results.Ok(new { data = result.Items, meta = new { query.Page, query.PageSize, total = result.Total } });
});
If you want the same controller-style ModelState behavior in Minimal APIs, you can implement a BindAsync on the model that uses TryBind and returns null, then return Results.ValidationProblem(errors) - but controllers already give you this for free.
5) Client-side using BlazorToolkit
For the Blazor client using BlazorToolkit: it’s very simple. Keep the OrderListQuery declaration in a shared assembly accessible by your Blazor project. IApiContext<T> now has an extension method Query(...) (see more) that accepts a reference to the [QueryModel] object.
[BlazorService]
public class OrdersService
{
public IApiContext<OrderItem> Api { get; set; } = default!;
public async Task<ServiceActionResult<ModelList<OrderItem>?>> GetAsync(OrderListQuery query)
{
return await ServiceUtils.HandleWebApiCallAsync(
async _ => await Api
.Get()
.Path("orders")
.Query(query) // <- builds query string from the typed model
.ExecuteListAsync()
);
}
}
Not using BlazorToolkit? You can copy code from ToQueryString() to generate a query-string portion of a URL.
6) Conventions & tips
- Arrays are comma-separated (
?tags=foo,bar,baz). If you prefer repeated keys (?tag=foo&tag=bar), you can tweak the binder to read multi-values. - Dates use ISO (
yyyy-MM-dd) forDateOnlyand round-trip"o"forDateTime. This keeps things timezone-safe and predictable. - Defaults: annotate properties with [DefaultValue(...)] to make pagination/sorting opt-in but resilient.
- Validation: the binder ensures each param is of the right type. You should still validate business rules (e.g., PageSize <= 200) in your service layer.
7) Performance & future directions
This library uses lightweight reflection at the edges (binding + optional client helpers). For most APIs this is negligible and dramatically reduces boilerplate. If you hit AOT/WASM scenarios or ultra-hot paths, you can later add a source generator that emits TryBind()/ToQueryString() per model—without changing controllers.
8) Quick checklist
Add
builder.Services.AddWebServiceToolkitQuery();Create a DTO and mark it
[QueryModel]Use
[QueryName]to align param names (from, to, etc.)Add
[DefaultValue]to set sensible pagination/sort defaultsAccept the model in your controller action; enjoy typed filters
See source code implemenation for reference:
Thanks for reading to the end. You can follow me on X: @InstanceMaster or LinkedIn Yuriy Frankiv.