Pagination Pattern¶
Standardized pagination for list endpoints.
Overview¶
flowchart LR
A[Request] -->|page, pageSize| B[Query]
B --> C[Count Total]
B --> D[Fetch Page]
C --> E[Response]
D --> E
E -->|Data + Headers| F[Client] Response Format¶
Body¶
Headers¶
| Header | Value |
|---|---|
X-Pagination-PageNumber | Current page |
X-Pagination-PageSize | Items per page |
X-Pagination-TotalCount | Total items |
X-Pagination-TotalPages | Total pages |
PaginatedResponse¶
public class PaginatedResponse<T>
{
public IList<T> Data { get; init; }
public int PageNumber { get; init; }
public int PageSize { get; init; }
public int TotalCount { get; init; }
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
}
Query Implementation¶
public async Task<PaginatedResponse<TransactionResponse>> Handle(
GetPaginatedTransactionsQuery request,
CancellationToken ct)
{
var query = context.Transactions
.Where(t => t.GroupId == request.GroupId)
.Where(t => !t.IsDeleted);
// Apply filters
if (request.CategoryId.HasValue)
query = query.Where(t => t.CategoryId == request.CategoryId);
// Get total before pagination
var totalCount = await query.CountAsync(ct);
// Apply pagination
var data = await query
.OrderByDescending(t => t.Date)
.Skip((request.PageNumber - 1) * request.PageSize)
.Take(request.PageSize)
.Select(t => t.ToResponse())
.ToListAsync(ct);
return new PaginatedResponse<TransactionResponse>
{
Data = data,
PageNumber = request.PageNumber,
PageSize = request.PageSize,
TotalCount = totalCount
};
}
Controller Helper¶
protected async Task<IActionResult> ExecuteWithPaginationAsync<TData>(
IRequest<PaginatedResponse<TData>> request)
{
var result = await mediator.Send(request);
Response.Headers["X-Pagination-PageNumber"] = result.PageNumber.ToString();
Response.Headers["X-Pagination-PageSize"] = result.PageSize.ToString();
Response.Headers["X-Pagination-TotalCount"] = result.TotalCount.ToString();
Response.Headers["X-Pagination-TotalPages"] = result.TotalPages.ToString();
return Ok(result);
}
Client Usage (TanStack Query)¶
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["transactions", filters],
queryFn: ({ pageParam = 1 }) =>
fetchTransactions({ ...filters, page: pageParam }),
getNextPageParam: (lastPage) =>
lastPage.pageNumber < lastPage.totalPages
? lastPage.pageNumber + 1
: undefined,
});
IPaginatedRequest Interface¶
Paginated request models implement IPaginatedRequest for consistent validation:
Validators can use pagination rules via inheritance or composition:
// Option 1: Inheritance
public sealed class GetMarkersRequestValidator : PaginatedRequestValidator<GetMarkersRequest>
{
public GetMarkersRequestValidator()
{
// Pagination rules inherited
}
}
// Option 2: Composition (when another base class is needed)
public sealed class MyValidator : SomeOtherBase<MyRequest>
{
public MyValidator()
{
this.AddPaginationRules(); // Extension method
}
}
Default Page Sizes¶
| Endpoint | Default Size |
|---|---|
| Transactions | 15 |
| Markers | 20 |
| Users (admin) | 50 |
| Groups (admin) | 20 |
| Action History | 20 |