WebServiceToolkit provides ModelList<T> for building paginated API responses.
ModelList<T> is a wrapper class that includes:
- The items for the current page
- Pagination metadata (page, total count, page count)
- Sort information
- Search term
| Property | Type | Description |
|---|---|---|
Items |
T[] |
Array of items for the current page |
TotalCount |
int |
Total items across all pages |
PagesCount |
int |
Total number of pages |
Page |
int |
Current page index (zero-based) |
Count |
int |
Number of items in current page |
SortBy |
string |
Column used for sorting |
IsAsc |
bool |
True if ascending order |
Search |
string |
Applied search query |
public async Task<ModelList<Product>> GetProductsAsync(int page, int pageSize)
{
var allProducts = await _repository.GetAllAsync();
var totalCount = allProducts.Count;
var items = allProducts
.Skip(page * pageSize)
.Take(pageSize)
.ToArray();
return new ModelList<Product>
{
Items = items,
TotalCount = totalCount,
PagesCount = (int)Math.Ceiling(totalCount / (double)pageSize),
Page = page,
Count = items.Length
};
}public async Task<ModelList<Product>> GetProductsAsync(
int page,
int pageSize,
string search,
string sortBy,
bool isAscending)
{
var query = _repository.Query();
// Apply search filter
if (!string.IsNullOrWhiteSpace(search))
{
query = query.Where(p =>
p.Name.Contains(search) ||
p.Description.Contains(search));
}
// Get total count before pagination
var totalCount = await query.CountAsync();
// Apply sorting
query = sortBy?.ToLower() switch
{
"name" => isAscending ? query.OrderBy(p => p.Name) : query.OrderByDescending(p => p.Name),
"price" => isAscending ? query.OrderBy(p => p.Price) : query.OrderByDescending(p => p.Price),
_ => query.OrderBy(p => p.Id)
};
// Apply pagination
var items = await query
.Skip(page * pageSize)
.Take(pageSize)
.ToArrayAsync();
return new ModelList<Product>
{
Items = items,
TotalCount = totalCount,
PagesCount = (int)Math.Ceiling(totalCount / (double)pageSize),
Page = page,
Count = items.Length,
SortBy = sortBy,
IsAsc = isAscending,
Search = search
};
}Combine ModelList<T> with query models for clean controller endpoints:
using DevInstance.WebServiceToolkit.Http.Query;
using System.ComponentModel;
[QueryModel]
public class ProductQuery
{
[DefaultValue(0)]
public int Page { get; set; }
[DefaultValue(20)]
public int PageSize { get; set; }
public string? Search { get; set; }
[QueryName("sort")]
public string? SortBy { get; set; }
[DefaultValue(true)]
public bool IsAscending { get; set; }
}[HttpGet]
public Task<ActionResult<ModelList<Product>>> GetProducts(ProductQuery query)
{
return this.HandleWebRequestAsync<ModelList<Product>>(async () =>
{
var result = await _productService.GetProductsAsync(
query.Page,
query.PageSize,
query.Search,
query.SortBy,
query.IsAscending);
return Ok(result);
});
}GET /api/products?page=0&pageSize=10&search=widget&sort=name
{
"items": [
{ "id": "abc123", "name": "Blue Widget", "price": 9.99 },
{ "id": "def456", "name": "Red Widget", "price": 14.99 }
],
"totalCount": 25,
"pagesCount": 3,
"page": 0,
"count": 10,
"sortBy": "name",
"isAsc": true,
"search": "widget"
}Use ModelListResult.SingleItemList() to wrap a single item:
using DevInstance.WebServiceToolkit.Common.Tools;
[HttpGet("{id}")]
public Task<ActionResult<ModelList<Product>>> GetProduct(string id)
{
return this.HandleWebRequestAsync<ModelList<Product>>(async () =>
{
var product = await _productService.GetByIdAsync(id);
if (product == null)
throw new RecordNotFoundException(id);
return Ok(ModelListResult.SingleItemList(product));
});
}Response:
{
"items": [{ "id": "abc123", "name": "Widget", "price": 9.99 }],
"totalCount": 1,
"pagesCount": 1,
"page": 1,
"count": 1
}WebServiceToolkit uses zero-based page indexing by default:
// Page 0 = first page
var items = query.Skip(page * pageSize).Take(pageSize);If your API uses one-based pages, adjust the calculation:
// Page 1 = first page
var items = query.Skip((page - 1) * pageSize).Take(pageSize);
return new ModelList<Product>
{
Page = page, // Return the 1-based page number
// ...
};For cursor-based pagination, you might extend ModelList<T>:
public class CursorModelList<T> : ModelList<T>
{
public string? NextCursor { get; set; }
public string? PreviousCursor { get; set; }
}Prevent performance issues by enforcing a maximum page size:
[QueryModel]
public class ProductQuery
{
private int _pageSize = 20;
public int PageSize
{
get => _pageSize;
set => _pageSize = Math.Min(value, 100); // Max 100
}
}Always get the total count before applying Skip/Take:
// Good: Count on full query
var totalCount = await query.CountAsync();
var items = await query.Skip(skip).Take(take).ToListAsync();
// Bad: Count after pagination (always returns <= pageSize)
var items = await query.Skip(skip).Take(take).ToListAsync();
var totalCount = items.Count;Always return full pagination metadata so clients can build UI:
return new ModelList<Product>
{
Items = items,
TotalCount = totalCount, // For "Showing 1-20 of 150"
PagesCount = pagesCount, // For page navigation
Page = page, // Current page indicator
Count = items.Length // Actual items returned
};public async Task<ModelList<Product>> GetProductsAsync(ProductQuery query)
{
var items = await ExecuteQueryAsync(query);
return new ModelList<Product>
{
Items = items.ToArray(),
TotalCount = items.Any() ? await CountAsync(query) : 0,
PagesCount = items.Any() ? CalculatePages(query) : 0,
Page = query.Page,
Count = items.Count
};
}When getting count and items, use separate query instances:
var baseQuery = BuildQuery(filters);
// These can run in parallel without affecting each other
var countTask = baseQuery.Clone().CountAsync();
var itemsTask = baseQuery.Clone().Skip(skip).Take(take).ToListAsync();
await Task.WhenAll(countTask, itemsTask);
return new ModelList<Product>
{
Items = itemsTask.Result.ToArray(),
TotalCount = countTask.Result,
// ...
};