Generic Pagination Pattern

One base class for page number/size/sort direction, generic over a strongly-typed "order by" enum per resource, plus a filter DTO per resource that adds only the fields that resource can be searched by.

Base + generic sort type

Pagination.cs
public abstract class PaginationBase
{
    public int PageSize { get; set; } = 10;
    public int PageNumber { get; set; } = 1;
    public OrderType OrderType { get; set; }
}

public class Pagination<TOrderBy> : PaginationBase where TOrderBy : Enum
{
    public TOrderBy OrderBy { get; set; } = default!;
}

public enum OrderType
{
    Descending,
    Ascending
}

One order-by enum per resource

Each resource declares exactly the columns it's sensible to sort by — this becomes the allow-list; there's no way to accidentally expose "order by internal ID" or an unindexed column through the API, because the enum simply doesn't have that member.

PaginationEnums.cs
public enum ProductOrderBy
{
    CreatedAt,
    UpdatedAt,
    ProductName,
    Stock,
    Shelf,
    Brand,
    Sales
}

public enum EmployeeOrderBy
{
    CreatedAt,
    UpdatedAt,
    EmployeeName,
    EmployeeId,
    Role
}

Per-resource request DTO adds the filters

Inherits Pagination<TOrderBy> for the paging/sorting shell, then tacks on only the search fields that make sense for that resource.

PaginationDtos.cs
public class ProductPaginationDto : Pagination<ProductOrderBy>
{
    public string? ProductName { get; set; }
    public int Barcode { get; set; }
    public string? Location { get; set; }
}

public class EmployeePaginationDto : Pagination<EmployeeOrderBy>
{
    public string? EmployeeName { get; set; }
    public string? EmployeeId { get; set; }
    public EmployeeRoleDto? Role { get; set; }
}

Consuming it in a repository

The controller/service just passes the whole DTO down; the repository turns it into a query. A switch on the sort enum keeps the "which column" decision in one place per resource.

ProductRepository.cs
public async Task<List<Product>> GetAllProductsAsync(ProductPaginationDto pagination)
{
    var query = _dbSet.AsQueryable();

    if (!string.IsNullOrWhiteSpace(pagination.ProductName))
        query = query.Where(p => p.Name.Contains(pagination.ProductName));

    query = pagination.OrderBy switch
    {
        ProductOrderBy.ProductName => Sort(query, p => p.Name, pagination.OrderType),
        ProductOrderBy.Stock => Sort(query, p => p.StockAmount, pagination.OrderType),
        ProductOrderBy.UpdatedAt => Sort(query, p => p.UpdatedAt, pagination.OrderType),
        _ => Sort(query, p => p.CreatedAt, pagination.OrderType),
    };

    return await query
        .Skip((pagination.PageNumber - 1) * pagination.PageSize)
        .Take(pagination.PageSize)
        .ToListAsync();
}

private static IQueryable<T> Sort<T, TKey>(IQueryable<T> query, Expression<Func<T, TKey>> key, OrderType order)
    => order == OrderType.Ascending ? query.OrderBy(key) : query.OrderByDescending(key);
Same idea as Composable Repository Interfaces: push the "what's allowed" decision into the type system (an enum, a generic constraint) instead of trusting every caller to pass a sane raw string column name.