Deterministic Pagination in SpringBoot with Pageable and Custom PK Sorting

Rigas PapazisisSpringBoot

Overview

In Spring Boot applications using Spring Data JPA, pagination via Pageable is a standard way to retrieve large datasets efficiently. However, when sorting by non-unique fields (e.g., type, status, or creationDate), it can lead to inconsistent or duplicated results across pages. This post outlines the root cause, why deterministic sorting is critical for stable paging, and our chosen approach to enforce it dynamically and flexibly.

The Problem

When paginating a dataset sorted by a non-unique column, the database can return rows in a non-deterministic order. For example:

Imagine we have a Computation entity with the following attributes:

  • id (primary key)
  • type (A, B, or C)
  • creationDate

Suppose we have 1000 records:

  • 500 with type A
  • 300 with type B
  • 200 with type C

When querying:

GET /computations?sort=type,asc&page=1&size=20

The results are sorted by type, but since many rows share the same value (e.g., all “A” records), the database is free to return them in any internal order.

This can lead to duplicated or missing records across different pages.

Why It Matters

Inconsistent pagination:

  • Breaks frontend UIs relying on consistent pages
  • Causes confusion and data anomalies
  • Makes it impossible to guarantee reliable navigation across pages

To ensure consistency, we must always include a tie-breaker in the sorting—typically the entity’s primary key. But not all entities use id as their PK field name (e.g., some use code, typeId, etc.).

Solution: Append Primary Key Dynamically Using a Custom Annotation

We wanted a global, reusable approach to append the primary key to the sort condition only if it’s not already present, and allow this behavior to adapt to each entity’s unique key field.

Design Goals

  • Centralize logic for appending primary key in sort
  • Avoid boilerplate in each controller or repository
  • Support entities with different PK field names
  • Avoid modifying repositories or overriding JPA behavior

Step 1: Define @AdditionalSort Annotation

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface AdditionalSort {
    String value() default "id";
}

This allows us to annotate Pageable parameters and specify the primary key (or fallback field) per endpoint.

Step 2: Configure a Custom Pageable Resolver

@Configuration
public class PageableConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        PageableHandlerMethodArgumentResolver pageResolver =
                new PageableHandlerMethodArgumentResolver(new AdditionalSortHandlerMethodArgumentResolver());
        resolvers.add(pageResolver);
    }

    private static class AdditionalSortHandlerMethodArgumentResolver extends SortHandlerMethodArgumentResolver {

        @Override
        public Sort resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                    NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
            Sort sort = super.resolveArgument(parameter, mavContainer, webRequest, binderFactory);

            // Return as-is if unsorted
            if (sort.isUnsorted()) return sort;

            if (parameter.hasParameterAnnotation(AdditionalSort.class) &&
                    parameter.getParameterAnnotation(AdditionalSort.class).value() != null) {

                String additionalSortField = parameter.getParameterAnnotation(AdditionalSort.class).value();

                // Append the field only if not already present
                if (sort.getOrderFor(additionalSortField) == null) {
                    return sort.and(Sort.by(additionalSortField));
                }
            }
            return sort;
        }
    }
}

This resolver checks if the controller parameter has the @AdditionalSort annotation. If the annotated field is not already present in the user-provided sort, it appends it.

Step 3: Use in Controllers

You can now annotate any pageable parameter to apply this logic:

@GetMapping(path = "/computation-definition", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Page<ComputationDefinitionAllDTO>> getAllComputationDefinitions(
        @RequestParam(value = "code", required = false) Optional<String> optionalCode,
        @PageableDefault(size = 20)
        @SortDefault(sort = "id", direction = Sort.Direction.DESC)
        @AdditionalSort("id") Pageable pageable) {

    Page<ComputationDefinitionAllDTO> result = computationDefinitionService.getAllComputationDefinitions(
            optionalCode, optionalResolution, pageable);
    return ResponseEntity.ok(result);
}
@GetMapping(path = "/codes", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Page<ReportsReportCodeDTO>> searchReportCodes(
        @RequestParam(value = "code", required = false) Optional<String> optionalCode,
        @PageableDefault(size = 500)
        @SortDefault(sort = "code", direction = Sort.Direction.ASC)
        @AdditionalSort("code") Pageable pageable) {

    Page<ReportsReportCodeDTO> results = reportDefinitionService.searchReportCodes(
            optionalCode, optionalReportsProvider, pageable);
    return ResponseEntity.ok(results);
}

In the first case, we append id as the tie-breaker. In the second, since the primary key is named code, we annotate accordingly.

Advantages of This Approach

  • Guarantees deterministic pagination without duplications
  • No repository-level changes
  • Minimal, localized controller changes
  • Supports different key fields for different entities
  • Works with existing @PageableDefault and @SortDefault

Conclusion

Pagination must be stable and predictable to ensure a correct user experience. By using a global @AdditionalSort mechanism, we maintain clean code and avoid subtle bugs caused by unstable sort orders.

This approach is scalable, customizable, and integrates well with Spring MVC’s argument resolution infrastructure.

Rigas Papazisis