Nexo

Documentação

Paginação

Page<T>, PageRequest com clamp e ToPageAsync para páginas numeradas; CursorPage<T> e ToCursorPageAsync para paginação por cursor.

O uso normal

Herde de PagedQuery<TItem> e a query já tem Number, Size e um Request com os valores tratados. No handler, termine a cadeia LINQ com 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)                    // sempre ordene antes de paginar
        .Select(p => new ProductSummary(p.ExternalId, p.Sku, p.Price, p.Category!.Name))
        .ToPageAsync(query.Request, ct);
}

PageRequest faz clamp

Valores vindos de query string não são confiáveis. PageRequest normaliza no construtor, então ?number=0&size=99999 não vira varredura de tabela.

EntradaResultado
new PageRequest(0, 20)página 1, tamanho 20
new PageRequest(-5, 20)página 1, tamanho 20
new PageRequest(3, 0)página 3, tamanho DefaultSize (20)
new PageRequest(3, 10_000)página 3, tamanho MaxSize (200)
new PageRequest(int.MaxValue, 20)página MaxNumber (100 000), tamanho 20
new PageRequest(2, 50)página 2, tamanho 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;
}

O limite do número da página existe porque Skip é (Number - 1) * Size: sem ele, ?number=2147483647 estourava o int e virava um 500. E uma página 100 000 já é sinal de que a tela quer paginação por cursor.

O que Page<T> entrega

C#
public sealed class Page<T>
{
    public IReadOnlyList<T> Items { get; }
    public long TotalCount { get; }     // total sem paginação
    public int Number { get; }
    public int Size { get; }

    public int TotalPages { get; }      // derivado
    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);
}

O total vem junto, então o cliente monta a paginação sem uma segunda requisição. Resultado vazio não faz a segunda consulta: ToPageAsync devolve Page<T>.Empty direto.

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: projetar depois da ida ao banco

Quando a transformação final não é traduzível para SQL — formatação, cálculo, enriquecimento com dado de outra fonte — use Map, que preserva os metadados de paginação.

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)));   // impossível no banco

Paginando sobre outra API

Às vezes as linhas não estão em banco nenhum: estão atrás da API HTTP de outro serviço, que pagina com os nomes dele e com o envelope dele. A query continua derivando de PagedQuery<TItem>, o endpoint continua devolvendo Page<T> e o cliente continua renderizando o mesmo pager. Só o handler muda — não existe IQueryable onde pendurar o ToPageAsync, então a página é montada à mão.

O contrato deles, em um tipo próprio
// page / pageSize / total: nenhum desses nomes é nosso, e está tudo bem. Manter a forma
// estrangeira em um tipo só faz a adaptação ser um passo único e visível — e faz um campo
// que eles renomeiem ser mudança em um arquivo.
public sealed record SupplierCatalogPage(
    [property: JsonPropertyName("page")] int PageNumber,
    int PageSize,
    int Total,
    IReadOnlyList<SupplierCatalogOffer>? Items)
{
    // "items": null é um corpo que eles podem mandar. O nullable fica no tipo de fio.
    [JsonIgnore] public IReadOnlyList<SupplierCatalogOffer> Offers => Items ?? [];

    // Page<T> recusa total negativo, e tem razão: essa falha é deles.
    [JsonIgnore] public long TotalCount => Math.Max(Total, 0);
}
O handler: buscar, reconciliar, enriquecer, projetar
public async Task<Page<SupplierOfferSummary>> HandleAsync(
    SearchSupplierOffers query,
    CancellationToken ct)
{
    // 1. Os valores já com clamp são os que vão no fio. ?page=0&size=99999 é normalizado
    //    antes deste processo pedir isso a qualquer outro — e serviço remoto é onde um
    //    tamanho de página sem limite dói mais, porque o banco é de outra pessoa.
    var request = query.Request;
    var remote = await catalog.FetchOffersAsync(query.Term, request.Number, request.Size, ct);

    // 2. O envelope deles vira o nosso. O total é deles; o tamanho é aquele dos dois que
    //    descreve as linhas que de fato chegaram (veja o aviso abaixo).
    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. Enriquece com o banco local, consultando só as chaves DESTA página: uma página de
    //    20 linhas custa uma query sobre 20 chaves — nunca um scan, nunca uma query por linha.
    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. O Map reescreve os itens e não toca em TotalCount, Number e Size. É isso que "a
    //    mesma paginação" quer dizer: a projeção nunca chega nesses números.
    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);
    });
}
  • O Map é síncrono de propósito, e é por isso que a busca de enriquecimento acontece antes dele. Uma projeção que fizesse await por item seria uma query por linha — o N+1, chegando pelo outro lado.
  • O total é deles. É o único número do envelope que só eles têm como saber, e é dele que saem TotalPages, HasNext e HasPrevious.
  • Mantenha os DTOs estrangeiros na borda: desserialize, projete a partir deles, e não deixe nenhum chegar a uma entidade ou a um chamador.
  • Dependência fora do ar é 502, não 500 — mapeie a falha de integração num tipo de exceção próprio e deixe o error handler dizer isso. Um 500 assume a culpa como sua e manda quem está de plantão procurar no lugar errado.
  • Ponha [MessageTimeout] na query. Mensagem que espera terceiro é a que fica pendurada pelo tempo que o terceiro quiser, e o orçamento cobre o despacho inteiro — a chamada, a busca local e a projeção.
  • Ordenação importa lá também: uma página remota não é mais estável que uma local se o serviço por trás dela não ordena o que fatia.

O sample tem isso ponta a ponta em GET /suppliers/offers, com um serviço stand-in dentro da própria aplicação fazendo o papel do fornecedor — assim o docker compose up não depende de terceiro, e apontar SupplierCatalog:BaseAddress para o real não muda mais nada.

Ligando ao 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);

Não é preciso validar os limites: o clamp está no PageRequest. Um validator de query segue útil para as regras de negócio dos filtros — um MinPrice negativo, por exemplo.

Fora de uma PagedQuery

ToPageAsync é extensão de IQueryable<T>; funciona em qualquer 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);

Paginação por cursor

Página numerada paga pelo OFFSET: a página 10 000 faz o banco contar e descartar 199 980 linhas antes de devolver 20. E, se alguém insere uma linha no meio, a página seguinte repete uma. Paginação por cursor (keyset) pede "as linhas depois desta", que o banco resolve por índice — custa o mesmo na primeira página e na milésima, e não repete nem pula nada.

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))
        // A chave é a ordem: única e indexada. Não ordene antes.
        .ToCursorPageAsync(e => e.Id, query.Request, descending: true, ct);
JSON
{
  "items": [ { "id": 812, "kind": "product-repriced", "summary": "…" } ],
  "nextCursor": "ODEx",
  "hasNext": true,
  "size": 20
}
  • O cliente devolve nextCursor como ?after= para pegar a próxima página. O cursor é opaco: não monte nem interprete.
  • A chave precisa ser única: com uma chave repetida, as linhas empatadas na borda da página seriam puladas. A chave primária é a escolha natural.
  • Tipos aceitos para a chave: inteiros, decimal, DateTime, DateTimeOffset, TimeSpan, DateOnly e string.
  • Um cursor adulterado ou de outra chave vira ValidationFailedException com o código invalid-cursor — um 400, não um 500.
  • Sem total e sem "ir para a página 37": se a tela precisa disso, é página numerada.