ADR-0013: Collection endpoints return a bare array with pagination in headers, never a raw Page
| Status |
Accepted |
| Date |
2026-09-01 |
| Deciders |
Christhonie Geldenhuys |
| Related |
Bug #1078, Bug #1079 (the drift); Bug #1123 (enforcement); Feature #473; Enum Modelling, OpenAPI Contract Flow, Filterable List Pattern |
1. Context
admin-service exposes many collection (list) endpoints, consumed by the portals through the generated TypeScript client and JHipster-style readers that expect a bare-array body plus an X-Total-Count header — the contract JHipster’s PaginationUtil.generatePaginationHttpHeaders produces. The generated resources, and most hand-written ones (e.g. RaceNumberResource.getAllRaceNumbers), already follow it.
Spring Data’s own default, however, is Page<T>. Returning it straight from a controller — ResponseEntity<Page<T>> — serialises a wrapper body ({ "content": […], "totalElements": N, "pageable": {…}, … }) with no X-Total-Count header. It is the path of least resistance, and it drifts in silently on hand-written *ResourceEx classes where a developer wires the query service’s Page straight to the response.
That drift shipped to stage. GET /api/race-numbers/stock returns Page<RaceNumberDTO>. The admin-portal RaceNumberService.query() reads the body as IRaceNumber[] and the count from X-Total-Count; against a Page body it gets a wrapper object (not an array) and no header. The result: the T02 stock list renders empty, and the T05 UNFIT report shows nothing despite 144 UNFIT rows sitting in content (Bugs #1078 / #1079).
The forces in tension:
-
Consistency for the generated client and the FE readers — every list screen and the OpenAPI-generated client already assume one shape.
-
Least churn — the bare-array + header contract is the incumbent that the majority of endpoints and the whole FE already speak.
-
The framework pushes the other way —
Page<T>is what Spring hands you; the standard has to actively reject it. -
Header metadata is invisible to body-only tooling — which is precisely how this bug hid:
res.bodyis the obvious thing to read, and the pagination lived in a header nobody saw.
The UNFIT report calls /api/race-numbers/stock?state.in=F. The endpoint answers 200 with a Page whose content holds all 144 rows and whose totalElements is 144. The client binds res.body — the whole Page object — to a table that iterates an array, finds no iterable rows, and renders an empty state. Nothing errors; the report is simply, silently, blank.
2. Decision
-
Every collection endpoint returns a bare JSON array of the DTO as its response body. The handler signature is
ResponseEntity<List<DTO>>, neverResponseEntity<Page<DTO>>. -
Pagination metadata travels in HTTP headers —
X-Total-Countand the JHipsterLinkheader (rel=next|prev|first|last) — produced byPaginationUtil.generatePaginationHttpHeaders. Convert the query service’sPageinto aListbody and the headers; do not leak thePage. -
A controller must not return
Page<T>— or any envelope object — as the body. -
Filtering follows the JHipster criteria contract (
?field.equals=/.in=/.contains=/.greaterThanOrEqual=/.lessThanOrEqual=/.specified=, pluspage/size/sort), resolved through a JPASpecificationin a query service. -
Enum values on the wire follow Enum Modelling — the constant name in responses, the code in the database and as an accepted filter value, and strict filter resolution (accept code or name,
400otherwise). -
HTTP status contracts are declared with
@ApiResponses— Javadoc does not reach the published spec (see OpenAPI Contract Flow). -
The
Page-body prohibition is enforced mechanically, not by review alone (see Consequences).
3. Consequences
3.1. Positive
-
One contract for the generated client and every front-end list reader; a new list screen needs no per-endpoint special-casing.
-
Least migration — it matches the incumbent and the generated resources; only the drifted endpoints move.
-
The response body schema is exactly
DTO[]; no per-entityPageResponse<T>wrapper types pollute the OpenAPI spec or the generated client.
3.2. Negative
-
It fights Spring’s
Pagedefault, so it only stays true with enforcement (Bug #1123). -
Pagination metadata lives in headers — invisible to body-only tooling, and readable only from the full response, not the body. That is the exact trap behind Bug #1078; the FE contract must read the response, not just
res.body. -
X-Total-Countrequires aCOUNT(*)per list call (asPagedoes too). Acceptable at current data volumes; revisit if a collection grows large enough that the count dominates.
3.3. Neutral
-
The gateway already proxies and CORS-exposes
X-Total-Count/Link— proven by the SPAs that consume them today (registration-portal, membership-ui, and admin-portal’s ownstock-activity/operational-batchlist screens). CORS header exposure is browser-enforced, so a working browser client is the proof; no gateway change is required. -
Choosing headers over a self-describing body envelope is deliberate (see Alternatives).
3.4. Enforcement
An ArchUnit test in admin-service fails any @RestController handler that returns Page<…> or ResponseEntity<Page<…>>, with a message pointing to PaginationUtil and ResponseEntity<List<DTO>>. Tracked as Bug #1123. The same suite may be extended toward the enum-filter strictness gap where it is mechanically detectable.
4. Alternatives Considered
4.1. Alternative A: A body envelope (Page / { data, meta } / JSON:API / HAL)
Return pagination inside the body — Spring’s Page, a slim { data, meta } object, or a standard envelope. It is self-describing (the response says there are more pages), it is where the wider industry has drifted, and it stops fighting the framework default. Rejected because adopting it now would re-touch every conforming endpoint, every front-end list reader, and force a client regeneration — a large migration for a marginal gain over the incumbent, and consistency with the incumbent is the actual objective. This would be the right choice for a greenfield service that has no established client to keep in step.
4.2. Alternative B: Adapt the front-end to consume Page (fix #1078 in admin-portal, leave /stock as-is)
Change RaceNumberService.query() to read page.content / page.totalElements and leave the endpoint returning a Page. Rejected because it blesses the drift and splits the contract — some endpoints return arrays, some return Page — which is the very problem this ADR exists to remove; and it spreads Page-handling across every FE consumer. It is only correct if the standard were the body envelope of Alternative A.
4.3. Alternative C: Cursor / keyset pagination (drop the total)
Return { items, nextCursor } and no total count. Rejected as unnecessary at current data scale and a larger client change; the value of X-Total-Count (operators want row counts) outweighs its cost here. Revisit for any specific collection that grows to where COUNT(*) becomes the bottleneck.
5. References
-
Design decision surfaced during the 2026-08-27 stage test pass of the T05 Stock Reports (admin-portal 0.1.7 / admin-service 2.4.15).
-
ADO: Feature #473 (Number & Tag Lifecycle Management); Bug #1078 and Bug #1079 (the
/stockdrift); Bug #1123 (ArchUnit enforcement). -
Code:
admin-service—RaceNumberResourceEx.stockView(the non-conformant endpoint) vsRaceNumberResource.getAllRaceNumbers(the conforming pattern);tech.jhipster.web.util.PaginationUtil. -
Related pages: OpenAPI Contract Flow (status-code contracts), Filterable List Pattern (the URL filter contract), Enum Modelling (enum wire representation), JHipster — Keep & Drop, REST API Conventions.