In this first technical blog post I will show some insights into how we are using Spring Data JPA Projections when building solutions for our clients. Although I assume the reader has some experience with Spring (Boot) and Spring Data, the general principles are transferrable to other languages and/or frameworks.
All the code for the example can be found on our Bluemagma GitHub repo.
Which problem are we trying to solve?
How the representation of your data in the database is constructed and sent to the client is an important part of any business application.
In many Spring Boot + Spring Data tutorials you will see that the entity classes themselves are sent over HTTP to the client. In other cases you might see a 1-to-1 mapping of an entity to a DTO class. Although this approach can hide some of the entity’s internals, it still has a lot of issues. Most entities have relationships with other entities, will these be included in the DTO? What if one view in the front end needs a specific relation but other views don’t. Will we load the unnecessary data each time? Eventually you’ll get to the point that you’re sending almost your entire database the client, which obviously has serious performance issues.
That’s why we believe using Spring Data JPA Projections is a great solution. If you’re working with JPA and Hibernate but not the Spring Data project, you should certainly check out Vlad Mihalcea’s post about mapping a projection query to a DTO. The solutions offered there are useful, but if you are using Spring Data in your application it can be simplified even further.
Spring Data JPA primer
The basic principle of Spring Data is the following; let’s say we have an entity as follows:
/** * Product domain entity */ @Entity @Table(name = "t_product") @NoArgsConstructor(access = AccessLevel.PACKAGE) @Getter @ToString public class Product { @Id @GeneratedValue(strategy = GenerationType.SEQUENCE) private Long id; @Column(nullable = false, unique = true) private String name; @Column(nullable = false, unique = true) private String code; @Column(nullable = false) private BigDecimal price; public Product(final String name, final String code, final BigDecimal price) { this.name = name; this.code = code; this.price = price; } }
In order to query the data we would create a ProductRepository
like so:
/** * Repository methods for the {@link Product} domain entity */ public interface ProductRepository extends CrudRepository<Product, Long> { }
The CrudRepository
offers us many methods out of the box, like findById, findAll, ...
but all of these methods return the entity Product
.
Including Spring Data JPA Projections and dynamic projections
There’s no substitute for the official documention on Spring Data JPA Projections, but I will offer an explanation of the specific parts we use in our solution.
If we want to return something else than the entity (i.e. only a part of it), we can create a projection:
/** * Projection for {@link Product} domain entity */ public interface ProductNameOnly { String getName(); }
This interface defines that all we want in the projection is the name. We can utilize it by defining a custom method in the ProductRepository
:
/** * Repository methods for the {@link Product} domain entity */ public interface ProductRepository extends CrudRepository<Product, Long> { Collection<ProductNameOnly> findAllProjectedBy(); }
If we call this method, we will only receive the name of each of the Products
.
That’s pretty cool but we can achieve the same result in quite a lot of other ways as well (using DTOs like in Vlad’s article). We can make it a bit more interesting by introducing a second projection named ProductCodeOnly
which only returns the Product
‘s code. Instead of creating a second method in the ProductRepository
that returns a Collection<ProductCodeOnly>
we can define a dynamic projection query:
/** * Repository methods for the {@link Product} domain entity */ public interface ProductRepository extends CrudRepository<Product, Long> { <T> Collection<T> findAllProjectedBy(Class<T> projectionClass); }
This way we have 1 method which we can re-use, we simply have to pass in the projection class we want to use, and Spring Data JPA does the rest. The controller would look something like this:
/** * Web API end points for the {@link Product} domain entity */ @Controller public class ProductController { private final ProductRepository productRepository; public ProductController(final ProductRepository productRepository) { this.productRepository = productRepository; } @GetMapping(value = "/product") public ResponseEntity findAllNames() { final Collection<ProductNameOnly> productNames = this.productRepository.findAllProjectedBy(ProductNameOnly.class); return ResponseEntity.ok(productNames); } }
Although the projections of ProductCodeOnly
and ProductNameOnly
are purely used as an example, you can see the benefit of returning a select number of attributes. In real world entities there are often dozens of attributes which are unnecessary for a particular view.
Another example is, when you’re building your frontend in Angular, React, Vue, … and you have to populate a select box with options, the only attributes you probably need is the id
and the name
, one for the value of the <option>
element and one for the display. Creating a separate projection is ideal for this.
Using content negotiation to select a projection
We want to be able to call the /product
end point and receive the data in the representation that we need it. In order to achieve that we can use content negotiation using the Accept
header. The controller would look like this:
/** * Web API end points for the {@link Product} domain entity */ @Controller public class ProductController { private final ProductRepository productRepository; public ProductController(final ProductRepository productRepository) { this.productRepository = productRepository; } @GetMapping(value = "/product", produces = "application/vnd.product-name-only+json") public ResponseEntity findAllNames() { final Collection<ProductNameOnly> productNames = this.productRepository.findAllProjectedBy(ProductNameOnly.class); return ResponseEntity.ok(productNames); } @GetMapping(value = "/product", produces = "application/vnd.product-code-only+json") public ResponseEntity findAllCodes() { final Collection<ProductCodeOnly> productCodes = this.productRepository.findAllProjectedBy(ProductCodeOnly.class); return ResponseEntity.ok(productCodes); } }
You see we have two methods which both have /product
as their end point but they produce two different representations of the Product
. This puts the power in the hands of the client to specify which projection she would like to receive. Simply by including an HTTP header with key Accept
and value application/vnd.product-xxx-only+json
she will get a different response.
Resolving the incoming Accept header
As programmers we’re lazy people and we don’t enjoy repeating things. As you might have noticed, the two methods in the controller are pretty much identical, the only difference is the projection class they pass as argument to the repository method.
Using a HandlerMethodArgumentResolver
we can solve this problem as well. What we want to do is resolve the correct projection class, based on the Accept
header value that was sent in the request.
We want the end result of the controller to look like this:
/** * Web API end points for the {@link Product} domain entity */ @Controller public class ProductController { private final ProductRepository productRepository; public ProductController(final ProductRepository productRepository) { this.productRepository = productRepository; } @GetMapping(value = "/product", produces = { ProductNameOnly.ACCEPT_HEADER, ProductCodeOnly.ACCEPT_HEADER }) public ResponseEntity findAll(@ProjectedBy(def = ProductNameOnly.class) final Class<?> projectionClass) { final Collection<?> products = this.productRepository.findAllProjectedBy(projectionClass); return ResponseEntity.ok(products); } }
We will create an annotation ProjectedBy
which has one parameter def
which defines the default projection that should be used if we can’t resolve to a projection based on the HTTP Accept header. The produces
parameter on the GetMapping
includes both header values we can produce on this method, instead of typing the string value we have moved it to the projection class itself, this way it can be re-used for individual and list projections. As you can see, if the client does not specify one of those two values as their Accept header, by default the ProductNameOnly
projection will be used;
The code for the ProjectedBy
annotation is simply:
/** * Annotation that can be placed on any Controller method argument which will try to automatically * resolve to a projection. */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.PARAMETER) public @interface ProjectedBy { /** * The projection class to use if the resolver fails to resolve a projection class */ Class<?> def(); }
To resolve the correct projection, we use the following resolver:
/** * Resolver for all controller arguments that are annoted with {@link ProjectedBy}. The resolver will use the HTTP Accept header to find * the associated projection. If the Accept header is empty or does not match the regex pattern, it will return the projection defined * in the {@link ProjectedBy#def()} parameter. */ @Slf4j public class ProjectedByArgumentResolver implements HandlerMethodArgumentResolver { @Override public boolean supportsParameter(final MethodParameter parameter) { if (!parameter.hasParameterAnnotations()) { return false; } return parameter.getParameterAnnotation(ProjectedBy.class) != null; } @Override public Object resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer, final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) throws Exception { final String acceptHeader = webRequest.getHeader(HttpHeaders.ACCEPT); final ProjectedBy parameterAnnotation = parameter.getParameterAnnotation(ProjectedBy.class); if (acceptHeader == null || acceptHeader.isEmpty()) { return parameterAnnotation.def(); } if (!this.matchesAcceptHeaderPattern(acceptHeader)) { return parameterAnnotation.def(); } final String className = this.extractClassName(acceptHeader, parameter); try { return Class.forName(className); } catch (Exception e) { log.error("Could not create projection with class name: {}", className); log.error("", e); return parameterAnnotation.def(); } } private boolean matchesAcceptHeaderPattern(final String acceptHeader) { String requiredAcceptHeaderPattern = "application/vnd.[a-z-]+\\+json"; final Pattern pattern = Pattern.compile(requiredAcceptHeaderPattern); return pattern.matcher(acceptHeader).matches(); } private String extractClassName(final String acceptHeader, final MethodParameter parameter) { final String projectionNameWithDashes = acceptHeader.split("\\.")[1].split("\\+")[0]; final StringBuilder builder = new StringBuilder(); Stream.of(projectionNameWithDashes.split("-")) .forEach(token -> { final String capitalizedToken = StringUtils.capitalize(token); builder.append(capitalizedToken); }); return String.format( "%s.projection.%s", parameter.getMethod().getDeclaringClass().getPackage().getName(), builder.toString() ); } }
Although it looks a bit daunting, the implementation is fairly straightforward. It implements the HandlerMethodArgumentResolver
which means we have to implement the supportsParameter
and the resolveArgument
method.
supportsParameter
is easy, we simply check if the ProjectedBy
annotation is placed on the argument, if it is, we support it.
To resolve to the correct projection we have to get the value of the Accept
header. At Bluemagma we use our own convention on how the header value should be constructed, I have simplified it in this example. We first check if the header value matches the preset pattern, which is basically application/vnd.<entity-name>-<projection-class-name>+json
. Once we’ve extracted that name we need to get the Class object associated with the projection interface which we do on line 35 (there is some added complexity here, because you need to pass the fully qualified name to the Class.forName()
method, the latter part of the extractClassName()
assumes that any projections are in a package called projection which resides at the same level as the controller).
Once we’ve resolved the Class for projection, it is available in our findAll()
method in the controller and we can pass it to the repository.
In order to use our new resolver we need to add it in a Spring configuration file:
/** * Configuration for all web related things */ @Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addArgumentResolvers(final List<HandlerMethodArgumentResolver> resolvers) { resolvers.add(new ProjectedByArgumentResolver()); } }
If we now wish to support a different representation for Product
, all we have to do is add the Accept
header value to the produces
parameter of the findAll
method in the controller and add the new projection in the correct package. Good to go!
Check out the full code for this example on the Bluemagma GitHub repo.
If you found this article interesting keep an eye out for a follow-up article where I delve deeper into using Domain Driven Design and Spring Data JPA Projections and projecting relationships in detail.