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 wayPage<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.body is the obvious thing to read, and the pagination lived in a header nobody saw.

Example scenario

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>>, never ResponseEntity<Page<DTO>>.

  • Pagination metadata travels in HTTP headersX-Total-Count and the JHipster Link header (rel=next|prev|first|last) — produced by PaginationUtil.generatePaginationHttpHeaders. Convert the query service’s Page into a List body and the headers; do not leak the Page.

  • 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=, plus page / size / sort), resolved through a JPA Specification in 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, 400 otherwise).

  • 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-entity PageResponse<T> wrapper types pollute the OpenAPI spec or the generated client.

3.2. Negative

  • It fights Spring’s Page default, 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-Count requires a COUNT(*) per list call (as Page does 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 own stock-activity / operational-batch list 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 /stock drift); Bug #1123 (ArchUnit enforcement).

  • Code: admin-serviceRaceNumberResourceEx.stockView (the non-conformant endpoint) vs RaceNumberResource.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.