Nexo

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.

C#
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.

InputResult
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
C#
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

C#
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.

JSON
{
  "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.

C#
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 database

Paging 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.

Their contract, kept in a type of its own
// 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);
}
The handler: fetch, reconcile, enrich, project
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);
    });
}
  • Map is 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, HasNext and HasPrevious are 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

C#
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.

C#
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.

C#
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);
JSON
{
  "items": [ { "id": 812, "kind": "product-repriced", "summary": "…" } ],
  "nextCursor": "ODEx",
  "hasNext": true,
  "size": 20
}
  • The client sends nextCursor back 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, DateOnly and string.
  • A tampered cursor, or one from another key, becomes a ValidationFailedException with code invalid-cursor — a 400, not a 500.
  • No total and no "jump to page 37": if the screen needs that, it wants numbered pages.