How to use Java Records with Spring Data JPA
Are you struggling with performance issues in your Spring, Jakarta EE, or Java EE application?
What if there were a tool that could automatically detect what caused performance issues in your JPA and Hibernate data access layer?
Wouldn’t it be awesome to have such a tool to watch your application and prevent performance issues during development, long before they affect production systems?
Well, Hypersistence Optimizer is that tool! And it works with Spring Boot, Spring Framework, Jakarta EE, Java EE, Quarkus, Micronaut, or Play Framework.
So, rather than fixing performance issues in your production system on a Saturday night, you are better off using Hypersistence Optimizer to help you prevent those issues so that you can spend your time on the things that you love!
Introduction
In this article, we are going to see how we can use Java Records with Spring Data JPA Repositories.
As I already explained, Java Records cannot be used as JPA entities since the Records are immutable, and JPA requires the entity class to have a default constructor and be modifiable, as that’s how the entity properties are populated when the entity is being fetched from the database.
For this reason, this article will show you how to combine Java Records and JPA entities so that you get the best out of both.
Domain Mode
Let’s assume we have the following Post and PostComment entities:

The Post is the parent entity in this one-to-many table relationship and is mapped like this:
@Entity(name = "Post")
@Table(name = "post")
public class Post {
@Id
private Long id;
private String title;
@OneToMany(
mappedBy = "post",
cascade = CascadeType.ALL,
orphanRemoval = true
)
private List<PostComment> comments = new ArrayList<>();
public Long getId() {
return id;
}
public Post setId(Long id) {
this.id = id;
return this;
}
public String getTitle() {
return title;
}
public Post setTitle(String title) {
this.title = title;
return this;
}
public List<PostComment> getComments() {
return comments;
}
public Post addComment(PostComment comment) {
comments.add(comment);
comment.setPost(this);
return this;
}
public PostRecord toRecord() {
return new PostRecord(
id,
title,
comments.stream().map(comment ->
new PostCommentRecord(
comment.getId(),
comment.getReview()
)
).toList()
);
}
}
The PostComment is the client entity in this relationship and is mapped as follows:
@Entity(name = "PostComment")
@Table(name = "post_comment")
public class PostComment {
@Id
@GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Post post;
private String review;
public Long getId() {
return id;
}
public PostComment setId(Long id) {
this.id = id;
return this;
}
public Post getPost() {
return post;
}
public PostComment setPost(Post post) {
this.post = post;
return this;
}
public String getReview() {
return review;
}
public PostComment setReview(String review) {
this.review = review;
return this;
}
}
However, since we don’t want to expose the JPA entities outside of the Service and Data Access Layer, we have created the following PostRecord and PostCommentRecord objects that we are going to share with the Web Layer:

