How to use Spring Data's Specification


The query methods of Spring Data JPA are a convenient way to retrieve data from your database. By simply defining methods in an interface, the framework can derive queries. For more complicated things, you can also define named queries and write your own JPQL or native SQL queries. However, with a growing application, this approach shows its drawbacks. New use-cases require new, but only slightly different, queries. The results are growing repositories that become harder and harder to maintain.
In this blog post, I’ll show you how to use Spring Data’s Specification to address this problem.

The Example

We will extend the example code of my previous blog post ‘How to test the Data Layer of your Spring Boot Application with @DataJpaTest’. There we implemented a named query method to retrieve all restaurants with a rating of at least 8/10.

RestaurantRepository.java
public interface RestaurantRepository extends JpaRepository<Restaurant, Long> {

    @Query(value = "SELECT r FROM Restaurant r LEFT JOIN r.ratings ra GROUP BY r having avg(ra.score) >= 8")
    List<Restaurant> findTopRatedRestaurants();
}
  • We used a JPQL statement to retrieve the top-rated restaurants.

What’s the problem with this method? If the code remains as it is right now, nothing. As written in the intro, the problems with query methods - named and derived alike - start to show as soon as our application grows. Let’s say we want to implement the option to retrieve top-rated restaurants while also filtering their name. We would need to implement a new method:

RestaurantRepository.java
public interface RestaurantRepository extends JpaRepository<Restaurant, Long> {

    @Query(value = "SELECT r FROM Restaurant r LEFT JOIN r.ratings ra GROUP BY r having avg(ra.score) >= 8")
    List<Restaurant> findTopRatedRestaurants();

    @Query(value = "SELECT r FROM Restaurant r LEFT JOIN r.ratings ra " 
                   + "where r.name like %:name% GROUP BY r having avg(ra.score) >= 8")
    List<Restaurant> findTopRatedRestaurantsByName(@Param("name") String name);
}
  • We duplicated the complete method, just to extend it with one parameter.

Imagine we want to filter even more attributes of a restaurant. We would start to implement nearly the same thing over and over again. This would become a maintenance nightmare. If the name of one entity or property would change, we would need to edit every single query method. A manual task that is quite error-prone.
Thankfully, Spring Data supports different ways to retrieve data. In situations like these, Specifications can improve your code significantly.

What is a Specification

The idea of Spring Data’s Specification originates in the Specification pattern. The pattern was developed by Eric Evans and was later refined together with Martin Fowler. The reasoning behind the pattern is the separation of the way an object is matched from the object itself.
In our example, this means we won’t implement an isTopRatedAndHasNameLike method in the Restaurant class itself but put the predicates that must match into a class of its own - a specification. By doing this, we create a clear separation of responsibilities.
The original paper mentions three implementation strategies for the pattern. Spring uses the “composite specification”, which is built on top of the GoF’s Composite pattern. Instead of building one really specific specification, we implement multiple ones, that can be freely combined in any way.
Following the pattern, Spring Data’s Specification is an abstraction on top of JPAs Criteria API that allows the building of atomic predicates, which can be freely combined.

Implementation

The first thing we’ll do is to add org.hibernate:hibernate-jpamodelgen as a new dependency to the project. Although this is optional, I suggest doing it to make the code of our specifications type-safe.

pom.xml
<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-jpamodelgen</artifactId>
</dependency>

In the next step, we extend our repository to add support for Specifications:

RestaurantRepository.java
public interface RestaurantRepository extends JpaRepository<Restaurant, Long>,
                                              JpaSpecificationExecutor<Restaurant> {
    // old query methods omitted for brevity
}
  • The repository also extends the JpaSpecificationExecutor interface.

Now we can implement the Specification itself:

RestaurantTopRatedSpecification.java
class RestaurantTopRatedSpecification implements Specification<Restaurant> {

    public static final double MINIMAL_AVERAGE_RATING = 8.0;

    @Override
    public Predicate toPredicate(Root<Restaurant> restaurant, 
                                 CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) {
        final var averageRating = criteriaBuilder.avg(restaurant.join(Restaurant_.RATINGS).get(Rating_.SCORE));
        final var topRatedPredicate = criteriaBuilder.greaterThanOrEqualTo(averageRating, MINIMAL_AVERAGE_RATING);
        query.groupBy(restaurant.get(Restaurant_.ID)).having(topRatedPredicate);

        return null;
    }
}
  • Our specification implements the Specification interface.
  • The toPredicate method contains the logic of the specification.
  • The Restaurant_ class was generated by hibernate-jpamodelgen. Instead of using a plain String, we use constants that will change if we rename a property of our entity. This will lead to errors at compile- and not at runtime.
  • We use the CriteriaBuilder and the CriteriaQuery to build our condition.
  • Normally, we would return a predicate. This specification only adds a “groupBy” and “having” and therefore does not return a predicate.

