PagedResults in EF Core: One class, endless reuse

Published: Monday 1 June 2026

.NET AI tool .NET C# AI

Get version-accurate .NET & C# AI answers.

If you're repeating the same paging logic — like count, skip, and take — across your application, you're adding unnecessary code to your app. Let us show you a cleaner way.

The common problem with PagedResults

Take this class:

// ManualPagedProductsDto.cs
public class ManualPagedProductsDto
{
    public IEnumerable<ProductDto> Records { get; set; } = [];
 
    public int TotalCount { get; set; }
 
    public int Page { get; set; }
 
    public int PageSize { get; set; }
 
    public int PageCount { get; set; }
}

There isn't anything wrong with this class as such. It contains all the properties we need for pagination: the records, the total count, page, page size, and page count. The problem is that it's tightly coupled to products. If we wanted to add a similar class for categories, for example, we would have to create a new class.

Build a reusable PagedResults class

The solution is to build a PagedResults class which takes in a generic type. The generic type represents the record type that we are returning.

// PagedResults.cs
public class PagedResults<T>
{
    public IEnumerable<T> Records { get; }
 
    public int TotalCount { get; }
 
    public int Page { get; }
 
    public int PageSize { get; }
 
    public int PageCount { get; }
}

We use an IEnumerable in the Records property to represent that it's read-only. We could use IList or ICollection, but these expose mutable methods like Add and Remove.

Setting the properties

These properties only have a getter, meaning that they are read-only. Therefore, the only way we can set them is through the constructor. We pass in parameters for the records, total count, page, and page size. For the page count, we can calculate it by dividing the total count by the page size. However, doing a simple division and converting to an int will not give us the correct page count. This is because the compiler will round it down to the nearest whole number.

If we have 11 records and a page size of 10, it will equal 1.1, which will be rounded down to 1. But there are 2 pages: 10 on the first page, and 1 on the second page. Therefore, we must use Math.Ceiling. This will round it up to the next whole number.

Another problem we have is that if either the total count or the page size equals 0, it will either return 0 or it won't return a number. But we will always have at least 1 page. Therefore, we have to add a clause to check if both are greater than 0. Only then do we perform the calculation. Otherwise, we return the total pages as 1.

// PagedResults.cs
public class PagedResults<T>
{
    public IEnumerable<T> Records { get; }
 
    public int TotalCount { get; }
 
    public int Page { get; }
 
    public int PageSize { get; }
 
    public int PageCount { get; }
 
    public PagedResults(
        IEnumerable<T> records,
        int totalCount,
        int page,
        int pageSize
        )
    {
        Records = records;
        TotalCount = totalCount;
        Page = page;
        PageSize = pageSize;
        PageCount = totalCount > 0 && pageSize > 0 ?
            (int)Math.Ceiling((double)totalCount / pageSize) : 1;
    }
}

The result

When we use this class, we would expect the API to return a response similar to this:

{
    "records": [
        {
            "id": 1,
            "name": "Wireless Noise-Cancelling Headphones",
            "description": "Over-ear headphones with active noise cancellation and 30-hour battery life.",
            "price": 149.99,
            "categoryName": "Electronics"
        },
        {
            "id": 2,
            "name": "Mechanical Keyboard",
            "description": "Tenkeyless mechanical keyboard with Cherry MX switches and RGB backlighting.",
            "price": 89.99,
            "categoryName": "Electronics"
        },
        {
            "id": 3,
            "name": "4K USB-C Monitor",
            "description": "27-inch 4K IPS display with USB-C power delivery and built-in speakers.",
            "price": 399.99,
            "categoryName": "Electronics"
        }
    ],
    "totalCount": 3,
    "page": 1,
    "pageSize": 10,
    "pageCount": 1
}

How to use PagedResults in Entity Framework

We can now use this class in Entity Framework Core queries.

Entity Framework Core is featured in our Minimal API course, where you'll be able to add queries in a real-world API. It's had great feedback from other developers, and there are free preview videos to get you started.

To use PagedResults in Entity Framework Core, we add a query with the results that we want to return. We then take that queryable and execute it to get the total records. We then work out the page count based on the total number of records and page size. We then get the records that we want to return for that page.

We can then use that records variable to create a new PagedResults class, with the record type as the generic type. We also add the total count, page, and page size.

public async Task<PagedResults<ProductDto>> GetPagedProductsManualAsync (
    int page,
    int pageSize,
    CancellationToken cancellationToken = default)
{
    var query = _dbContext.Products
        .Include(x => x.Category)
        .AsNoTracking();
 
    var totalCount = await query.CountAsync(cancellationToken);
 
    var pageCount = totalCount > 0 && pageSize > 0
        ? (int)Math.Ceiling((double)totalCount / pageSize)
        : 1;
 
    var records = await query
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .Select(x => new ProductDto
        {
            Id = x.Id,
            Name = x.Name,
            Description = x.Description,
            Price = x.Price,
            CategoryName = x.Category.Name
        })
        .ToListAsync(cancellationToken);
 
    return new PagedResults<ProductDto>(
        records,
        totalCount,
        page,
        pageSize
        );
}

However, the problem with this method is that it's also tightly coupled to products.

Add a queryable extension

The way to resolve this is to build a queryable extension method. We pass in the generic type and parameters such as the IQueryable, page, and page size. This allows us to query the total count and filter the paginated records before returning a PagedResults type.

// QueryableExtensions.cs
public static class QueryableExtensions
{
    public static async Task<PagedResults<T>> ToPagedResultsAsync<T>(
        IQueryable<T> query,
        int page,
        int pageSize,
        CancellationToken cancellationToken = default)
    {
        var totalCount = await query.CountAsync(cancellationToken);
 
        var records = await query
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .ToListAsync(cancellationToken);
 
        return new PagedResults<T>(records, totalCount, page, pageSize);
    }
}

As a result, we can significantly reduce the amount of code that appears in the method, and it's supported for multiple entities.

public Task<PagedResults<ProductDto>> GetPagedProductsManualAsync(
    int page,
    int pageSize,
    CancellationToken cancellationToken = default)
{
    return _dbContext.Products
        .Include(x => x.Category)
        .AsNoTracking()
        .Select(x => new ProductDto
        {
            Id = x.Id,
            Name = x.Name,
            Description = x.Description,
            Price = x.Price,
            CategoryName = x.Category.Name
        }).ToPagedResultsAsync(page, pageSize, cancellationToken);
}

Watch the video

Watch the video where we show you the step-by-step approach to building a PagedResults class that supports multiple types, and how you can use it in Entity Framework.