Decode.Queries 3.0.0

dotnet add package Decode.Queries --version 3.0.0
                    
NuGet\Install-Package Decode.Queries -Version 3.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Decode.Queries" Version="3.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Decode.Queries" Version="3.0.0" />
                    
Directory.Packages.props
<PackageReference Include="Decode.Queries" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Decode.Queries --version 3.0.0
                    
#r "nuget: Decode.Queries, 3.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Decode.Queries@3.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Decode.Queries&version=3.0.0
                    
Install as a Cake Addin
#tool nuget:?package=Decode.Queries&version=3.0.0
                    
Install as a Cake Tool

Decode.Queries

Pagination contracts for .NET query APIs: a normalized page request and an immutable page response with navigation metadata.

No dependencies. Targets net8.0, net9.0 and net10.0, so a Domain or Application layer can reference it without taking on a web or data-access stack.

📦 Installation

dotnet add package Decode.Queries

🛠️ Usage

1. Accept the query

using Decode.Queries;

[HttpGet]
public async Task<IActionResult> GetUsers([FromQuery] PagedQuery query, CancellationToken ct)
{
    PagedQueryResponse<UserResponse> page = await _repository.GetPagedAsync(query, ct);
    return Ok(page);
}

2. Use it in the repository

public async Task<PagedQueryResponse<UserResponse>> GetPagedAsync(PagedQuery query, CancellationToken ct)
{
    long total = await connection.ExecuteScalarAsync<long>("SELECT COUNT(1) FROM app_user", ...);

    // Offset is (Page - 1) * PageSize, the expression every repository otherwise rewrites.
    List<UserResponse> rows = (await connection.QueryAsync<UserResponse>(
        "SELECT ... FROM app_user ORDER BY created_at DESC LIMIT @Limit OFFSET @Offset",
        new { Limit = query.PageSize, query.Offset })).ToList();

    return PagedQueryResponse.Create(rows, total, query);
}

PagedQueryResponse.Create reads Page and PageSize from the query as it was actually served — normalized and capped — so the response reports what the caller received rather than what they asked for.

Response shape

{
  "data": [ { "id": "3fa85f64-...", "name": "Product A" } ],
  "total": 250,
  "page": 2,
  "pageSize": 10,
  "totalPages": 25,
  "hasPreviousPage": true,
  "hasNextPage": true
}

It composes with Decode.AspNetCore's envelope without conflict — the page goes in data:

{ "success": true, "data": { "data": [...], "total": 250, ... }, "errors": null, "errorId": null }

📖 Design notes

Invalid input is clamped, invalid configuration throws

page=0 reads back as 1; a pageSize above the cap reads back as the cap. These arrive from a query string, where an out-of-range value is far more often a naive client than an attack, and a 400 buys nothing — the effective PageSize is echoed in the response, so a caller that was capped can see it.

PagedQuery.DefaultPageSize and PagedQuery.DefaultMaxPageSize do the opposite and throw on a non-positive value. They are written by a developer at startup, not received from a caller, and the right failure for bad configuration is a loud one.

Normalization happens on read, not on write

A model binder assigns properties in no defined order. Clamping inside the setter makes the outcome depend on whether PageSize was bound before or after anything it depends on; clamping on read does not.

The cap cannot be raised by the caller

MaxPageSize has no setter, so a model binder cannot reach it. A public settable cap on a model-bound type is a cap in name only — the client would send ?maxPageSize=1000000&pageSize=1000000 and lift its own limit.

Raising it is the application's decision, expressed by deriving a type:

public class ExportQuery() : PagedQuery(maxPageSize: 5_000);

The response cannot describe a page it does not hold

Every property is read-only and every derived value is computed in the constructor. A shape with settable Page, Total and Data lets nothing stop a response from reporting page 5 of 3 while carrying the rows of page 1, and HasNextPage then lies to the client.

Data is IReadOnlyList<T>, not IEnumerable<T>

A lazy sequence stored in a response model is enumerated by the serializer, which runs after the action returned — by then the database connection and the transaction that produced it are typically gone, and the failure surfaces during response writing rather than where the query was issued. Requiring a materialized list moves that to the call site.

Total, TotalPages and Offset are 64-bit

Total is the result of a COUNT. TotalPages is derived from it, and narrowing it to int would wrap silently at a small page size over a large table. Offset is (Page - 1) * PageSize, which overflows int at page numbers a caller can simply type into a query string.

The page count uses integer ceiling division rather than Math.Ceiling((double)total / pageSize), which loses precision once the count passes 2^53.

⚠️ Known limitation: deep pagination

A large Offset is expensive on every database engine, because the skipped rows are still produced before being discarded. Nothing here prevents that, and capping the page number would break legitimate deep paging. If it matters for your dataset, page by key (WHERE id > @lastSeen) rather than by offset — a different contract, deliberately not modelled by this package.

📄 License

MIT License.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.
  • net8.0

    • No dependencies.
  • net9.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
3.0.0 95 9/14/2026
2.1.0 92 9/13/2026