The best way to do the Spring 6 migration

Imagine having a tool that can automatically detect JPA and Hibernate performance issues. Wouldn’t that be just awesome?

Well, Hypersistence Optimizer is that tool! And it works with Spring Boot, Spring Framework, Jakarta EE, Java EE, Quarkus, or Play Framework.

So, enjoy spending your time on the things you love rather than fixing performance issues in your production system on a Saturday night!

Introduction

In this article, we are going to see how you can get the most out of the Spring 6 and Hibernate 6 migration.

The tips in this article are based on the work I’ve done to add support for Spring 6 in Hypersistence Optimizer and the High-Performance Java Persistence project.

Java 17

First of all, Spring 6 has bumped up the minimum Java version to 17, and that’s awesome because you can now use Text Blocks and Records.

Text Blocks

Thanks to Text Blocks, your @Query annotations are going to be much more readable:

@Query("""
    select p
    from Post p
    left join fetch p.comments
    where p.id between :minId and :maxId
    """)
List<Post> findAllWithComments(
    @Param("minId") long minId, 
    @Param("maxId") long maxId
);

For more details about Java Text Blocks, check out this article as well.

Records

Java Records are great for DTO projections. For instance, you can define a PostRecord class like this:

public record PostCommentRecord(
    Long id,
    String title,
    String review
) {}

And, then you can fetch the PostCommentRecord objects with the Spring Data JPA query method:

@Query("""
    select new PostCommentRecord(
        p.id as id,
        p.title as title,
        c.review as review
    )
    from PostComment c
    join c.post p
    where p.title like :postTitle
    order by c.id
    """)
List<PostCommentRecord> findCommentRecordByTitle(
    @Param("postTitle") String postTitle
);

The reason why we could use the simple name of the PostCommentRecord Java Class in the JPQL constructor expression is that I registered the following ClassImportIntegrator from the Hypersistence Utils project:

properties.put(
    "hibernate.integrator_provider",
    (IntegratorProvider) () -> Collections.singletonList(
        new ClassImportIntegrator(
            List.of(
                PostCommentRecord.class
            )
        )
    )
);

For more details about Java Records, check out this article as well.

And that’s not all! Java 17 improves the error message for NullPointerException and adds pattern matching for switch and instanceOf.

JPA 3.1

By default, Spring 6 uses Hibernate 6.1, which in turn uses Jakarta Persistence 3.1.

Now, the 3.0 version marks the migration from Java Persistence to Jakarta Persistence, so, for this reason, you will have to replace the javax.persistence package imports with the jakarta.persistence namespace.

That is the most significant change that you will have to make in order to migrate to JPA 3. In the meantime, the 3.1 version was released, but this one includes just some minor improvements that were already supported by Hibernate.

UUID entity attributes

For instance, JPA 3 now supports UUID basic types:

@Column(
    name = "external_id",
    columnDefinition = "UUID NOT NULL"
)
private UUID externalId;

And you can even use them as entity identifiers:

@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;

But that’s just a terrible idea because using an UUID for the Primary Key is going to cause a lot of issues:

  • the index pages will be sparsely populated because each new UUID will be added randomly across the B+Tree clustered index.
  • there are going to be more page splits because of the randomness of the Primary Key values
  • the UUID is huge, needing twice as many bytes as a bigint column. Not only it affects the Primary Key but all the associated Foreign Keys as well.

More, if you’re using SQL Server, MySQL, or MariaDB, the default table is going to be organized as a Clustered Index, making all these problems even worse.

So, you are better off avoiding using the UUID for entity identifiers. If you really need to generate unique identifiers from the application, then you are better off using a 64-bit time-sorted random TSID instead.

For more details about why the standard UUIDs are not a good fit for Primary Keys and why you should use a time-sorted TSID instead, check out this article.

New JPQL functions

JPQL was enhanced with many new functions, like the CEILING, FLOOR, EXP, LN, POWER, ROUND, SIGN numeric functions.

However, the most useful one I’ve found is the EXTRACT Date/Time function:

List<Post> posts = entityManager.createQuery("""
    select p
    from Post p
    where EXTRACT(YEAR FROM createdOn) = :year
    """, Post.class)
.setParameter("year", Year.now().getValue())
.getResultList();

This is useful because Date/Time processing usually requires database-specific functions, and having a generic function that can render the proper database-specific function is surely handy.

Auto-closable EntityManager and EntityManagerFactory

While the Hibernate Session and SessionFactory were already extending the AutoClosable interface, now the JPA EntityManager and EntityManagerFactory have followed this practice as well:

AutoClosable EntityManager and EntityManagerFactory

Although you might rarely need to rely on that because Spring takes care of the EntityManager on your behalf, it’s very handy when you have to process the EntityManager programmatically.

Hibernate 6

While Java 17 and JPA 3.1 bring you a few features, Hibernate 6 provides a ton of enhancements.

JDBC optimizations

Previously, Hibernate was reading the JDBC ResultSet column values using the associated column alias, and that was proven to be slow. For this reason, Hibernate 6 has switched to reading the underlying column values by their position in the underlying SQL projection.

Apart from being faster, there’s a very nice side-effect of making this change. The underlying SQL queries are now more readable.

For instance, if you run this JPQL query on Hibernate 5:

Post post = entityManager.createQuery("""
    select p 
    from Post p
    join fetch p.comments
    where p.id = :id
    """, Post.class)
.setParameter("id", 1L)
.getSingleResult();

The following SQL query will be executed:

