DEV Community

RD
RD

Posted on

Spring Reusable Pagination and Sorting

Ever wish you could add pagination and sorting to your REST APIs with minimal effort?

Below guide has been released as a library on Maven Central here

Imagine adding a single annotation to your controller methods and having all the query parameter parsing, validation, and error handling handled automatically:

@GetMapping
@PageSortConfig(
    defaultSize = 5,
    maxSize = 50,
    validSortFields = {"name", "createdAt", "updatedAt"}
)
public List<FileDto> listFiles(PageSortRequest pageSortRequest) {
    // Use pageSortRequest values for your database queries
}
Enter fullscreen mode Exit fullscreen mode

In this article, I'll show you how to build such a framework from scratch.

Index

  1. Create the PageSortRequest Model
  2. Create the Configuration Annotation
  3. Implement the Argument Resolver
  4. Register the Argument Resolver
  5. Using the Framework
  6. Conclusion

Step 1: Create the PageSortRequest Model

First, we need a class to hold pagination and sorting parameters. We'll use Java's record feature for an immutable representation:

public record PageSortRequest(
        int page,
        int size,
        String sortBy,
        String sortDir
) {
    private static final int PAGE_DEFAULT = 0;
    private static final int SIZE_DEFAULT = 25;
    private static final String SORT_BY_DEFAULT = "createdAt";
    private static final String SORT_DIR_DEFAULT = "asc";

    // Compact constructor with defaults
    public PageSortRequest {
        // Default values
        if (page < 0) page = PAGE_DEFAULT;
        if (size <= 0) size = SIZE_DEFAULT;
        if (sortBy == null || sortBy.isBlank()) sortBy = SORT_BY_DEFAULT;
        if (sortDir == null || sortDir.isBlank() || !isValidSortDirection(sortDir)) {
            sortDir = SORT_DIR_DEFAULT;
        } else {
            sortDir = sortDir.toLowerCase();
        }
    }

    private static boolean isValidSortDirection(String direction) {
        return "asc".equalsIgnoreCase(direction) || "desc".equalsIgnoreCase(direction);
    }

    // Utility methods
    public boolean isAscending() {
        return "asc".equalsIgnoreCase(sortDir);
    }

    public String getSqlSortDirection() {
        return isAscending() ? "ASC" : "DESC";
    }
}
Enter fullscreen mode Exit fullscreen mode

This record encapsulates four common parameters:

  • page - the page number (zero-based)
  • size - items per page
  • sortBy - field name to sort by
  • sortDir - sort direction ("asc" or "desc")

The compact constructor provides default values and normalization, so we don't have to handle null values elsewhere.

Step 2: Create the Configuration Annotation

Next, we need an annotation to configure pagination and sorting behavior on a per-endpoint basis:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PageSortConfig {
    /**
     * Minimum page number (default: 0)
     */
    int minPage() default 0;

    /**
     * Default page number when not specified or invalid (default: 0)
     */
    int defaultPage() default 0;

    /**
     * Minimum size/limit per page (default: 1)
     */
    int minSize() default 1;

    /**
     * Maximum size/limit per page (default: 100)
     */
    int maxSize() default 100;

    /**
     * Default page size when not specified or invalid (default: 25)
     */
    int defaultSize() default 25;

    /**
     * Valid field names that can be used for sorting
     */
    String[] validSortFields() default {};
}
Enter fullscreen mode Exit fullscreen mode

This annotation lets developers:

  • Set boundaries for page size
  • Define default values
  • List allowed sort fields to prevent SQL injection attacks
  • Configure each endpoint individually

Step 3: Implement the Argument Resolver

Now we need to create a class that implements Spring's HandlerMethodArgumentResolver interface to:

  1. Extract query parameters
  2. Apply validation rules
  3. Create the PageSortRequest object