As you may have noticed, this specification is package private. This is not needed in any way, it’s just my personal preference. I like to provide all specifications through a facade. This way I can provide instances of the specifications through static methods, which helps to improve the readability of the clients that use them.

RestaurantSpecifications.java
public final class RestaurantSpecifications {

    private RestaurantSpecifications() {
    }

    public static Specification<Restaurant> hasTopRating() {
        return new RestaurantTopRatedSpecification();
    }
}
  • A simple static method that returns an instance of the specification we just created.

Now we can create a service that uses the new specification to retrieve some data:

RestaurantService.java
@Service
public class RestaurantService {

    private final RestaurantRepository restaurantRepository;
    // constructor ommited for brevity

    public List<Restaurant> findTopRated() {
        return restaurantRepository.findAll(where(hasTopRating()));
    }
}
  • We use the findAll method of our repository with our newly created specification. The static where method is provided by the Specification interface. Its sole purpose is to make the method call read a little more like an English sentence. Our static hasTopRating method complements this idea.

Combine multiple Specifications

As written earlier, the problems with query methods emerge when you need to combine multiple predicates. To properly demonstrate the advantages of specifications over query methods, we need to implement a second one, that can be combined with the RestaurantTopRatedSpecification:

RestaurantNameSpecification.java
class RestaurantNameSpecification implements Specification<Restaurant> {

    private final String name;

    public RestaurantNameSpecification(String name) {
        this.name = name;
    }

    @Override
    public Predicate toPredicate(Root<Restaurant> restaurant, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) {
        final var nameExpression = criteriaBuilder.lower(restaurant.get(Restaurant_.name));
        return criteriaBuilder.like(nameExpression, "%" + name.toLowerCase() + "%");
    }
}
  • This specification abstracts a predicate of the restaurant’s name. Therefore, we pass a name to filter for into it.
  • We use the CriteriaBuilder to build the predicate.

Now we can combine both of our specifications:

RestaurantService.java
@Service
public class RestaurantService {
    //other code omitted for brevity

    public List<Restaurant> findTopRatedByName(String name) {
        return restaurantRepository.findAll(where(nameIsLike(name)).and(hasTopRating()));
    }
}
  • We use the and method of the Specification interface, to combine both of our specifications. The interface also provides an or and not method.
As you can imagine, you can combine different specifications any way you need it.

Testing a Specification

The implementation of the specifications may be done, but we still need to test them. As mentioned before, a Specification is an abstraction that makes usage of the JPA Criteria API. Therefore, we can use Spring’s @DataJpaTest to test them:

RestaurantNameSpecificationTest.java
@DataJpaTest
class RestaurantNameSpecificationTest {

    @Autowired
    private RestaurantRepository restaurantRepository;

    @BeforeEach
    void setup() {
        restaurantRepository.save(new Restaurant("Café Java"));
        restaurantRepository.save(new Restaurant("Spring Restaurant"));
        restaurantRepository.save(new Restaurant("Jakarta Restaurant"));
    }

    @Test
    void matchesName() {
        final var topRestaurants = restaurantRepository.findAll(
                where(nameIsLike("Restaurant"))
        );

        assertThat(topRestaurants, hasSize(2));
        assertThat(topRestaurants, contains(
                hasProperty("name", is("Spring Restaurant")),
                hasProperty("name", is("Jakarta Restaurant"))
        ));
    }
}
  • We use the @DataJpaTest test slice, to test our specification.
  • This setup method populates the database with our test data.
  • We use our specification to query for data in the database.
  • Hamcrest offers some neat options to make assertions about collections and their content.

Conclusion

Specifications can be a solution to the problem of an ever-growing repository. By implementing atomic specifications, that can be combined in any way, we can drastically reduce the number of query methods and duplicated code. But as with almost all choices, you should think about when to use them. Although specifications are a versatile tool that can be used in any situation, I still would advise you to use them only when needed. They won’t come for free. You and your team need to understand the concept of the Specification and Composite pattern, you need to understand the JPA Criteria API and you need to test and maintain them. For all those reasons, I still use the derived named queries wherever it is reasonable. They are easy to understand, write, and - to a certain degree - easy to maintain.

Resources