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
}
In this article, I'll show you how to build such a framework from scratch.
Index
- Create the PageSortRequest Model
- Create the Configuration Annotation
- Implement the Argument Resolver
- Register the Argument Resolver
- Using the Framework
- 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";
}
}
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 {};
}
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:
- Extract query parameters
- Apply validation rules
- 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");
}
}
}
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);
}
}
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()
);
}
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:
- A clean and consistent API for clients
- Automatic parameter parsing and validation
- Security against invalid inputs
- 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)