@Component
@Slf4j
public class PageSortArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterType().equals(PageSortRequest.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
        // Extract annotation from method if present
        Method method = parameter.getMethod();
        PageSortConfig pageSortConfig = method != null ?
                method.getAnnotation(PageSortConfig.class) : null;

        // Get default values from annotation
        int defaultPage = pageSortConfig != null ? pageSortConfig.defaultPage() : 0;
        int defaultSize = pageSortConfig != null ? pageSortConfig.defaultSize() : 25;

        // Parse request parameters
        String pageParam = webRequest.getParameter("page");
        String sizeParam = webRequest.getParameter("size");
        String sortByParam = webRequest.getParameter("sortBy");
        String sortDirParam = webRequest.getParameter("sortDir");

        int page = defaultPage;
        int size = defaultSize;

        try {
            if (pageParam != null && !pageParam.isEmpty()) {
                page = Integer.parseInt(pageParam);
            }
        } catch (NumberFormatException e) {
            // Use default
        }

        try {
            if (sizeParam != null && !sizeParam.isEmpty()) {
                size = Integer.parseInt(sizeParam);
            }
        } catch (NumberFormatException e) {
            // Use default
        }

        // Apply validation if the annotation is present
        if (pageSortConfig != null) {
            validatePage(page, pageSortConfig.minPage());
            validateSize(size, pageSortConfig.minSize(), pageSortConfig.maxSize());
            validateSortBy(sortByParam, pageSortConfig.validSortFields());
            validateSortDir(sortDirParam);
        }

        return new PageSortRequest(page, size, sortByParam, sortDirParam);
    }

    private void validatePage(int page, int minPage) {
        if (page < minPage) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
                    "Page number cannot be less than " + minPage);
        }
    }

    private void validateSize(int size, int minSize, int maxSize) {
        if (size < minSize) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
                    "Page size cannot be less than " + minSize);
        }
        if (size > maxSize) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
                    "Page size cannot be greater than " + maxSize);
        }
    }

    private void validateSortBy(String sortBy, String[] validSortFields) {
        // If validSortFields is empty, no sorting is allowed
        if (validSortFields.length == 0 && sortBy != null && !sortBy.isBlank()) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
                    "Sorting is not allowed for this resource");
        }
        // If validSortFields is specified, sortBy must be one of them
        else if (validSortFields.length > 0 && sortBy != null && !sortBy.isBlank()) {
            Set<String> validFieldsSet = Arrays.stream(validSortFields).collect(Collectors.toSet());
            if (!validFieldsSet.contains(sortBy)) {
                throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
                        "Invalid sort field. Valid options are: " + String.join(", ", validFieldsSet));
            }
        }
    }

    private void validateSortDir(String sortDir) {
        if (sortDir != null && !sortDir.isBlank() &&
                !("asc".equalsIgnoreCase(sortDir) || "desc".equalsIgnoreCase(sortDir))) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
                    "Invalid sort direction. Valid options are: asc, desc");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This class does the heavy lifting:

  • Extracts query parameters
  • Handles parsing errors
  • Enforces min/max constraints
  • Validates sort fields against the allowed list
  • Throws appropriate HTTP 400 errors with descriptive messages
  • Creates the PageSortRequest instance with all validated parameters

Step 4: Register the Argument Resolver

Finally, we need to register our custom argument resolver with Spring MVC:

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    private final PageSortArgumentResolver pageSortArgumentResolver;

    public WebMvcConfig(PageSortArgumentResolver pageSortArgumentResolver) {
        this.pageSortArgumentResolver = pageSortArgumentResolver;
    }

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(pageSortArgumentResolver);
    }
}
Enter fullscreen mode Exit fullscreen mode

This configuration class implements Spring's WebMvcConfigurer interface and adds our custom resolver to the Spring MVC configuration.

Using the Framework

Now, any controller method can easily accept pagination and sorting parameters:

@GetMapping
@PageSortConfig(
    defaultSize = 10,
    maxSize = 50,
    validSortFields = {"name", "createdAt", "updatedAt"}
)
public List<TasksBoardDto> getAllBoards(PageSortRequest pageSortRequest) {
    log.info("Getting all boards with pagination: {}", pageSortRequest);
    // Use pageSortRequest in your service layer
    return boardsService.getAllTasksBoards(
        pageSortRequest.page(), 
        pageSortRequest.size(),
        pageSortRequest.sortBy(),
        pageSortRequest.getSqlSortDirection()
    );
}
Enter fullscreen mode Exit fullscreen mode

With this framework, your API endpoints automatically support:

  • ?page=2&size=10 for pagination
  • ?sortBy=name&sortDir=desc for sorting
  • Input validation with clear error messages
  • Security against SQL injection through sort field validation

Conclusion

This pagination and sorting framework provides:

  1. A clean and consistent API for clients
  2. Automatic parameter parsing and validation
  3. Security against invalid inputs
  4. Reusability across all your endpoints

By centralizing this logic, you save time, reduce code duplication, and provide a better developer experience—both for your API users and your fellow developers.

Top comments (0)