Skip to main content
The BuildingBlocks library provides pagination types for implementing consistent pagination across all microservices APIs.

Overview

Pagination helps manage large datasets by returning data in smaller, manageable chunks. The pagination system provides:
  • PaginationRequest: Standard request parameters for pagination
  • PaginatedResult: Standard response wrapper with pagination metadata
  • Consistency: All APIs use the same pagination pattern
  • Performance: Reduces memory usage and network transfer

PaginationRequest

The PaginationRequest record defines standard pagination parameters.
BuildingBlocks/Pagination/PaginationRequest.cs
namespace BuildingBlocks.Pagination;

public record PaginationRequest(int PageIndex = 0, int PageSize = 10);

Properties

PropertyTypeDefaultDescription
PageIndexint0Zero-based page index (first page is 0)
PageSizeint10Number of items per page

Usage in Queries

Include PaginationRequest in your query definition:
public record GetOrdersQuery(
    PaginationRequest Pagination,
    Guid? CustomerId = null,
    OrderStatus? Status = null
) : IQuery<PaginatedResult<OrderDto>>;

Usage in Controllers

Accept pagination parameters from query string:
[HttpGet]
public async Task<ActionResult<PaginatedResult<OrderDto>>> GetOrders(
    [FromQuery] int pageIndex = 0,
    [FromQuery] int pageSize = 10,
    [FromQuery] Guid? customerId = null,
    [FromQuery] OrderStatus? status = null)
{
    var query = new GetOrdersQuery(
        new PaginationRequest(pageIndex, pageSize),
        customerId,
        status
    );
    
    var result = await _mediator.Send(query);
    return Ok(result);
}
Example Request:
GET /api/orders?pageIndex=0&pageSize=20&customerId=a1b2c3d4&status=Pending

PaginatedResult

The PaginatedResult class wraps paginated data with metadata.
BuildingBlocks/Pagination/PaginatedResult.cs
namespace BuildingBlocks.Pagination;

public class PaginatedResult<TEntity>
    (int pageIndex, int pageSize, long count, IEnumerable<TEntity> data) 
    where TEntity : class
{
    public int PageIndex { get; } = pageIndex;
    public int PageSize { get; } = pageSize;
    public long Count { get; } = count;
    public IEnumerable<TEntity> Data { get; } = data;
}

Properties

PropertyTypeDescription
PageIndexintCurrent page index (zero-based)
PageSizeintNumber of items per page
CountlongTotal number of items across all pages
DataIEnumerable<TEntity>Items for the current page

Computed Properties

You can easily compute additional pagination metadata:
public static class PaginatedResultExtensions
{
    public static int GetTotalPages<T>(this PaginatedResult<T> result) where T : class
    {
        return (int)Math.Ceiling(result.Count / (double)result.PageSize);
    }
    
    public static bool GetHasNextPage<T>(this PaginatedResult<T> result) where T : class
    {
        return result.PageIndex < result.GetTotalPages() - 1;
    }
    
    public static bool GetHasPreviousPage<T>(this PaginatedResult<T> result) where T : class
    {
        return result.PageIndex > 0;
    }
}

Implementation with Entity Framework

Here’s how to implement pagination in a query handler using Entity Framework:
public class GetOrdersQueryHandler 
    : IQueryHandler<GetOrdersQuery, PaginatedResult<OrderDto>>
{
    private readonly IApplicationDbContext _context;
    private readonly IMapper _mapper;
    
    public GetOrdersQueryHandler(
        IApplicationDbContext context,
        IMapper mapper)
    {
        _context = context;
        _mapper = mapper;
    }
    
    public async Task<PaginatedResult<OrderDto>> Handle(
        GetOrdersQuery query, 
        CancellationToken cancellationToken)
    {
        // Build base query
        var queryable = _context.Orders
            .AsNoTracking()
            .Include(o => o.Items)
            .AsQueryable();
        
        // Apply filters
        if (query.CustomerId.HasValue)
        {
            queryable = queryable.Where(o => o.CustomerId == query.CustomerId.Value);
        }
        
        if (query.Status.HasValue)
        {
            queryable = queryable.Where(o => o.Status == query.Status.Value);
        }
        
        // Get total count before pagination
        var totalCount = await queryable.LongCountAsync(cancellationToken);
        
        // Apply pagination
        var orders = await queryable
            .OrderByDescending(o => o.OrderDate)
            .Skip(query.Pagination.PageIndex * query.Pagination.PageSize)
            .Take(query.Pagination.PageSize)
            .ToListAsync(cancellationToken);
        
        // Map to DTOs
        var orderDtos = _mapper.Map<List<OrderDto>>(orders);
        
        // Return paginated result
        return new PaginatedResult<OrderDto>(
            query.Pagination.PageIndex,
            query.Pagination.PageSize,
            totalCount,
            orderDtos
        );
    }
}

