Typed Query Models for Clean REST APIs in .NET - bind, validate, and navigate query strings the sane way
October 8, 2025 by Yuriy Frankiv (aka InstanceMaster) · 6 min read
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.
Read Next
A Practical Guide to ASP.NET Applications: Designing Robust CRUD WebAPIs (Part2)
This article offers an approach to designing, implementing, and unit-testing CRUD APIs (Create, Read, Update, Delete). We will explore best practices for designing web APIs, discuss how to implement pagination, searching, and outline methods for organizing models effectively.
Read more...
A Practical Guide to ASP.NET Applications: The Structure (Part 1)
In this article, we will discuss structuring an ASP.NET Core project to prepare it for large-scale commercial software solutions. Topics covered include project structure as well as service boundaries and responsibilities.
Read more...