skip to content

How does Spring for GraphQL turn the incoming first/after/last/before arguments into a controller method parameter? Explain ScrollSubrange and Subrange.

level: middleimportance: should knowfreq 33%

answer

  1. Subrange = position + count + forward
  2. ScrollSubrange extends Subrange<ScrollPosition>
  3. no @Argument needed — resolved by type
  4. position() empty on first page
  5. cursor decoded by ScrollPositionCursorStrategy

basics

~20 s

You declare a ScrollSubrange parameter on the controller method. Spring parses first/after (or last/before) into it, exposing the decoded position (a Spring Data ScrollPosition), the count, and a forward flag you pass to your query.

solid answer

~40 s

Spring for GraphQL resolves a `ScrollSubrange` (or the generic `Subrange`) method parameter automatically via a dedicated argument resolver — you don't even need `@Argument`. It reads the Relay pagination args and decodes the incoming cursor (`after`/`before`) using the configured `CursorStrategy` (default `ScrollPositionCursorStrategy`). The resulting `ScrollSubrange` exposes three things: `position()` → `Optional<ScrollPosition>` (the decoded keyset or offset position to scroll from, empty on the first page), `count()` → `OptionalInt` (from `first`/`last`), and `forward()` → boolean (`true` for `first`/`after`, `false` for `last`/`before`). You feed these into a Spring Data repository call — typically `Repository.findBy(..., scrollPosition, Limit.of(count))` or a `findFirstNBy...` query — and return a `Window<T>`. `ScrollSubrange` also normalizes direction: it applies `ScrollPosition.forward()`/`backward()` so the position already carries the paging direction.

code

java · 20 lines
java
@Controller
class BookController {

    private final BookRepository repository; // extends CrudRepository & supports scrolling

    BookController(BookRepository repository) { this.repository = repository; }

    // ScrollSubrange is resolved by type — no @Argument required.
    @QueryMapping
    Window<Book> books(ScrollSubrange subrange) {
        ScrollPosition position = subrange.position()
                .orElse(ScrollPosition.keyset()); // empty => first page
        int count = subrange.count().orElse(20);  // default page size
        Sort sort = subrange.forward()
                ? Sort.by("createdAt", "id").ascending()
                : Sort.by("createdAt", "id").descending();
        return repository.findBy(Specification.unrestricted(),
                q -> q.limit(count).sortBy(sort).scroll(position));
    }
}

go deeper

for a junior

Know that Spring gives you a parameter object carrying the count, decoded position, and direction.

for a middle

Name ScrollSubrange, its three accessors, and that the cursor is decoded to a ScrollPosition automatically.

for a senior

Explain direction normalization and the first-page empty-position handling; enforce a max count.

for a principal

Discuss the resolver design (Subrange<P> generic over position type) and the keyset-vs-offset backward-paging correctness difference.

**What a Subrange is.** `org.springframework.graphql.data.pagination.Subrange<P>` is Spring's abstraction of 'a slice of a list relative to a position, in a direction, with a count'. It has: - `position()` → `Optional<P>` — the decoded position to page relative to; **empty** when no `after`/`before` cursor was supplied (i.e. the first page). - `count()` → `OptionalInt` — how many items to fetch, taken from `first` or `last`. - `forward()` → `boolean` — `true` for forward paging (`first`/`after`), `false` for backward (`last`/`before`). **ScrollSubrange specializes it for Spring Data.** `org.springframework.graphql.data.query.ScrollSubrange extends Subrange<ScrollPosition>`. Here `P` is Spring Data's `org.springframework.data.domain.ScrollPosition`, which is either a `KeysetScrollPosition` (keyset/cursor seek) or an `OffsetScrollPosition` (offset). So `position()` gives you a ready-to-use `ScrollPosition` you can hand to a repository. **How the argument gets bound.** Spring registers a `SubrangeMethodArgumentResolver` that recognizes a method parameter of type `Subrange`/`ScrollSubrange`. When the field is invoked it: 1. Reads `first`/`after` or `last`/`before` from the GraphQL arguments. 2. Decodes the `after`/`before` cursor string via the configured `CursorStrategy` (for scroll, `ScrollPositionCursorStrategy`, which base64-decodes to a `ScrollPosition`). 3. Constructs a `ScrollSubrange` via `ScrollSubrange.create(position, count, forward)`, which also **normalizes direction** — for keyset it sets `position.forward()` or `position.backward()` so the seek direction is baked in. You therefore usually don't annotate the parameter; declaring the type is enough (though it coexists with other `@Argument`s and `@QueryMapping`). **Direction normalization gotcha.** For `KeysetScrollPosition`, backward paging is native: `ScrollPosition` records the direction, and Spring Data reverses the WHERE/ORDER internally. For `OffsetScrollPosition`, backward paging is *emulated*: `ScrollSubrange.create` adjusts the offset by the count to look 'before' the cursor, which can be imprecise near the list head. Prefer keyset for correct backward paging. **Empty position = first page.** On the initial request there is no `after`/`before`, so `position()` is empty. Handle that by using `ScrollPosition.offset()` / `ScrollPosition.keyset()` (the zero position) or the repository's no-position overload. **Count.** `count()` is optional because `first`/`last` are nullable in the schema; supply a sensible server-side default and enforce a max page size to prevent abuse. **Relationship to the response side.** `ScrollSubrange` is the *input* half. The *output* half is returning a `Window<T>`, which `ConnectionFieldTypeVisitor` + `ConnectionAdapter` turn into edges/pageInfo. They're two ends of the same feature.

  • Do you have to annotate the ScrollSubrange parameter with @Argument?
    No. A dedicated argument resolver binds it by type. `@Argument` is for named scalar/input arguments; the subrange is assembled from the standard first/after/last/before args plus cursor decoding.
  • What does subrange.forward() return for a request of last:10, before:"X"?
    false — `last`/`before` is backward paging. ScrollSubrange also flips the ScrollPosition to backward so the repository seeks in the correct direction.

saying these in an interview costs you the question

  • Manually calling Integer.parseInt on the cursor instead of letting CursorStrategy decode it
  • Thinking you must annotate the parameter with @Argument for it to bind
  • Assuming position() is always present (ignoring the first-page empty case)
  • Believing forward()/backward() has no effect on the actual SQL direction

context