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
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.
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.
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.
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.