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
DateOnly
for 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
) forDateOnly
and 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.