Typed Query Models for Clean REST APIs in .NET - bind, validate, and navigate query strings the sane way

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(...) or Enum.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., Fromfrom).
  • 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). Use DateTime (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) for DateOnly and round-trip "o" for DateTime. 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 defaults

  • Accept 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.