Stage 7 · Master
Identity Service: HTTP API Layer
Pagination, Filtering, and Sorting
How list endpoints stay stable under change, why filters and sort options must match indexes, and how identity-service and community-service should expose tenant-scoped collections without teaching clients false assumptions.
Why List Endpoints Need Design
List endpoints appear simple because they usually return arrays. In production, they are often among the trickiest endpoints to design well. Clients want continuation, filtering, sorting, and totals. Databases want selective predicates and stable ordering. Multi-tenant systems add one more non-negotiable rule: every list begins inside a tenant partition. A good list contract therefore aligns client needs, database reality, and tenancy correctness instead of optimizing only for the first happy-path demo.
Meridian's likely hot lists are not random. identity-service will list memberships within a tenant. community-service will list residents inside a tenant or building, announcements by recency, and complaints by status. billing-service will list invoices by due date and payments by recency. The right pagination strategy depends on whether rows are mostly static catalog data or constantly changing operational data.
Offset vs Cursor
Offset pagination is easy to explain: page 1, page 2, page 3. It is also easy to make unstable. If new announcements or payments are inserted between requests, offset-based page boundaries shift and clients may see duplicates or gaps. Cursor pagination is harder to explain once and easier to operate forever because it continues from the last observed sort key instead of pretending the dataset stood still.
| Criterion | Offset pagination | Cursor pagination |
|---|---|---|
| Client familiarity | Higher | Lower at first |
| Stability under inserts | Poor | Good when the sort key is deterministic |
| Deep traversal cost | Worsens as offset grows | Usually remains index-friendly |
| Best fit for Meridian's activity feeds | Weak | Chosen for mutable collections |
Filters Must Match Indexes
A list contract is also an indexing contract. Offering arbitrary filtering and arbitrary sorting may look flexible, but it quietly promises performance and correctness across combinations the database was never designed to support. A disciplined backend instead exposes a small set of filters that correspond to real use cases and real composite indexes.
select id, tenant_id, subject, status, created_at
from complaints
where tenant_id = $1
and ($2::text is null or status = $2)
and (
$3::timestamptz is null
or (created_at, id) < ($3, $4)
)
order by created_at desc, id desc
limit $5;The query begins with tenant scope, then applies the optional filter, then continues from the last seen tuple. That order should mirror the index order as closely as possible.
The same principle applies even to apparently calm datasets such as memberships. If the service allows filtering memberships by role and sorting by created_at, the index strategy should reflect tenant_id, role, created_at, and a deterministic tiebreaker. Otherwise the API contract and the storage design are telling two different stories.
HTTP Contract Shape
List endpoints should keep their query parameters small and explicit: limit, cursor or page, and a few meaningful filters. The response should return the items plus metadata that lets the client continue. Meridian's current response.Meta type in libs/platform/response is intentionally minimal, so this chapter is specifying the shape the list contract should converge on once list endpoints are implemented. The important idea is that metadata exists to support continuation and interpretation, not to mirror every SQL implementation detail.
{
"data": [
{
"id": "complaint_123",
"subject": "Water leakage in Tower A",
"status": "open",
"created_at": "2026-07-19T09:15:00Z"
}
],
"meta": {
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNy0xOVQwOToxNTowMFoiLCJpZCI6ImNvbXBsYWludF8xMjMifQ",
"limit": 20,
"sort": "created_at_desc"
},
"error": null
}The exact metadata fields can evolve, but the envelope still follows the shared success contract: data plus optional meta and error: null.
Totals deserve careful thought. Exact total counts can be expensive on large, highly filtered, or frequently updated tables. Some endpoints truly need them; others do not. A senior backend engineer should teach that trade-off honestly instead of returning total_count everywhere simply because clients find it pleasant. Every piece of response metadata has a cost somewhere.
Every extra sort field is a long-term promise about stable ordering, index fit, and pagination semantics. A disciplined API exposes only the combinations it is prepared to implement well.
Current Repository Status
The current services expose only /healthz, so no pagination or filtering code exists in the reference repository. This chapter is a design specification for the first tenant-scoped collection endpoints in identity-service, community-service, and billing-service.
- Design list endpoints around real tenant-scoped collections, not generic array responses.
- Prefer cursor pagination for mutable feeds such as announcements, complaints, invoices, and payments.
- Offer only filters and sort orders that the schema and indexes are prepared to support.
- Use metadata to communicate continuation honestly instead of promising cheap totals everywhere.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.