# Pagination

> Return paged, sortable list results with the shared pagination primitives, from query to typed client.

List endpoints share one shape. A query inherits `PagedRequest`, the handler returns
`PagedResult`, the endpoint maps it to a wire `PagedResponse`, and the typed client reads that
same shape back. The primitives already ship in `Slicekit.Core.Common` and `Slicekit.Api.Common`. Reuse
them. Do not invent a per-endpoint envelope.

The canonical example is the admin users list:
`api/src/Slicekit.Core/Features/Users/ListUsers/` for the slice and
`api/src/Slicekit.Api/Endpoints/v1/Admin/ListUsersEndpoint.cs` for the HTTP adapter. This guide follows
that slice end to end. For the slice mechanics in general, see [vertical slices](/docs/vertical-slices).

## The request: PagedRequest

`PagedRequest` (in `api/src/Slicekit.Core/Common/PagedResult.cs`) is an abstract record carrying only
init-only `Page` and `PageSize`, defaulting to 1 and 20:

```csharp
public abstract record PagedRequest
{
    public int Page { get; init; } = 1;
    public int PageSize { get; init; } = 20;
}
```

Your query inherits it and declares only the filter and sort fields of its own slice. `ListUsersQuery`
adds a search term, a sort field, a direction and an enabled filter:

```csharp
public sealed record ListUsersQuery(
    string? Search = null,
    UserSortField Sort = UserSortField.CreatedAtUtc,
    SortDirection Direction = SortDirection.Desc,
    bool? Enabled = null) : PagedRequest;
```

`Page` and `PageSize` are inherited, so callers set them with object-initializer syntax rather than
through the constructor:

```csharp
new ListUsersQuery(search) { Page = 2, PageSize = 50 }
```

## The validator: PagedRequestValidator

Inherit `PagedRequestValidator` and you get the page bounds for free:

```csharp
public abstract class PagedRequestValidator : AbstractValidator where T : PagedRequest
{
    protected PagedRequestValidator()
    {
        RuleFor(x => x.Page).GreaterThanOrEqualTo(1);
        RuleFor(x => x.PageSize).InclusiveBetween(1, 100);
    }
}
```

The `PageSize` ceiling of 100 is deliberate: without it a caller can ask for a million rows and time
out the database. If a slice has no filter rules of its own, drop the body with a primary-constructor
declaration, exactly as `ListUsers` does:

```csharp
public sealed class ListUsersQueryValidator : PagedRequestValidator;
```

If a slice does need filter rules, add them in the constructor. To raise the ceiling for one endpoint,
re-declare `RuleFor(x => x.PageSize)` in the derived constructor; FluentValidation applies last rule
wins for the same property.

## The handler: PagedResult

`PagedResult` is the handler return type. The total-page and navigation flags are computed
properties on the record, so the handler never works them out inline:

```csharp
public sealed record PagedResult(
    IReadOnlyList Items,
    int TotalCount,
    int Page,
    int PageSize)
{
    public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
    public bool HasNextPage => Page < TotalPages;
    public bool HasPreviousPage => Page > 1;
}
```

The handler filters once, counts, orders, then takes the page. Ordering is mandatory:

```csharp
public async Task<Result<PagedResult>> HandleAsync(
    ListUsersQuery query, CancellationToken ct = default)
{
    var q = db.Users.AsNoTracking();

    if (!string.IsNullOrWhiteSpace(query.Search))
    {
        var search = query.Search.Trim();
        if (Guid.TryParse(search, out var id))
            q = q.Where(u => u.Id == id);
        else
            q = q.Where(u => u.Email!.Contains(search));
    }

    if (query.Enabled is { } enabled)
        q = q.Where(u => u.Enabled == enabled);

    var totalCount = await q.CountAsync(ct);

    q = ApplyOrder(q, query.Sort, query.Direction);

    var items = await q
        .Skip((query.Page - 1) * query.PageSize)
        .Take(query.PageSize)
        .Select(u => new UserListItem(u.Id, u.Email!, u.Enabled, u.CreatedAtUtc, u.LastLoginAtUtc))
        .ToListAsync(ct);

    return new PagedResult(items, totalCount, query.Page, query.PageSize);
}
```

Two round-trips (one `CountAsync`, one page query) is the right default. Note that `CountAsync` runs
on the filtered query but before `ApplyOrder`: counting does not need an `ORDER BY`, and skipping it
keeps Postgres from doing a needless sort.

## Why ordering is not optional

Postgres returns rows in an unspecified order without an `ORDER BY`, so the same page request can
return different rows across calls and pages can overlap. `ListUsers` resolves the sort field and
direction to a deterministic key with a switch:

```csharp
private static IQueryable ApplyOrder(IQueryable q, UserSortField sort, SortDirection dir) =>
    (sort, dir) switch
    {
        (UserSortField.Email, SortDirection.Asc) => q.OrderBy(u => u.Email),
        (UserSortField.Email, SortDirection.Desc) => q.OrderByDescending(u => u.Email),
        (UserSortField.LastLoginAtUtc, SortDirection.Asc) => q.OrderBy(u => u.LastLoginAtUtc),
        (UserSortField.LastLoginAtUtc, SortDirection.Desc) => q.OrderByDescending(u => u.LastLoginAtUtc),
        (_, SortDirection.Asc) => q.OrderBy(u => u.CreatedAtUtc),
        _ => q.OrderByDescending(u => u.CreatedAtUtc),
    };
```

