Sorting, includes and paging, all typed
Filters decide which rows come back; this piece covers the rest of the read pipeline — in what order, with which related data, and how many at a time. All three are typed: enums the client sends, translated by registrations you write once. And all three compose with everything from the earlier articles into the same single SQL statement.
Sorting: an enum, repeatable
The client sends ?sortBy= values from an enum you define; a sort builder translates them. From the sample:
e.SortBy((query, sortBy) => sortBy switch
{
VehicleSortBy.Code => query.OrderOrThenBy(x => x.Code),
VehicleSortBy.CodeDesc => query.OrderOrThenByDescending(x => x.Code),
VehicleSortBy.Brand => query.OrderOrThenBy(x => x.Brand!.Title),
VehicleSortBy.BrandDesc => query.OrderOrThenByDescending(x => x.Brand!.Title),
_ => query.OrderOrThenBy(x => x.Id)
});
?sortBy= is repeatable, and that's where the one sharp edge lives. The builder runs once per requested value, so each arm must start or continue the ordering. The framework's OrderOrThenBy / OrderOrThenByDescending extensions do exactly that — the hand-rolled is IOrderedQueryable<T> check people write instead compiles fine and throws on the first sorted request.
curl "$URL/vehicles?sortBy=BrandDesc&sortBy=Code&includes=Brand"
# Volvo (TRK-001, TRK-002), Scania (TRK-003), Mercedes-Benz (VAN-001, VAN-002)
# - brand descending, code ascending within a brand
Includes: lean by default, fat on request
Related data is a flags enum the client asks for — so list payloads stay small until somebody actually needs more:
[Flags]
public enum VehicleIncludes
{
None = 0, Brand = 1, VehicleType = 2, Interventions = 4,
All = Brand | VehicleType | Interventions
}
e.Includes((query, includes) =>
{
if (includes?.HasFlag(VehicleIncludes.Brand) == true)
query = query.Include(x => x.Brand);
if (includes?.HasFlag(VehicleIncludes.Interventions) == true)
query = query.Include(x => x.Interventions);
// ...
return query;
});
curl "$URL/vehicles" # brandTitle: null - nothing joined
curl "$URL/vehicles?includes=Brand" # brandTitle: "Volvo", ...
curl "$URL/vehicles/1" # everything - details always applies all includes
That last line is a deliberate default: Details(id) applies all registered includes, on the theory that a detail view wants the whole aggregate.
Two rules save real debugging time. An entity gets one Includes(...) registration — a second one replaces the first, so compose everything in one lambda. And an unexpectedly empty nested collection is a missing or wrongly-gated include, not a mapping bug — never something to "fix" in a filter.
Paging: defaults in options, clamps at the boundary
Two settings do the work: DefaultPageSize fills in when the request omits one, MaxPageSize caps what a request may ask for. The clamp is enforced once at the HTTP boundary — no client escapes it — while direct service-layer calls keep full control for jobs and exports.
curl "$URL/vehicles/search?pageSize=2&page=2"
# count 5, items TRK-003 + VAN-001 (page is 1-based; count = total matching rows)
Note what count is not: it's not the page length. It's the total the pager needs, computed server-side next to the page — which is exactly why the /search endpoint exists.
What to take away
Sorting, includes and paging are the same idea as filters wearing three more hats: a typed vocabulary the client sends, translated once at registration, composed by the pipeline. Typed means discoverable (OpenAPI shows the enums), and typed means the sharp edges — continuing an ordering, replacing an includes registration — surface in one place instead of every endpoint.
One piece left, and it answers the question every filter design eventually faces: AND, OR and the endpoints.
Docs: Regira Entities — sorting, includes, paging options.