Effortless Data Retrieval in Spring Boot: The Best Approach for Fetching Related Data with Filtering, Pagination & Sorting

Effortless Data Retrieval in Spring Boot: The Best Approach for Fetching Related Data with Filtering, Pagination & Sorting

Learn to apply Specification API, Criteria API & Entity Graphs!

Fetching related data efficiently in Spring Boot can be challenging, especially with multiple relationships. In this guide, I'll walk you through a powerful yet simple approach using @GetMapping, dynamic filtering, the Specification API, pagination, sorting, entity graphs, and lazy fetching with Spring Data JPA. Learn how to optimize queries and expose collections seamlessly—no performance bottlenecks, just clean and scalable data retrieval! 🚀

Make sure you are using the Lombok plugin to reduce boilerplate code.

Entity

We have an Event entity that has

  1. Many to Many: Event <> User (Event participant)

  2. One to One: Event <> Schedule

  3. Many to One: Event <> User (Event admin)

Notice that we are using “fetch = FetchType.LAZY“; this avoids unnecessary joins, thereby optimizing the database queries.

@Data
@Entity
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class Event {
    @Id
    @GeneratedValue
    private Long id;
    private String name;
    private String description;
    private String contactDetails;

    @Enumerated(EnumType.ORDINAL)
    private EventStatus eventStatus;

    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "schedule_id")
    @JsonBackReference("event_schedule")
    private Schedule schedule;

    @ManyToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinTable(
            name = "event_participants",
            joinColumns = @JoinColumn(name = "event_id"),
            inverseJoinColumns = @JoinColumn(name = "participant_id")
    )
    @JsonBackReference("event_participants")
    private List<UserProfile> participants;

    @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.MERGE)
    @JoinColumn(name = "admin_id")
    @JsonBackReference("event_admin")
    private UserProfile admin;
}

Event DTO

Let’s make a Data Transfer Object that will be exposed (sent) to the client instead of the above entity. We won’t include any kind of lists in the DTO when exposing a collection of it.

