Java Records – How to use them with Hibernate and JPA
Take your skills to the next level!
The Persistence Hub is the place to be for every Java developer. It gives you access to all my premium video courses, monthly Java Persistence News, monthly coding problems, and regular expert sessions.
A lot of developers complain that Java is too verbose. And I can understand that, especially for all classes that are supposed to be a basic data structure, like JPA/Hibernate entities or DTOs. So, it was no surprise that I got a lot of questions about JDK’s records feature and how you can use it with Hibernate.
Let me immediately answer the 2 most common questions before we get into the details:
- Yes, Java records are a great fit for your persistence layer.
- But no, you can’t implement an entity using a record.
That’s because of a few important differences between the implementation of a Java record and the requirements of an entity, which I will explain in this article.
Records in Java
Records is a preview feature in Java 14. Its main goal is to provide a way to handle data as data and to simplify the declaration of an immutable data structure.
Here you can see the declaration of the BookWithAuthorNamesRecord. It stores the id, title, and price of a book and the names of its authors.
record BookWithAuthorNamesRecord( Long bookId, String title, Double price, String authorNames) {}
Records are implicitly final. By declaring a record with its components, you automatically create a private final field and a public read accessor method for each component. A Java record also provides you with a constructor that initializes all of its fields and implementations for the equals(), hashCode(), and toString() methods.
BookWithAuthorNamesRecord b = new BookWithAuthorNamesRecord( 1L, "Hibernate Tips - More than 70 solutions to common Hibernate problems", 19.99D, "Thorben Janssen"); log.info(b.title() + " was written by "+b.authorNames());
As you can see, records are a straightforward and clean option to implement a read-only data structure. But as I will explain in the next section, some of the features that make a record easy to use also make it impossible to implement an entity with it.
Records can’t be entities
As I explain in the JPA for Beginners online course, a JPA-compliant entity needs to fulfill a few simple requirements. It needs to:
- be annotated with @Entity,
- have a public or protected parameterless constructor so that the persistence provider can instantiate objects when mapping query results,
- be a top-level class,
- not be final so that your persistence provider can generate proxies, e.g., to offer lazy loading for to-one associations,
- declare one or more attributes that identify the entity object,
- map database columns to non-final attributes and
- provide getter and setter methods to access these attributes.
If you’re implementing your entity as a standard Java class, these requirements are easy to fulfill. But the 4 requirements that I highlighted make it impossible to implement an entity as a Java record. Records don’t support a parameterless constructor, and they are final, which entities are not allowed to be. The fields of a record are also final, and their accessor methods don’t follow the required naming schema.
As you can see, you can’t implement a JPA-compliant entity using a Java record. Hibernate’s requirements are not as strict as JPA’s requirements. Hibernate can persist final classes and doesn’t require any accessor methods for mapped entity attributes. But it still requires a default constructor and non-final fields.
All of this makes it impossible to use a Java record to implement an entity. But it’s a good fit for a DTO projection, which is often used as a read-only representation of the data stored in your database.
Records are great DTOs
DTOs are the best projection if you don’t want to change the selected information. They provide better performance than entities and allow you to decouple your domain model from your API.
The best way to instantiate a DTO projection is to tell Hibernate to map the query result to a DTO object. You can do that using a constructor expression in JPQL and the Criteria API. If you want to map the result of a native query, you can use a @SqlResultSetMapping. All 3 of these approaches tell Hibernate which constructor it has to call and which parameter values it shall provide.
If you prefer a more flexible approach, you can use a Hibernate-specific ResultTransformer. Unfortunately, the most commonly used ResultTransformers expect your DTO class to follow the JavaBeans convention by providing getter and setter methods. As explained earlier, Java records don’t do that. In most situations, I would, therefore, use one of JPA’s constructor expressions.
The constructor expressions supported by JPA require a constructor that sets all attributes of the DTO object. The constructor of a record is a perfect match for that, and Hibernate can call it in the same way as it calls the constructor of a regular Java class.
Instantiating a record in JPQL
The constructor expression in a JPQL query consists of the keyword new, the fully qualified class name, and a list of constructor parameters.
TypedQuery<BookWithAuthorNamesRecord> q = em.createQuery( "SELECT new org.thoughts.on.java.model.BookWithAuthorNamesRecord(" + "b.id, b.title, b.price, concat(a.firstName, ' ', a.lastName)) " + " FROM Book b JOIN b.author a " + " WHERE b.title LIKE :title", BookWithAuthorNamesRecord.class); q.setParameter("title", "%Hibernate Tips%"); List<BookWithAuthorNamesRecord> books = q.getResultList();
After you executed the query and retrieved the instance of the BookWithAuthorNamesRecord, you can use it in your business code.
Instantiating a record in a CriteriaQuery
You can use the construct method of JPA’s CriteriaBuilder to define a constructor call in your CriteriaQuery. The first method parameter is a reference to the class Hibernate shall instantiate, and all other parameters will be used as constructor parameters.
// Create query CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery<BookWithAuthorNamesRecord> cq = cb .createQuery(BookWithAuthorNamesRecord.class); // Define FROM clause Root<Book> root = cq.from(Book.class); Join<Book, Author> author = root.join(Book_.author); // Define DTO projection cq.select(cb.construct( BookWithAuthorNamesRecord.class, root.get(Book_.id), root.get(Book_.title), root.get(Book_.price), cb.concat(cb.concat(author.get(Author_.firstName), " "), author.get(Author_.lastName)))); // Define WHERE clause ParameterExpression<String> paramTitle = cb.parameter(String.class); cq.where(cb.like(root.get(Book_.title), paramTitle)); // Execute query TypedQuery<BookWithAuthorNamesRecord> q = em.createQuery(cq); q.setParameter(paramTitle, "%Hibernate Tips%"); List<BookWithAuthorNamesRecord> books = q.getResultList();
Instantiating a record from a native query
Native queries don’t get parsed by your JPA implementation. They get directly send to the database. Due to that, you can’t use a constructor expression as we did in the JPQL example.
But you can use a @SqlResultSetMapping annotation to define the result mapping. The following example defines a mapping with the name BookWithAuthorNamesRecordMapping. It tells Hibernate to instantiate an object of the BookWithAuthorNamesRecord record and to use the title, author, and publisher fields of the result set record as constructor parameters.
@Entity @SqlResultSetMapping( name = "BookWithAuthorNamesRecordMapping", classes = @ConstructorResult( targetClass = BookWithAuthorNamesRecord.class, columns = { @ColumnResult(name = "id", type = Long.class), @ColumnResult(name = "title"), @ColumnResult(name = "price"), @ColumnResult(name = "authorName")})) public class Book { ... }
In the next step, you need to provide the name of this mapping as the 2nd parameter to the createNativeQuery method. Hibernate will then apply the defined mapping to each record in the result set.
Query q = em.createNativeQuery( "SELECT b.id, b.title, b.price, a.firstName || a.lastName as authorName FROM Book b JOIN Author a ON b.author_id = a.id WHERE b.title LIKE :title", "BookWithAuthorNamesRecordMapping"); q.setParameter("title", "%Hibernate Tips%"); List<BookWithAuthorNamesRecord> books = q.getResultList(); for (BookWithAuthorNamesRecord b : books) { log.info(b); }
Conclusion
Java records are an interesting feature to represent immutable data structures. Compared to regular Java classes, they introduce a few restrictions that don’t fulfill JPA’s and Hibernate’s requirements of an entity class. But they are an excellent solution for DTO projections.
DTO’s are often read-only, and the constructor provided by a Java record is an ideal match for JPA’s constructor expression. This makes them an efficient and obvious choice for all queries that return data that you don’t want to change.
Thank you for this great article.
Thanks for this article.
before I used lombok’s @Value ( recommand by spring un spring data jpa) but now I think I used Records.
According to https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#projections.dtos , using a Lombok @Value class, and a spring data jpa repository with a method like this: ` Collection findByLastname(String lastname, Class type);` I can pass the type in as either the entity class or the DTO/Lombok class
Would that work with records? In simple cases that seems easier and neater than the options you mention here.
Hi David,
that should work as well. Internally, Spring Data JPA does the same as I showed in this article. You just don’t need to implement it yourself 😉
Regards,
Thorben
Thank you Thorben for this very efficient explanation! I really appreciate. Stephan