Documentation
Paging
Page<T>, a clamping PageRequest and ToPageAsync for numbered pages; CursorPage<T> and ToCursorPageAsync for cursor paging.
The ordinary use
Derive from PagedQuery<TItem> and the query already has Number, Size and a Request with the values cleaned up. In the handler, finish the LINQ chain with ToPageAsync.
public sealed class SearchProducts : PagedQuery<ProductSummary>
{
public string? Term { get; init; }
public Guid? CategoryId { get; init; }
}
public Task<Page<ProductSummary>> HandleAsync(SearchProducts query, CancellationToken ct)
{
var filtered = products;
if (!string.IsNullOrWhiteSpace(query.Term))
{
var term = query.Term.Trim();
filtered = filtered.Where(p => p.Sku.Contains(term) || p.Name.Contains(term));
}
if (query.CategoryId is { } categoryId)
{
filtered = filtered.Where(p => p.Category!.ExternalId == categoryId);
}
return filtered
.OrderBy(p => p.Sku) // always order before paging
.Select(p => new ProductSummary(p.ExternalId, p.Sku, p.Price, p.Category!.Name))
.ToPageAsync(query.Request, ct);
}PageRequest clamps
Values off a query string cannot be trusted. PageRequest normalises them in its constructor, so ?number=0&size=99999 does not turn into a table scan.
| Input | Result |
|---|---|
new PageRequest(0, 20) | page 1, size 20 |
new PageRequest(-5, 20) | page 1, size 20 |
new PageRequest(3, 0) | page 3, size DefaultSize (20) |
new PageRequest(3, 10_000) | page 3, size MaxSize (200) |
new PageRequest(int.MaxValue, 20) | page MaxNumber (100,000), size 20 |
new PageRequest(2, 50) | page 2, size 50 |
public readonly struct PageRequest
{
public const int DefaultSize = 20;
public const int MaxSize = 200;
public const int MaxNumber = 100_000;
public int Number { get; } // 1..MaxNumber
public int Size { get; } // 1..MaxSize
public int Skip => (Number - 1) * Size;
}The cap on the page number exists because Skip is (Number - 1) * Size: without it, ?number=2147483647 overflowed the int and became a 500. And a page 100,000 is already a sign the screen wants cursor paging.
What Page<T> gives you
public sealed class Page<T>
{
public IReadOnlyList<T> Items { get; }
public long TotalCount { get; } // the total, unpaged
public int Number { get; }
public int Size { get; }
public int TotalPages { get; } // derived
public bool HasPrevious { get; }
public bool HasNext { get; }
public Page<TOut> Map<TOut>(Func<T, TOut> selector);
public static Page<T> Empty(int number = 1, int size = PageRequest.DefaultSize);
}The total comes with it, so the client builds its pager without a second request. An empty result does not make the second query: ToPageAsync returns Page<T>.Empty straight away.
{
"items": [
{ "id": "01a0e09d-4067-720d-9c5e-a9a99d3e3bef", "sku": "MART-01", "price": 59.9 },
{ "id": "01a0e09d-42a9-7848-9f00-79ce46395796", "sku": "REG-01", "price": 19.5 }
],
"totalCount": 3,
"number": 1,
"size": 2,
"totalPages": 2,
"hasPrevious": false,
"hasNext": true
}Map: projecting after the round trip
When the final transformation does not translate to SQL — formatting, a calculation, enrichment from another source — use Map, which preserves the paging metadata.
var page = await products
.OrderBy(p => p.Sku)
.Select(p => new ProductRow(p.ExternalId, p.Sku, p.Price))
.ToPageAsync(query.Request, ct);
return page.Map(row => new ProductSummary(
row.Id,
row.Sku,
row.Price.ToString("C", culture))); // impossible in the databasePaging over another API
Sometimes the rows are not in a database at all: they are behind somebody else's HTTP API, which pages with its own parameter names and its own envelope. The query still derives from PagedQuery<TItem>, the endpoint still returns a Page<T>, and the client still renders the same pager. Only the handler changes — there is no IQueryable to hang ToPageAsync off, so the page is assembled by hand.
// page / pageSize / total: none of those names is ours, and that is fine. Keeping the
// foreign shape in one type makes the adaptation a single, visible step — and makes a
// field they rename a one-file change.
public sealed record SupplierCatalogPage(
[property: JsonPropertyName("page")] int PageNumber,
int PageSize,
int Total,
IReadOnlyList<SupplierCatalogOffer>? Items)
{
// "items": null is a body they are free to send. The nullable stays on the wire type.
[JsonIgnore] public IReadOnlyList<SupplierCatalogOffer> Offers => Items ?? [];
// Page<T> refuses a negative total, and it is right to: that failure is theirs.
[JsonIgnore] public long TotalCount => Math.Max(Total, 0);
}public async Task<Page<SupplierOfferSummary>> HandleAsync(
SearchSupplierOffers query,
CancellationToken ct)
{
// 1. The clamped values are what go on the wire. ?page=0&size=99999 is normalised
// before this process asks anybody else for it — and a remote service is where an
// unclamped page size hurts most, because it is somebody else's database.
var request = query.Request;
var remote = await catalog.FetchOffersAsync(query.Term, request.Number, request.Size, ct);
// 2. Their envelope becomes ours. The total is theirs; the size is whichever of the two
// describes the rows actually in hand (see the warning below).
var size = remote.PageSize > 0 && remote.PageSize < request.Size ? remote.PageSize : request.Size;
var page = new Page<SupplierCatalogOffer>(remote.Offers, remote.TotalCount, request.Number, size);
// 3. Enrich from the local database, querying only the keys on THIS page: a page of 20
// rows costs one query over 20 keys — never a scan, and never one query per row.
var skus = page.Items.Select(o => o.Sku).Distinct().ToArray();
var stocked = (await products
.Where(p => skus.Contains(p.Sku))
.Select(p => new StockedProduct(p.Sku, p.ExternalId, p.Price))
.ToListAsync(ct))
.ToDictionary(p => p.Sku, StringComparer.Ordinal);
// 4. Map rewrites the items and leaves TotalCount, Number and Size alone. That is what
// "the same paging" means: the projection never gets to touch those numbers.
return page.Map(offer =>
{
var local = stocked.GetValueOrDefault(offer.Sku);
return new SupplierOfferSummary(
offer.Sku, offer.Name, offer.UnitCost, offer.Currency,
LeadTime(offer.LeadTimeDays), local?.Id, local?.Price);
});
}Mapis synchronous on purpose, so the enrichment lookup happens before it. A projection that awaited per item would be one query per row — the N+1, arrived at from the other direction.- The total is theirs. It is the one number in the envelope only they can know, and it is what
TotalPages,HasNextandHasPreviousare derived from. - Keep the foreign DTOs at the edge: deserialize them, project out of them, and never let one reach an entity or a caller.
- A dependency being down is a 502, not a 500 — map the integration failure to its own exception type and let the error handler say so. A 500 claims the fault as yours and sends whoever is on call looking in the wrong place.
- Put
[MessageTimeout]on the query. A message that waits on a third party is the one that hangs for as long as that third party feels like, and the budget covers the whole dispatch — the call, the lookup and the projection. - Order matters over there too: a remote page is no more stable than a local one if the service behind it does not order what it slices.
The sample has this end to end at GET /suppliers/offers, with a stand-in service inside the same app playing the part of the supplier — so docker compose up needs no third party, and repointing SupplierCatalog:BaseAddress at the real one changes nothing else.
Wiring it to the endpoint
app.MapGet("/products", (
[AsParameters] SearchProductsRequest request,
IMediator mediator,
CancellationToken ct) =>
mediator.FetchAsync(
new SearchProducts
{
Number = request.Page ?? 1,
Size = request.Size ?? PageRequest.DefaultSize,
Term = request.Term,
CategoryId = request.CategoryId,
},
ct));
internal sealed record SearchProductsRequest(int? Page, int? Size, string? Term, Guid? CategoryId);There is no need to validate the bounds: the clamping lives in PageRequest. A query validator is still useful for the business rules of the filters — a negative MinPrice, for instance.
Outside a PagedQuery
ToPageAsync is an extension on IQueryable<T>; it works on any query.
var page = await orders
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.PlacedOn)
.Select(o => new OrderRow(o.ExternalId, o.Total))
.ToPageAsync(number: 2, size: 25, ct);Cursor paging
A numbered page pays for its OFFSET: page 10,000 makes the database count and throw away 199,980 rows before returning 20. And if someone inserts a row in the middle, the next page repeats one. Cursor (keyset) paging asks for "the rows after this one", which the database answers from an index — it costs the same on the first page and the thousandth, and it neither repeats nor skips anything.
public sealed class ActivityFeed : CursorQuery<ActivityRow>;
public Task<CursorPage<ActivityRow>> HandleAsync(ActivityFeed query, CancellationToken ct) =>
entries
.Select(e => new ActivityRow(e.Id, e.Kind, e.Summary))
// The key is the ordering: unique and indexed. Do not order beforehand.
.ToCursorPageAsync(e => e.Id, query.Request, descending: true, ct);{
"items": [ { "id": 812, "kind": "product-repriced", "summary": "…" } ],
"nextCursor": "ODEx",
"hasNext": true,
"size": 20
}- The client sends
nextCursorback as?after=to get the next page. The cursor is opaque: do not build one, do not read one. - The key has to be unique: with a repeated key, rows tied at the page boundary would be skipped. The primary key is the natural choice.
- Accepted key types: integers,
decimal,DateTime,DateTimeOffset,TimeSpan,DateOnlyandstring. - A tampered cursor, or one from another key, becomes a
ValidationFailedExceptionwith codeinvalid-cursor— a 400, not a 500. - No total and no "jump to page 37": if the screen needs that, it wants numbered pages.