Notice that the data type of eventStatus is String rather than a custom enum. This is so that we can add @Patten if we ever want to validate it in a POST request.

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EventDTO {
    private Long id;
    private String name;
    private String description;
    private String contactDetails;
    private String eventStatus;
    private Schedule schedule;
    private UserDTO admin;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserDTO {
    private Long id;
    private String name;
    private String email;
}

Repository and Entity Graph

Now we will use the power of Spring Data JPA, the Specification API, Pagination, and Entity Graph to optimize our query performance.

Note that we are using the Pageable from the

  1. import org.springframework.data.domain.Page;

  2. import org.springframework.data.domain.Pageable;

@Repository
public interface EventRepository extends JpaRepository<Event, Long>, JpaSpecificationExecutor<Event> {
    @NonNull
    @EntityGraph(value = "event_collection")
    Page<Event> findAll(Specification<Event> spec, Pageable page);
}

Add the following entity graph to the Event entity before the class definition. This will help create joins for the schedule and admin. You can also add participants if you want. If either the schedule or user has their own additional relationships, we would need to use subgraphs.

@Data
@Entity
@AllArgsConstructor
@NoArgsConstructor
@Builder
@NamedEntityGraph(
        name = "event_collection",
        attributeNodes = {
                @NamedAttributeNode(value = "schedule"),
                @NamedAttributeNode(value = "admin")
        }
)
public class Event {...}

Controller and Modal Attribute

We will now create a controller class and develop a strong, efficient endpoint that provides a collection of events based on the client's preferences. To achieve this, we need to consider the client's preferences, which can be done using @ModelAttribute or simply through request parameters.

@RestController
@RequestMapping("events")
@RequiredArgsConstructor
public class EventController {

    private final EventService eventService;

    @GetMapping("/filter")
    public ResponseEntity<Page<EventDTO>> getEventsByFilter(
            @ModelAttribute EventFilter eventFilter
    ){
        Page<EventDTO> events = eventService.getFilteredEvents(eventFilter);
        return ResponseEntity.ok(events);
    }
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class EventFilter {
    private String name;
    private String eventStatus;
    private LocalDate startBefore;
    private LocalDate startAfter;
    private LocalDate endBefore;
    private LocalDate endAfter;
    private Integer page;
    private Integer size;
}

The client will apply filters via request parameters in the URL, and only the corresponding model attributes will be populated, while the rest will remain null values.

Criteria and Specification API

This is where the real magic happens! We'll create a method that dynamically filters events based on the client's preferences using EventFilter. This will be achieved through Spring Data JPA Specification, combined with if-else conditions and predicates, ensuring flexible and efficient querying.

Create a specification class.

@Service
public class EventSpecification {
}

Now add the below static method in the EventSpecification class. This method uses the Specification and Criteria API to dynamically construct a WHERE clause for the SQL statement.

Notice that for event status, as we are taking a string value, we need to convert it into an enum before comparing. Thus we use the valueOf() method and pass the string to it.

    public Specification<Event> getEventsByFilter(EventFilter filter) {
        return (Root<Event> root, CriteriaQuery<?> query, CriteriaBuilder cb) -> {
            Predicate predicate = cb.conjunction();

            if (filter.getName() != null) {
                predicate = cb.and(predicate, cb.like(cb.lower(root.get("name")), "%" + filter.getName().toLowerCase() + "%"));
            }

            if (filter.getEventStatus() != null) {
                predicate = cb.and(predicate, cb.equal(root.get("eventStatus"), EventStatus.valueOf(filter.getEventStatus())));
            }

            if (filter.getStartBefore() != null) {
                predicate = cb.and(predicate, cb.lessThan(root.get("schedule").get("startDate"), filter.getStartBefore()));
            }

            if (filter.getStartAfter() != null) {
                predicate = cb.and(predicate, cb.greaterThan(root.get("schedule").get("startDate"), filter.getStartAfter()));
            }

            if (filter.getEndBefore() != null) {
                predicate = cb.and(predicate, cb.lessThan(root.get("schedule").get("endDate"), filter.getEndBefore()));
            }

            if (filter.getEndAfter() != null) {
                predicate = cb.and(predicate, cb.greaterThan(root.get("schedule").get("endDate"), filter.getEndAfter()));
            }

            return predicate;
        };
    }

Mapper class

Now we will map the Event to EventDTO using a few methods, but they won’t add much overhead assuming the page size will be less.

@Service
public class EventMapper {

    public static Page<EventDTO> mapToDTO(Page<Event> events) {
        List<EventDTO> eventDTOs = events.getContent().stream().map(EventMapper::convertToDTO).collect(Collectors.toList());
        return new PageImpl<>(eventDTOs, events.getPageable(), events.getTotalElements());
    }

    private static EventDTO convertToDTO(Event event) {
        return EventDTO.builder()
                .id(event.getId())
                .name(event.getName())
                .description(event.getDescription())
                .contactDetails(event.getContactDetails())
                .eventStatus(event.getEventStatus().name()) // Convert Enum to String
                .schedule(event.getSchedule()) // Assuming Schedule is already structured properly
                .admin(event.getAdmin() != null ? convertUserToDTO(event.getAdmin()) : null)
                .build();
    }

    private static UserDTO convertUserToDTO(UserProfile user) {
        return UserDTO.builder()
                .id(user.getId())
                .name(user.getName())
                .email(user.getEmail())
                .build();
    }
}

Service Class

Now that we have everything ready, let’s create a service class that merges everything together.

The getFilteredEvents method takes a filter parameter, constructs a pageable object using the page and size from the filter and sorts the list of events by id (we can sort by different variables as well).

@Service
@RequiredArgsConstructor
public class EventService {
    private final EventRepository eventRepository;

    public Page<EventDTO> getFilteredEvents(EventFilter filter) {
        Specification<Event> spec = EventSpecification.getEventsByFilter(filter);

        Pageable pageable = PageRequest.of(
                filter.getPage() != null ? filter.getPage() : 0,
                filter.getSize() != null ? filter.getSize() : 10,
                Sort.by(Sort.Direction.DESC, "id")
        );

        Page<Event> filteredEvents = eventRepository.findAll(spec, pageable);
        return EventMapper.mapToDTO(filteredEvents);
    }
}

This is it! We have created a way to expose a list of data in an optimized manner. Now let’s quickly test it out.

Testing on Postman

I have inserted 100 events using Command Line Runner and the Faker dependency and created a GET request on Postman to use the endpoint. Then I have defined all the request parameters that we can store in our Event Filter.

Happy Coding! It’s 200