Default to `CreatedAtUtc` and add a tie-breaker on `Id` if duplicate timestamps are possible. Exposing
sort fields as an enum rather than a raw column name keeps the surface closed: a caller can only sort
by what you list.

## The endpoint: bind and map

The endpoint binds `page`, `pageSize` and the filters straight from the query string, dispatches the
query over Wolverine, and maps the Core list item to a public DTO:

```csharp
private static async Task<Results<Ok<PagedResponse>, ProblemHttpResult>> HandleAsync(
    int page = 1,
    int pageSize = 20,
    string? search = null,
    UserSortField sort = UserSortField.CreatedAtUtc,
    SortDirection direction = SortDirection.Desc,
    bool? enabled = null,
    IMessageBus bus = default!,
    CancellationToken ct = default)
{
    var result = await bus.InvokeAsync<Result<PagedResult>>(
        new ListUsersQuery(search, sort, direction, enabled) { Page = page, PageSize = pageSize }, ct);
    if (!result.IsSuccess) return result.Error.ToProblem();

    var paged = result.Value;
    var items = paged.Items
        .Select(u => new UserItem(u.Id, u.Email, u.Enabled, u.CreatedAtUtc, u.LastLoginAtUtc))
        .ToArray();
    return TypedResults.Ok(new PagedResponse(items, paged.TotalCount, paged.Page,
        paged.PageSize, paged.TotalPages));
}
```

The shared `Slicekit.Api.Common.PagedResponse` carries the navigation flags and offers a
`PagedResponse.From(pagedResult)` factory so an endpoint never recomputes them by hand. When the
Core list-item shape is already the wire shape, call `From` directly instead of projecting. The admin
endpoint defines its own narrower `PagedResponse` carrying only `TotalPages`, because that is all
its client reads. Use whichever matches the contract your client consumes; do not return more fields
than the SPA uses.

## The typed client consumes the same shape

On the frontend the wire shape is mirrored in `frontend/src/shared/api/types.ts`, one to one with what
the admin endpoint returns:

```tsx

  items: T[];
  totalCount: number;
  page: number;
  pageSize: number;
  totalPages: number;
};
```

The client method builds the query string and hands the generic to `apiFetch`, which returns the
parsed shape typed (see [the API client](/docs/api-client)):

```tsx
listUsers: (
  page = 1,
  pageSize = 20,
  search?: string,
  sort: UserSortField = 'CreatedAtUtc',
  direction: SortDirection = 'Desc',
  enabled?: boolean,
) => {
  const params = new URLSearchParams({
    page: String(page),
    pageSize: String(pageSize),
    sort,
    direction,
  });
  if (search) params.set('search', search);
  if (enabled !== undefined) params.set('enabled', String(enabled));
  return apiFetch<PagedResponse>(`/api/v1/admin/users?${params}`);
},
```

A TanStack `useQuery` wraps that call. The page and filter values belong in the query key so each page
is cached separately, and `placeholderData: (prev) => prev` keeps the previous page visible while the
next one loads instead of flashing empty:

```tsx

  page = 1,
  pageSize = 20,
  search?: string,
  sort: UserSortField = 'CreatedAtUtc',
  direction: SortDirection = 'Desc',
  enabled?: boolean,
) {
  return useQuery({
    queryKey: [...adminUsersQueryKey, page, pageSize, search ?? '', sort, direction, enabled ?? 'all'],
    queryFn: () => adminApi.listUsers(page, pageSize, search, sort, direction, enabled),
    placeholderData: (prev) => prev,
  });
}
```

The component reads `data.items` for the rows and `data.totalPages` (or `data.totalCount`) to drive
the pager.

## When offset paging is not enough

`Skip(n)` grows expensive as `n` grows, because Postgres still reads and discards the skipped rows. For
tables that can grow large or be paged deeply (exports, infinite scroll), reach for cursor-based
paging: order by a stable key, return a `nextCursor` (typically the last `Id` plus `CreatedAtUtc`), and
have the client send it back. Slicekit ships no generic cursor helper. Add one only when a specific
endpoint demonstrably needs it.

## Checklist

- Query inherits `PagedRequest`; declare only your own filter and sort fields.
- Validator inherits `PagedRequestValidator`; keep the `PageSize` ceiling unless you have a reason.
- Handler returns `PagedResult`: filter, `CountAsync`, order, `Skip`/`Take`, project.
- Ordering is present and deterministic (a stable key, with a tie-breaker if needed).
- Endpoint binds `page`/`pageSize` from the query string and maps to the wire `PagedResponse`.
- Frontend type mirrors the wire shape; page and filters live in the TanStack query key.
- Verify: `?page=2&pageSize=20` returns the next slice, and `?pageSize=10000` is rejected with HTTP 400.