Key Implementation Points

  1. Count Before Pagination: Get total count before applying Skip and Take
  2. Order Results: Always order before pagination for consistent results
  3. Skip and Take: Use for offset-based pagination
  4. AsNoTracking: Use for read-only queries to improve performance

Response Format

Example Response:
{
  "pageIndex": 0,
  "pageSize": 10,
  "count": 147,
  "data": [
    {
      "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "orderNumber": "ORD-2024-001",
      "customerId": "c1d2e3f4-a5b6-7890-cdef-123456789012",
      "orderDate": "2024-03-05T10:30:00Z",
      "status": "Pending",
      "total": 299.99,
      "items": [
        {
          "productId": "p1q2r3s4-t5u6-7890-vwxy-z12345678901",
          "productName": "Product A",
          "quantity": 2,
          "price": 149.99
        }
      ]
    }
    // ... 9 more items
  ]
}

Interpreting the Response

  • pageIndex: 0: This is the first page
  • pageSize: 10: Each page contains 10 items
  • count: 147: There are 147 total items
  • data: Array of 10 items for this page
  • Total Pages: Math.Ceiling(147 / 10) = 15 pages
  • Has Next Page: Yes (page 0 of 15)
  • Has Previous Page: No (this is the first page)

Advanced Pagination Patterns

Dynamic Page Size

Limit maximum page size to prevent performance issues:
public record PaginationRequest(int PageIndex = 0, int PageSize = 10)
{
    private const int MaxPageSize = 100;
    
    public int GetSafePageSize() => Math.Min(PageSize, MaxPageSize);
}

Cursor-Based Pagination

For large datasets or real-time data, consider cursor-based pagination:
public record CursorPaginationRequest(
    string? Cursor = null,
    int PageSize = 10
);

public class CursorPaginatedResult<TEntity> where TEntity : class
{
    public string? NextCursor { get; init; }
    public string? PreviousCursor { get; init; }
    public bool HasNextPage { get; init; }
    public bool HasPreviousPage { get; init; }
    public IEnumerable<TEntity> Data { get; init; }
}

// Implementation
public async Task<CursorPaginatedResult<OrderDto>> Handle(
    GetOrdersQuery query, 
    CancellationToken ct)
{
    DateTime? cursorDate = query.Cursor != null 
        ? DateTime.Parse(query.Cursor) 
        : null;
    
    var queryable = _context.Orders.AsNoTracking();
    
    if (cursorDate.HasValue)
    {
        queryable = queryable.Where(o => o.OrderDate < cursorDate.Value);
    }
    
    var orders = await queryable
        .OrderByDescending(o => o.OrderDate)
        .Take(query.PageSize + 1)
        .ToListAsync(ct);
    
    var hasNextPage = orders.Count > query.PageSize;
    var data = orders.Take(query.PageSize).ToList();
    
    var nextCursor = hasNextPage 
        ? data.Last().OrderDate.ToString("O") 
        : null;
    
    return new CursorPaginatedResult<OrderDto>
    {
        Data = _mapper.Map<List<OrderDto>>(data),
        NextCursor = nextCursor,
        HasNextPage = hasNextPage
    };
}

Search with Pagination

Combine full-text search with pagination:
public record SearchOrdersQuery(
    string SearchTerm,
    PaginationRequest Pagination
) : IQuery<PaginatedResult<OrderDto>>;