SELECT 
    bidirectio0_.id AS id1_0_0_,
    comments1_.id AS id1_1_1_,
    bidirectio0_.title AS title2_0_0_,
    comments1_.post_id AS post_id3_1_1_,
    comments1_.review AS review2_1_1_,
    comments1_.post_id AS post_id3_1_0__,
    comments1_.id AS id1_1_0__
FROM post 
    bidirectio0_
INNER JOIN 
    post_comment comments1_ ON bidirectio0_.id=comments1_.post_id
WHERE 
    bidirectio0_.id=1

Ugly!

How, if you run the same JPQL on Hibernate 6, the following SQL query will be run instead:

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 = 1

Much better, right?

Semantic Query Model and Criteria queries

Hibernate 6 provides a brand-new entity query parser that is able to produce a canonical model, the Semantic Query Model, from both JPQL and Criteria API.

Hibernate SQM – Semantic Query Model

By unifying the entity query model, Criteria queries are now able to be enhanced with features that are not supported by a Jakarta Persistence, like Derived Tables or Common Table Expressions.

For more details about the Hibernate Semantic Query Model, check out this article.

The legacy Hibernate Criteria has been removed, but the Criteria API was enhanced with many new features that are available via the HibernateCriteriaBuilder.

For instance, you can use the ilike function for case-insensitive LIKE matching:

HibernateCriteriaBuilder builder = entityManager
    .unwrap(Session.class)
    .getCriteriaBuilder();

CriteriaQuery<Post> criteria = builder.createQuery(Post.class);
Root<Post> post = criteria.from(Post.class);

ParameterExpression<String> parameterExpression = builder
    .parameter(String.class);
    
List<Post> posts = entityManager.createQuery(
    criteria
        .where(
            builder.ilike(
                post.get(Post_.TITLE), 
                parameterExpression)
            )
        .orderBy(
            builder.asc(
                post.get(Post_.ID)
            )
        )
)
.setParameter(parameterExpression, titlePattern)
.setMaxResults(maxCount)
.getResultList();

However, this is just a basic example. With the new HibernateCriteriaBuilder, you can now render:

Dialect enhancements

While in Hibernate 5, there were a ton of Dialect versions you had to choose from based on the underlying database version, this was greatly simplified in Hibernate 6:

Hibernate 6 MySQL Dialect

More, you don’t even need to provide the Dialect in the Spring configuration, as it can be resolved from the JDBC DatabaseMetaData.

For more details about this topic, check out this article.

Auto-deduplication

Do you remember how annoying it was to provide the DISTINCT keyword for entity deduplication whenever you were using JOIN FETCH?

List<Post> posts = entityManager.createQuery("""
    select distinct p
    from Post p
    left join fetch p.comments
    where p.title = :title
    """, Post.class)
.setParameter("title", "High-Performance Java Persistence")
.setHint(QueryHints.HINT_PASS_DISTINCT_THROUGH, false)
.getResultList();

If you forgot to send the PASS_DISTINCT_THROUGH hint, then Hibernate 5 would pass the DISTINCT keyword to the SQL query and cause the execution plan to run some extra steps that only made your query slower:

Unique  
  (cost=23.71..23.72 rows=1 width=1068) 
  (actual time=0.131..0.132 rows=2 loops=1)
  ->  Sort  
        (cost=23.71..23.71 rows=1 width=1068) 
        (actual time=0.131..0.131 rows=2 loops=1)
        Sort Key: p.id, pc.id, p.created_on, pc.post_id, pc.review
        Sort Method: quicksort  Memory: 25kB
        ->  Hash Right Join  
            (cost=11.76..23.70 rows=1 width=1068) 
            (actual time=0.054..0.058 rows=2 loops=1)
              Hash Cond: (pc.post_id = p.id)
              ->  Seq Scan on post_comment pc  
                  (cost=0.00..11.40 rows=140 width=532) 
                  (actual time=0.010..0.010 rows=2 loops=1)
              ->  Hash  
                   (cost=11.75..11.75 rows=1 width=528) 
                   (actual time=0.027..0.027 rows=1 loops=1)
                    Buckets: 1024  Batches: 1  Memory Usage: 9kB
                    ->  Seq Scan on post p  
                        (cost=0.00..11.75 rows=1 width=528) 
                        (actual time=0.017..0.018 rows=1 loops=1)
                          Filter: (
                            (title)::text = 
                            'High-Performance Java Persistence eBook has been released!'::text
                          )
                          Rows Removed by Filter: 3

For more details about how DISTINCT works in JPA, heck out this article as well.

That’s no longer the case, as now the entity Object reference deduplication is done automatically, so your JOIN FETCH queries no longer need the DISTINCT keyword:

List<Post> posts = entityManager.createQuery("""
    select p
    from Post p
    left join fetch p.comments
    where p.title = :title
    """, Post.class)
.setParameter("title", "High-Performance Java Persistence")
.getResultList();

Awesome, right?

I'm running an online workshop on the 20-21 and 23-24 of November about High-Performance Java Persistence.

If you enjoyed this article, I bet you are going to love my Book and Video Courses as well.

Conclusion

Spring 6 is really worth upgrading to. Besides benefiting from all the language optimizations provided by Java 17, there are tons of new features provided by all the other framework dependencies that have been integrated into Spring 6.

For instance, Hibernate 6 provides a lot of optimizations and new features that are going to address many of your daily data access requirements.

And that is also why I have launched the Spring and Hibernate 6 Migration training. So, if you like my articles, you are going to love running this training with me.

Transactions and Concurrency Control eBook

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.