How To Best Use Java Records as DTOs in Spring Boot 3
Explore how to best use compact Java records as DTOs for database and API calls in Spring Boot 3 with Hibernate 6 as the persistence provider.
Join the DZone community and get the full member experience.
Join For FreeWith the Spring 6 and Spring Boot 3 releases, Java 17+ became the baseline framework version. So now is a great time to start using compact Java records as Data Transfer Objects (DTOs) for various database and API calls.
Whether you prefer reading or watching, let’s review a few approaches for using Java records as DTOs that apply to Spring Boot 3 with Hibernate 6 as the persistence provider.
Sample Database
Follow these instructions if you’d like to install the sample database and experiment yourself. Otherwise, feel free to skip this section:
1. Download the Chinook Database dataset (music store) for the PostgreSQL syntax.
2. Start an instance of YugabyteDB, a PostgreSQL-compliant distributed database, in Docker:
mkdir ~/yb_docker_data
docker network create custom-network
docker run -d --name yugabytedb_node1 --net custom-network \
-p 7001:7000 -p 9000:9000 -p 5433:5433 \
-v ~/yb_docker_data/node1:/home/yugabyte/yb_data --restart unless-stopped \
yugabytedb/yugabyte:latest \
bin/yugabyted start \
--base_dir=/home/yugabyte/yb_data --daemon=false
3. Create the chinook
database in YugabyteDB:
createdb -h 127.0.0.1 -p 5433 -U yugabyte -E UTF8 chinook
4. Load the sample dataset:
psql -h 127.0.0.1 -p 5433 -U yugabyte -f Chinook_PostgreSql_utf8.sql -d chinook
Next, create a sample Spring Boot 3 application:
1. Generate an application template using Spring Boot 3+ and Java 17+ with Spring Data JPA as a dependency.
2. Add the PostgreSQL driver to the pom.xml
file:
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.5.4</version>
</dependency>
3. Provide YugabyteDB connectivity settings in the application.properties
file:
spring.datasource.url = jdbc:postgresql://127.0.0.1:5433/chinook
spring.datasource.username = yugabyte
spring.datasource.password = yugabyte
All set! Now, you’re ready to follow the rest of the guide.
Data Model
The Chinook Database comes with many relations, but two tables will be more than enough to show how to use Java records as DTOs.
The first table is Track
, and below is a definition of a corresponding JPA entity class:
@Entity
public class Track {
@Id
private Integer trackId;
@Column(nullable = false)
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "album_id")
private Album album;
@Column(nullable = false)
private Integer mediaTypeId;
private Integer genreId;
private String composer;
@Column(nullable = false)
private Integer milliseconds;
private Integer bytes;
@Column(nullable = false)
private BigDecimal unitPrice;
// Getters and setters are omitted
}
The second table is Album
and has the following entity class:
@Entity
public class Album {
@Id
private Integer albumId;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private Integer artistId;
// Getters and setters are omitted
}
In addition to the entity classes, create a Java record named TrackRecord
that stores short but descriptive song information:
public record TrackRecord(String name, String album, String composer) {}
Naive Approach
Imagine you need to implement a REST endpoint that returns a short song description. The API needs to provide song and album names, as well as the author’s name.
The previously created TrackRecord
class can fit the required information. So, let’s create a record using the naive approach that gets the data via JPA Entity
classes:
1. Add the following JPA Repository:
public interface TrackRepository extends JpaRepository<Track, Integer> {
}
2. Add Spring Boot’s service-level method that creates a TrackRecord
instance from the Track
entity class. The latter is retrieved via the TrackRepository
instance:
@Transactional(readOnly = true)
public TrackRecord getTrackRecord(Integer trackId) {
Track track = repository.findById(trackId).get();
TrackRecord trackRecord = new TrackRecord(
track.getName(),
track.getAlbum().getTitle(),
track.getComposer());
return trackRecord;
}
The solution looks simple and compact, but it’s very inefficient because Hibernate needs to instantiate two entities first: Track
and Album
(see the track.getAlbum().getTitle()
). To do this, it generates two SQL queries that request all the columns of the corresponding database tables:
Hibernate:
select
t1_0.track_id,
t1_0.album_id,
t1_0.bytes,
t1_0.composer,
t1_0.genre_id,
t1_0.media_type_id,
t1_0.milliseconds,
t1_0.name,
t1_0.unit_price
from
track t1_0
where
t1_0.track_id=?
Hibernate:
select
a1_0.album_id,
a1_0.artist_id,
a1_0.title
from
album a1_0
where
a1_0.album_id=?
Hibernate selects 12 columns across two tables, but TrackRecord
needs only three columns! This is a waste of memory, computing, and networking resources, especially if you use distributed databases like YugabyteDB that scatters data across multiple cluster nodes.
TupleTransformer
The naive approach can be easily remediated if you query only the records the API requires and then transform a query result set to a respective Java record.
The Spring Data module of Spring Boot 3 relies on Hibernate 6. That version of Hibernate split the ResultTransformer
interface into two interfaces: TupleTransformer
and ResultListTransformer
.
The TupleTransformer
class supports Java records, so, the implementation of the public TrackRecord getTrackRecord(Integer trackId)
can be optimized this way:
@Transactional(readOnly = true)
public TrackRecord getTrackRecord(Integer trackId) {
org.hibernate.query.Query<TrackRecord> query = entityManager.createQuery(
"""
SELECT t.name, a.title, t.composer
FROM Track t
JOIN Album a ON t.album.albumId=a.albumId
WHERE t.trackId=:id
""").
setParameter("id", trackId).
unwrap(org.hibernate.query.Query.class);
TrackRecord trackRecord = query.setTupleTransformer((tuple, aliases) -> {
return new TrackRecord(
(String) tuple[0],
(String) tuple[1],
(String) tuple[2]);
}).getSingleResult();
return trackRecord;
}
entityManager.createQuery(...)
- Creates a JPA query that requests three columns that are needed for theTrackRecord
class.query.setTupleTransformer(...)
- TheTupleTransformer
supports Java records, which means aTrackRecord
instance can be created in the transformer’s implementation.
This approach is more efficient than the previous one because you no longer need to create entity classes and can easily construct a Java record with the TupleTransformer
. Plus, Hibernate generates a single SQL request that returns only the required columns:
Hibernate:
select
t1_0.name,
a1_0.title,
t1_0.composer
from
track t1_0
join
album a1_0
on t1_0.album_id=a1_0.album_id
where
t1_0.track_id=?
However, there is one very visible downside to this approach: the implementation of the public TrackRecord getTrackRecord(Integer trackId)
method became longer and wordier.
Java Record Within JPA Query
There are several ways to shorten the previous implementation. One is to instantiate a Java record instance within a JPA query.
First, expand the implementation of the TrackRepository
interface with a custom query that creates a TrackRecord
instance from requested database columns:
public interface TrackRepository extends JpaRepository<Track, Integer> {
@Query("""
SELECT new com.my.springboot.app.TrackRecord(t.name, a.title, t.composer)
FROM Track t
JOIN Album a ON t.album.albumId=a.albumId
WHERE t.trackId=:id
""")
TrackRecord findTrackRecord(@Param("id") Integer trackId);
}
Next, update the implementation of the TrackRecord getTrackRecord(Integer trackId)
method this way:
@Transactional(readOnly = true)
public TrackRecord getTrackRecord(Integer trackId) {
return repository.findTrackRecord(trackId);
}
So, the method implementation became a one-liner that gets a TrackRecord
instance straight from the JPA repository: as simple as possible.
But that’s not all. There is one more small issue. The JPA query that constructs a Java record requires you to provide a full package name for the TrackRecord
class:
SELECT new com.my.springboot.app.TrackRecord(t.name, a.title, t.composer)...
Let’s find a way to bypass this requirement. Ideally, the Java record needs to be instantiated without the package name:
SELECT new TrackRecord(t.name, a.title, t.composer)...
Hypersistence Utils
Hypersistence Utils library comes with many goodies for Spring and Hibernate. One feature allows you to create a Java record instance within a JPA query without the package name.
Let’s enable the library and this Java records-related feature in the Spring Boot application:
1. Add the library’s Maven artifact for Hibernate 6.
2. Create a custom IntegratorProvider
that registers TrackRecord
class with Hibernate:
public class ClassImportIntegratorProvider implements IntegratorProvider {
@Override
public List<Integrator> getIntegrators() {
return List.of(new ClassImportIntegrator(List.of(TrackRecord.class)));
}
}
3. Update the application.properties
file by adding this custom IntegratorProvider
:
spring.jpa.properties.hibernate.integrator_provider=com.my.springboot.app.ClassImportIntegratorProvider
After that you can update the JPA query of the TrackRepository.findTrackRecord(...)
method by removing the Java record’s package name from the query string:
@Query("""
SELECT new TrackRecord(t.name, a.title, t.composer)
FROM Track t
JOIN Album a ON t.album.albumId=a.albumId
WHERE t.trackId=:id
""")
TrackRecord findTrackRecord(@Param("id") Integer trackId);
It’s that simple!
Summary
The latest versions of Java, Spring, and Hibernate have a number of significant enhancements to simplify and make coding in Java more fun. One such enhancement is built-in support for Java records that can now be easily used as DTOs in Spring Boot applications. Enjoy!
Opinions expressed by DZone contributors are their own.
Comments