public async Task<PaginatedResult<OrderDto>> Handle(
    SearchOrdersQuery query, 
    CancellationToken ct)
{
    var searchTerm = query.SearchTerm.ToLower();
    
    var queryable = _context.Orders
        .AsNoTracking()
        .Where(o => 
            o.OrderNumber.ToLower().Contains(searchTerm) ||
            o.Customer.Name.ToLower().Contains(searchTerm)
        );
    
    var totalCount = await queryable.LongCountAsync(ct);
    
    var orders = await queryable
        .OrderByDescending(o => o.OrderDate)
        .Skip(query.Pagination.PageIndex * query.Pagination.PageSize)
        .Take(query.Pagination.PageSize)
        .ToListAsync(ct);
    
    return new PaginatedResult<OrderDto>(
        query.Pagination.PageIndex,
        query.Pagination.PageSize,
        totalCount,
        _mapper.Map<List<OrderDto>>(orders)
    );
}

Best Practices

Always apply ordering before pagination to ensure consistent results across pages.
// Good - Ordered pagination
var orders = await _context.Orders
    .OrderByDescending(o => o.OrderDate)
    .Skip(pageIndex * pageSize)
    .Take(pageSize)
    .ToListAsync();

// Avoid - Unordered pagination
var orders = await _context.Orders
    .Skip(pageIndex * pageSize)
    .Take(pageSize)
    .ToListAsync(); // Results may vary between requests
Use LongCountAsync instead of loading all data and counting.
// Good - Efficient count
var totalCount = await queryable.LongCountAsync(ct);

// Avoid - Loading all data
var allData = await queryable.ToListAsync(ct);
var totalCount = allData.Count; // Loads everything into memory
Validate pagination parameters to prevent errors.
public class PaginationRequestValidator : AbstractValidator<PaginationRequest>
{
    public PaginationRequestValidator()
    {
        RuleFor(x => x.PageIndex)
            .GreaterThanOrEqualTo(0)
            .WithMessage("Page index must be 0 or greater");
        
        RuleFor(x => x.PageSize)
            .GreaterThan(0)
            .WithMessage("Page size must be greater than 0")
            .LessThanOrEqualTo(100)
            .WithMessage("Page size cannot exceed 100");
    }
}
Improve performance by disabling change tracking for read-only queries.
// Good - No tracking for read-only
var orders = await _context.Orders
    .AsNoTracking()
    .Skip(pageIndex * pageSize)
    .Take(pageSize)
    .ToListAsync();

// Avoid - Unnecessary tracking overhead
var orders = await _context.Orders
    .Skip(pageIndex * pageSize)
    .Take(pageSize)
    .ToListAsync();
For large tables, count operations can be expensive. Consider caching.
private async Task<long> GetTotalCountAsync(CancellationToken ct)
{
    var cacheKey = "orders:total-count";
    
    if (_cache.TryGetValue(cacheKey, out long cachedCount))
    {
        return cachedCount;
    }
    
    var count = await _context.Orders.LongCountAsync(ct);
    
    _cache.Set(cacheKey, count, TimeSpan.FromMinutes(5));
    
    return count;
}

Client-Side Usage

Example of consuming paginated API from a client:
interface PaginatedResult<T> {
  pageIndex: number;
  pageSize: number;
  count: number;
  data: T[];
}

interface OrderDto {
  id: string;
  orderNumber: string;
  customerId: string;
  orderDate: string;
  status: string;
  total: number;
}

async function getOrders(
  pageIndex: number = 0, 
  pageSize: number = 10
): Promise<PaginatedResult<OrderDto>> {
  const response = await fetch(
    `/api/orders?pageIndex=${pageIndex}&pageSize=${pageSize}`
  );
  
  if (!response.ok) {
    throw new Error('Failed to fetch orders');
  }
  
  return response.json();
}

// Usage
const result = await getOrders(0, 20);
console.log(`Page ${result.pageIndex + 1} of ${Math.ceil(result.count / result.pageSize)}`);
console.log(`Total orders: ${result.count}`);
console.log(`Orders on this page:`, result.data);

CQRS Pattern

Learn how to use pagination in queries

Building Blocks Overview

Explore other shared components

Build docs developers (and LLMs) love