The PostRecord is created like this:
public record PostRecord(
Long id,
String title,
List<PostCommentRecord> comments
) {
public Post toPost() {
Post post = new Post()
.setId(id)
.setTitle(title);
comments.forEach(
comment -> post.addComment(comment.toPostComment())
);
return post;
}
}
An the PostCommentRecord is created as follows:
public record PostCommentRecord(
Long id,
String review
) {
public PostComment toPostComment() {
return new PostComment()
.setId(id)
.setReview(review);
}
}
As you can see in the JPA entity mappings and the Record classes, we have created several methods that allow us to create the Record from the JPA entity and vice versa. These methods will be used when exporting the Records from the service layer and importing the Records so that we can propagate the changes to the database via their associated JPA entities.
The Service and Data Access Layer
The Data Access Layer is very simple and consists of the following PostRepository:
@Repository
public interface PostRepository
extends BaseJpaRepository<Post, Long> {
@Query("""
select p
from Post p
join fetch p.comments
where p.id = :postId
""")
Optional<Post> findWithCommentsById(
@Param("postId") Long postId
);
}
The
BaseJpaRepositoryis a replacement for the default Spring DataJpaRepositorythat removes dangerous methods, such asfindAllorsave, providing much better alternatives.For more details about the
BaseJpaRepositoryfrom Hypersistence Utils, check out this article.
The findWithCommentsById method allows us to fetch a Post entity along with all its associated PostComment child entities in a single SQL query.
The Service Layer provides the following ForumService Spring bean that the Web Layer will interact with:
@Service
@Transactional(readOnly = true)
public class ForumService {
@Autowired
private PostRepository postRepository;
public PostRecord findPostRecordById(Long postId) {
return postRepository
.findWithCommentsById(postId)
.map(Post::toRecord)
.orElse(null);
}
@Transactional
public PostRecord insertPostRecord(PostRecord postRecord) {
return postRepository.persist(postRecord.toPost()).toRecord();
}
@Transactional
public PostRecord mergePostRecord(PostRecord postRecord) {
return postRepository.merge(postRecord.toPost()).toRecord();
}
}
The findPostRecordById method fetches the Post entity and transforms it to a PostRecord using the toRecord method.
The insertPostRecord method takes a PostRecord object and persists the associated Post and PostComment child entities.
The mergePostRecord method takes a PostRecord object and merges the associated Post and PostComment child entities.
Testing Time
To test how the Java Records can be fetched, persisted, and merged using Spring Data JPA, we are going to call the aforementioned ForumService methods and see what statements are executed by Hibernate behind the scenes.
If we create a PostRecord object that has 5 associated PostCommentRecord child objects and pass the PostRecord to the insertPostRecord method:
forumService.insertPostRecord(
new PostRecord(
1L,
"High-Performance Java Persistence",
LongStream.rangeClosed(1, 5)
.mapToObj(i ->
new PostCommentRecord(
null,
String.format("Good review nr. %d", i)
)
)
.toList()
)
);
We can see that Spring Data JPA generates the following SQL INSERT statements:
Query:["
insert into post (title,id)
values (?,?)
"],
Params:[
(High-Performance Java Persistence, 1)
]
Query:["
insert into post_comment (post_id,review,id)
values (?,?,?)
"],
Params:[
(1, Good review nr. 1, 1),
(1, Good review nr. 2, 2),
(1, Good review nr. 3, 3),
(1, Good review nr. 4, 4),
(1, Good review nr. 5, 5)
]
Spring Data JPA can take advantage of the automatic JDBC batch insert feature provided by Hibernate if we configure the following settings:
properties.put(
AvailableSettings.STATEMENT_BATCH_SIZE,
50
);
properties.put(
AvailableSettings.ORDER_INSERTS,
Boolean.TRUE
);
For more details about the JDBC batch update mechanism, check out this article.
When fetching the PostRecord object using the findPostRecordById method:
PostRecord postRecord = forumService.findPostRecordById(1L);
Hibernate executes the following SQL SELECT query:
Query:["
select
p1_0.id,
c1_0.post_id,
c1_0.id,
c1_0.review,
p1_0.title
from
post p1_0
join post_comment c1_0 on p1_0.id=c1_0.post_id
where
p1_0.id=?
"],
Params:[(1)]
Now, we could send the PostRecord as a JSON object to the front-end layer as it’s very easy to convert Java Records to JSON using Jackson:
For instance, if we log the JSON representation of the PostRecord we’ve just fetched:
LOGGER.info("PostRecord to JSON: {}", JacksonUtil.toString(postRecord));
We are going to see the following JSON object printed to the log:
{
"id": 1,
"title": "High-Performance Java Persistence",
"comments": [
{
"id": 1,
"review": "Good review nr. 1"
},
{
"id": 2,
"review": "Good review nr. 2"
},
{
"id": 3,
"review": "Good review nr. 3"
},
{
"id": 4,
"review": "Good review nr. 4"
},
{
"id": 5,
"review": "Good review nr. 5"
}
]
}
If the front-end modifies the Post and PostComment objects and sends is the following JSON object that we pass to the mergePostRecord method:
String upatedPostRecordJSONSTring = """
{
"id": 1,
"title": "High-Performance Java Persistence, 2nd edition",
"comments": [
{
"id": 1,
"review": "Best book on JPA and Hibernate!"
},
{
"id": 2,
"review": "A must-read for every Java developer!"
}
]
}
""";
forumService.mergePostRecord(
JacksonUtil.fromString(
upatedPostRecordJSONSTring,
PostRecord.class
)
);
Then we can see that Spring Data JPA will execute the following SQL statements:
Query:["
select
p1_0.id,
p1_0.title,
c1_0.post_id,
c1_0.id,
c1_0.review
from
post p1_0
left join post_comment c1_0 on p1_0.id=c1_0.post_id
where p1_0.id=?
"],
Params:[(1)]
Query:["
update post
set title=?
where id=?
"],
Params:[(High-Performance Java Persistence, 2nd edition, 1)]
Query:["
update post_comment
set post_id=?,review=?
where id=?
"],
Params:[
(1, Best book on JPA and Hibernate!, 1),
(1, A must-read for every Java developer!, 2)
]
Query:["
delete from post_comment
where id=?
"],
Params:[
(3),
(4),
(5)
]
The first SQL SELECT query is executed by merge in order to fetch the latest state of the Post and PostComment entities so that the Hibernate dirty checking mechanism can determine what has changed.
For more details about how the
persistandmergeJPA operations work, check out this article.
The UPDATE statements that follow are for the post and post_comment table records that have changed, and the DELETE statements are for the records that the client has removed.
If you enjoyed this article, I bet you are going to love my Book and Video Courses as well.
Conclusion
Java Records can be very useful when using Spring Data JPA because they allow us to isolate the JPA entities to the Service and Data Access Layer.
Transforming the JPA entities to Java Records and from Java Records to JPA entities is rather straightforward, and this way, we don’t have to worry about N+1 query issues or LazyInitializationException problems as Java Records have all the data already pre-fetched.
For more details about Java Records, check out this article as well.







Hello Vlad, thank you for your nice and very understandable blog (as usual :-) ).
May I ask why you prefer loading data first into entities and afterwards transforming them into Records?
I expect there would be also a way to immediately create Records without JPA entities?
Kind regards,
Stephan
You’re welcome.
For the examples used in this article, it wouldn’t make any difference to fetch the Records directly. Using projections is useful when you need to control how many columns you need to fetch from a larger table. For that topic, I have this article.
For the insert use case, how would we know what the ID value should be? In your example you put 1L but can we expect the caller to know what the next ID should be?
If you take a look at the
PostComment, you’ll see that theidisnull, as it’s auto-generated.I could have done the same thing for
Post, but for the sake of readability, I deliberately made thePostuse an assigned generator so that you can see the same id when I insert, select and update it.Sorry I missed that, thank you!
You’re welcome