Backend Development with .NET
Session 07
Pagination, Filtering
& Sorting
Eng. Seif Mansour · Andalusia Academy
Week 4 · 2.5 hours
Session Goals
By the end of this session, you will be able to:
- Implement offset-based and cursor-based pagination in a .NET Web API
- Build reusable filter and sort query models that compose together
- Return pagination metadata correctly alongside response data
- Explain when cursor pagination is preferable over offset pagination
- Cap and whitelist user-supplied query parameters server-side to prevent abuse
Session Agenda
| Time |
Segment |
Type |
Duration |
| 0:00 | Why pagination exists | Theory | 10 min |
| 0:10 | Offset vs cursor pagination | Theory | 20 min |
| 0:30 | Implementing offset pagination in .NET | Demo | 25 min |
| 0:55 | Filtering conventions | Theory + Demo | 20 min |
| 1:15 | Break | — | 10 min |
| 1:25 | Sorting conventions | Theory + Demo | 15 min |
| 1:40 | Lab — paginated task list endpoint | Lab | 35 min |
| 2:15 | Wrap-up & discussion | Discussion | 15 min |
Section 1 of 4
Why Pagination Exists
An API that returns every row in the database in a single response will eventually crash the server, saturate the network, and break the client. Pagination is not an optimization — it is a correctness requirement once data grows beyond a few hundred rows.
In this section
- The cost of unbounded list responses
- Pagination as a contract with clients
- The rule: never return unbounded lists
The Cost of Unbounded List Responses
- Server memory — materializing 1 million rows into a list allocates all of them in memory at once before the first byte is sent
- Database load — a query with no
LIMIT locks table pages and competes with every other request for I/O
- Network bandwidth — a 50 MB JSON payload chokes mobile clients and slow connections entirely
- Client crash — browsers and mobile apps that try to render 100,000 list items freeze or crash
- Timeouts — long-running responses are killed by proxies, load balancers, and client timeout settings
Rule
Never return unbounded lists. Every collection endpoint must have a default page size and a server-enforced maximum.
Section 2 of 4
Offset vs Cursor Pagination
Two fundamentally different models for splitting a result set into pages. Offset is simple and UI-friendly; cursor is stable with live data. Knowing when to use each is a mark of a senior API designer.
In this section
- How offset pagination works
- The duplicate / skip problem with live data
- How cursor pagination solves it
- When to use each approach
Offset vs Cursor — Side by Side
Offset Pagination
GET /api/tasks?page=2&pageSize=20
- Simple to implement and understand
- Works well for numbered UI pages
- Can jump directly to any page number
- Unstable with live data — inserts shift pages, causing duplicates or skipped items
Cursor Pagination
GET /api/tasks?cursor=eyJpZCI6MjB9&pageSize=20
- Cursor encodes position (ID or timestamp)
- Stable with live data — no duplicates or skips
- Best for infinite scroll and real-time feeds
- Cannot jump to an arbitrary page number
Course approach
We implement offset pagination in this course. Cursor pagination is the production choice for feeds and any endpoint where the underlying data changes frequently between page requests.
The Offset Instability Problem
With offset pagination, the server calculates the page boundary at query time. If a new item is inserted between page 1 and page 2 requests, every item after it shifts by one position.
- Client requests page 1: items 1–20 returned
- A new item is inserted at position 1 (newest first sort)
- Client requests page 2: the server skips items 21–40 — but item 20 is now at position 21, so it appears again
- Result: one duplicate and one skipped item without any error
Impact
For most admin dashboards and task lists, this is acceptable. For a social feed or transaction history, it is a serious bug.
Sorted newest first
Page 1 request — items A..T
-- new item Z inserted --
Page 2 request SKIP 20:
Z is now #1
A is now #2 ... T is now #21
Returned: T (duplicate!), U, V ...
Section 3 of 4
Implementing Offset Pagination
A reusable PaginationParams model, a generic PagedResult wrapper, and a controller that wires them together. The design keeps pagination logic out of controllers and out of business services.
In this section
- PaginationParams model
- PagedResult<T> wrapper
- Applying Skip / Take in the service
- Filtering with TaskFilterParams
- Whitelisted sorting
Reusable PaginationParams Model
Models/PaginationParams.cs
public class PaginationParams
{
private const int MaxPageSize = 100;
private int _pageSize = 20;
public int Page { get; set; } = 1;
public int PageSize
{
get => _pageSize;
set => _pageSize = value > MaxPageSize ? MaxPageSize : value;
}
}
Why cap server-side?
Never trust the client's
pageSize. Without the cap, a caller passing
pageSize=1000000 bypasses all the protection pagination was meant to provide.
PagedResult<T> — Response Wrapper
Models/PagedResult.cs
public class PagedResult<T>
{
public IEnumerable<T> Data { get; set; } = [];
public int Page { get; set; }
public int PageSize { get; set; }
public int TotalCount { get; set; }
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
public bool HasNextPage => Page < TotalPages;
public bool HasPreviousPage => Page > 1;
}
Computed properties TotalPages, HasNextPage, and HasPreviousPage are derived — clients never need to calculate them themselves.
What the Paginated Response Looks Like
Clients receive both the data and enough metadata to build navigation controls without issuing a separate count query.
data — the items for this page
page — current page number
pageSize — items per page (after capping)
totalCount — total items in the full dataset
totalPages — total pages
hasNextPage / hasPreviousPage — for nav buttons
GET /api/tasks?page=2&pageSize=5
{
"data": [ {...}, {...}, {...}, {...}, {...} ],
"page": 2,
"pageSize": 5,
"totalCount": 47,
"totalPages": 10,
"hasNextPage": true,
"hasPreviousPage": true
}
Section 4 of 4
Filtering & Sorting
Query parameters are the REST-idiomatic way to narrow and order collection responses. A single composable filter model that inherits from PaginationParams keeps the surface area small and lets all combinations work without duplicating code.
In this section
- Filter query parameter conventions
- TaskFilterParams — composable model
- Sort parameter conventions
- Whitelisting sort fields
- Composing all three together
Filtering Conventions
Filters are passed as query parameters. Each filter narrows the result set independently and all filters compose together with AND logic.
GET /api/tasks?isCompleted=true
GET /api/tasks?search=meeting
GET /api/tasks?createdAfter=2026-01-01
GET /api/tasks?createdBefore=2026-03-01
GET /api/tasks?search=meeting&isCompleted=false
Convention
Nullable filter parameters mean "no filter" when absent — never default them to a value that silently excludes results.
Models/TaskFilterParams.cs
public class TaskFilterParams : PaginationParams
{
public string? Search { get; set; }
public bool? IsCompleted { get; set; }
public DateTime? CreatedAfter { get; set; }
public DateTime? CreatedBefore { get; set; }
}
Inheriting from PaginationParams means a single model covers page, size, and all filters — the controller binds it from the query string in one step.
Applying Filters in the Service Layer
Services/TaskService.cs
public PagedResult<TaskItem> GetAll(TaskFilterParams filters)
{
var query = _tasks.AsQueryable();
if (!string.IsNullOrWhiteSpace(filters.Search))
query = query.Where(t => t.Title.Contains(filters.Search,
StringComparison.OrdinalIgnoreCase));
if (filters.IsCompleted.HasValue)
query = query.Where(t => t.IsCompleted == filters.IsCompleted.Value);
var totalCount = query.Count();
var data = query.Skip((filters.Page - 1) * filters.PageSize)
.Take(filters.PageSize)
.ToList();
return new PagedResult<TaskItem>
{
Data = data,
Page = filters.Page,
PageSize = filters.PageSize,
TotalCount = totalCount
};
}
Count is taken before Skip/Take so TotalCount reflects the full filtered set, not just the current page.
Sorting Conventions
Standard convention: ?sortBy=createdAt&order=desc
sortBy — the field to sort on (must be whitelisted)
order — asc or desc (default: asc)
Security
Never pass the
sortBy value directly to an
ORDER BY clause. This is an injection vector. Always compare against an explicit whitelist of allowed field names.
Sorting whitelist
var allowedSort =
new Dictionary<string, Func<TaskItem, object>>
{
["title"] = t => t.Title,
["createdAt"] = t => t.CreatedAt,
["isCompleted"] = t => t.IsCompleted
};
if (allowedSort.TryGetValue(
filters.SortBy ?? "createdAt", out var keySelector))
{
query = filters.Order == "desc"
? query.OrderByDescending(keySelector)
: query.OrderBy(keySelector);
}
TaskFilterParams — Full Composable Model
Models/TaskFilterParams.cs
public class TaskFilterParams : PaginationParams
{
public string? Search { get; set; }
public bool? IsCompleted { get; set; }
public DateTime? CreatedAfter { get; set; }
public DateTime? CreatedBefore { get; set; }
public string? SortBy { get; set; } = "createdAt";
public string? Order { get; set; } = "asc";
}
One model. All combinations work: ?search=meeting&isCompleted=false&page=1&pageSize=5&sortBy=title&order=asc
Controller — Binding the Full Model
The controller action stays thin. ASP.NET Core binds every query string key into the filter model automatically — no manual parsing needed.
[FromQuery] binds all query params into TaskFilterParams at once
- Model binding applies capping rules from
PaginationParams before the action runs
- The service handles all filtering, sorting, and paging logic
- The controller returns the full
PagedResult<T> as-is
Controllers/TasksController.cs
[HttpGet]
public IActionResult GetAll(
[FromQuery] TaskFilterParams filters)
{
var result = _taskService.GetAll(filters);
return Ok(result);
}
Clean controller
Three lines. All pagination, filter, and sort concerns live in the service and models — not here.
Design Principle
"Never trust the client's page size.
Never pass sort fields to ORDER BY directly.
Always return metadata alongside data."
These three rules prevent memory exhaustion, SQL injection, and client-side guesswork — all from the same feature.
Lab — Paginated Task List Endpoint
Enhance GET /api/tasks to support pagination, filtering, and sorting. All parameters must compose together.
1
Create PaginationParams with page, pageSize (default 20, max 100 enforced in the setter)
2
Create PagedResult<T> with data, page, pageSize, totalCount, and computed totalPages / hasNextPage / hasPreviousPage
3
Create TaskFilterParams : PaginationParams adding Search, IsCompleted, SortBy, and Order
4
Update TaskService.GetAll to apply filters, whitelist-sort, then Skip/Take, and return a PagedResult
5
Update the controller to accept [FromQuery] TaskFilterParams filters and return the paged result
6
In Postman: verify ?search=meeting&isCompleted=false&page=1&pageSize=5&sortBy=title returns correct metadata and data
Four Rules to Leave With
1
Never return unbounded lists — every collection endpoint must have a default page size enforced on the server
2
Cap pageSize server-side — reject or clamp any value above the maximum; never trust the client
3
Whitelist sort fields — never pass user input directly to ORDER BY; compare against an explicit dictionary of allowed keys
4
Return metadata alongside data — totalCount, totalPages, hasNextPage, and hasPreviousPage belong in every paged response
Summary
- Pagination is mandatory — unbounded list responses are a correctness and performance failure waiting to happen
- Offset vs cursor — offset is simple and UI-friendly; cursor is stable with live data and the right choice for feeds and real-time endpoints
- PaginationParams enforces the server-side cap in a single model that every collection endpoint can inherit or compose
- PagedResult<T> standardizes the response shape — clients always know where to find data and navigation metadata
- TaskFilterParams extends PaginationParams so one model covers filtering, sorting, and paging in a single query-string binding
- Sort field whitelisting prevents injection and keeps the API surface explicit — unknown sort keys are silently defaulted or rejected
What's Next
Session 08 — API Documentation & Swagger
- Now that your API returns paginated, filtered, and sorted responses, Session 08 covers how to document those query parameters so consumers do not have to read the source code
- You will integrate Swashbuckle (Swagger UI) into your .NET project and annotate endpoints with XML comments and response type attributes
- Swagger will automatically discover your
TaskFilterParams query parameters and expose them as interactive form fields in the UI
- After Session 08, your Task API will have a living, testable API reference that stays in sync with the code
Before next session
Complete today's assignment. Confirm that all combinations of filtering, sorting, and paging work together and that the metadata values are correct before Session 08.
Assignment
Extend the Task API with a fully composable paginated, filtered, and sorted GET endpoint.
What to build:
- Create
PaginationParams, PagedResult<T>, and TaskFilterParams in a Models/ folder
- Update
TaskService.GetAll to accept TaskFilterParams and return a PagedResult<TaskItem>
- Whitelist sort fields using a dictionary — reject (or default) any unknown
sortBy value
- Update the controller action to bind the params with
[FromQuery] and return the paged result
Acceptance criteria:
- Response always includes
totalCount, totalPages, hasNextPage, and hasPreviousPage
pageSize is capped at 100 regardless of what the client sends
?search=meeting&isCompleted=false&page=1&pageSize=5&sortBy=title returns correct results and correct metadata
- Passing an unknown
sortBy value does not crash — it falls back to the default sort
Bonus
Add
CreatedAfter and
CreatedBefore date range filters to
TaskFilterParams and apply them in the service. Verify that a date range combined with search and pagination still returns correct
totalCount.
Questions?
Session 07 — Pagination, Filtering & Sorting