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.
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.
| Entrada | Resultado |
|---|---|
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 |
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
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.
{
"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.
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 bancoPaginando 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.
// 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);
}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 fizesseawaitpor 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,HasNexteHasPrevious. - 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
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.
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.
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);{
"items": [ { "id": 812, "kind": "product-repriced", "summary": "…" } ],
"nextCursor": "ODEx",
"hasNext": true,
"size": 20
}- O cliente devolve
nextCursorcomo?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,DateOnlyestring. - Um cursor adulterado ou de outra chave vira
ValidationFailedExceptioncom o códigoinvalid-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.