In this article, we'll explore the various scenarios where JDK 14 Records prove to be a game-changer.
Unlocking the Power of JDK 14 Records: A Simplified Introduction
New to JDK 14 Records? Let's start with the fundamentals: Records provide a compact syntax for defining classes that serve as simple, immutable data holders, minus the unnecessary code.
A practical example is the best way to demonstrate this. Consider the following Java class:
public final class Author {
private final String name;
private final String genre;
public Author(String name, String genre) {
this.name = name;
this.genre = genre;
}
public String getName() {
return name;
}
public String getGenre() {
return genre;
}
@Override
public boolean equals(Object o) {
...
}
@Override
public int hashCode() {
...
}
@Override
public String toString() {
...
}
}
Thanks to JDK 14, the Records syntax can be utilized to condense the above code into a single, concise line, making your coding experience more efficient:
public record Author(String name, String genre) {}
At t8tech.com, we've seen firsthand how JDK 14 Records can revolutionize the way you approach Spring development. In this article, we'll delve into the various scenarios where this feature comes into play, providing you with a comprehensive understanding of its applications.
And that's the final verdict! Running the javap
tool on Author.class
produces the following output:
Upon scrutinizing the properties of an immutable class, we notice that Person.class
indeed exhibits immutability:
- The class must be declared
final
to prevent subclassing (other classes cannot extend this class, thereby precluding method overriding). - All fields should be declared
private
andfinal
. (They are inaccessible to other classes, and they are initialized solely once in the constructor of this class.) - The class should feature a parameterized
public
constructor (or aprivate
constructor and factory methods for instance creation) that initializes the fields. - The class should provide getter methods for fields.
- The class should not expose setter methods.
You can delve deeper into immutable objects in my book, Java Coding Problems.
So, JDK 14 Records are not a substitute for mutable JavaBean classes. They cannot be utilized as JPA/Hibernate entities. However, they are an ideal fit for use with Streams. They can be instantiated via the constructor with arguments, and in lieu of getters, we can access the fields via methods with similar names (e.g., the field
name
is exposed via thename()
method).
Next, let’s explore several scenarios of utilizing JDK 14 Records in a Spring application.
JSON Serialization of Records
Let’s assume that an author has penned multiple books. By defining a List<Book>
in the Author
class, we can model this scenario, having the Author
and the Book
class:
public final class Author {
private final String name;
private final String genre;
private final List<Book> books;
...
}
public final Book {
private String title;
private String isbn;
...
}
If we use Records, then we can eliminate the boilerplate code as below:
public record Author(String name, String genre, List<Book> books) {}
public record Book(String title, String isbn) {}
Let’s consider the following data sample:
List<Author> authors = List.of(
new Author("Joana Nimar", "History", List.of(
new Book("History of a day", "JN-001"),
new Book("Prague history", "JN-002")
)),
new Author("Mark Janel", "Horror", List.of(
new Book("Carrie", "MJ-001"),
new Book("House of pain", "MJ-002")
))
);
If we want to serialize this data as JSON via a Spring REST Controller, then most we will most likely do it, as shown below. First, we have a service that returns the data:
@Service
public class BookstoreService {
public List<Author> fetchAuthors() {
List<Author> authors = List.of(
new Author("Joana Nimar", "History", List.of(
new Book("History of a day", "JN-001"),
new Book("Prague history", "JN-002")
)),
new Author("Mark Janel", "Horror", List.of(
new Book("Carrie", "MJ-001"),
new Book("House of pain", "MJ-002")
)));
return authors;
}
}
And, the controller is quite simple:
@RestController
public class BookstoreController {
private final BookstoreService bookstoreService;
public BookstoreController(BookstoreService bookstoreService) {
this.bookstoreService = bookstoreService;
}
@GetMapping("/authors")
public List<Author> fetchAuthors() {
return bookstoreService.fetchAuthors();
}
}
However, when we access the endpoint, localhost:8080/authors
, we encounter the following result:
This suggests that the objects are not serializable. The solution involves adding the Jackson annotations, JsonProperty
, to facilitate serialization:
import com.fasterxml.jackson.annotation.JsonProperty;
public record Author(
@JsonProperty("name") String name,
@JsonProperty("genre") String genre,
@JsonProperty("books") List<Book> books
) {}
public record Book(
@JsonProperty("title") String title,
@JsonProperty("isbn") String isbn
) {}
This time, accessing the localhost:8080/authors
endpoint yields the following JSON output:
[
{
"name": "Joana Nimar",
"genre": "History",
"books": [
{
"title": "History of a day",
"isbn": "JN-001"
},
{
"title": "Prague history",
"isbn": "JN-002"
}
]
},
{
"name": "Mark Janel",
"genre": "Horror",
"books": [
{
"title": "Carrie",
"isbn": "MJ-001"
},
{
"title": "House of pain",
"isbn": "MJ-002"
}
]
}
]
The complete code is available on GitHub.
Records and Dependency Injection: A Closer Look
Let’s revisit our controller and explore how records and dependency injection work together:
@RestController
public class BookstoreController {
private final BookstoreService bookstoreService;
public BookstoreController(BookstoreService bookstoreService) {
this.bookstoreService = bookstoreService;
}
@GetMapping("/authors")
public List<Author> fetchAuthors() {
return bookstoreService.fetchAuthors();
}
}
In this controller, we utilize Dependency Injection to inject a BookstoreService
instance. Alternatively, we could have employed @Autowired
. However, we can explore the use of JDK 14 Records, as demonstrated below:
@RestController
public record BookstoreController(BookstoreService bookstoreService) {
@GetMapping("/authors")
public List<Author> fetchAuthors() {
return bookstoreService.fetchAuthors();
}
}
The complete code is available on GitHub.
DTOs via Records and Spring Data Query Builder
Let’s reiterate this crucial point:
JDK 14 Records are incompatible with JPA/Hibernate entities due to the absence of setters.
Now, let’s examine the following JPA entity:
@Entity
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int age;
private String name;
private String genre;
// getters and setters
}
Our goal is to retrieve a read-only list of authors, including their names and ages. To achieve this, we require a DTO. We can define a Spring projection, a POJO, or a Java Record, as shown below:
public record AuthorDto(String name, int age) {}
The query that populates the DTO can be crafted using Spring Data Query Builder:
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Transactional(readOnly = true)
List<AuthorDto> retrieveAuthorsByGenre(String genre);
}
The complete application is available on GitHub.
DTOs via Records and Constructor Expression and JPQL
Given the same Author
entity and the same AuthorDto
record, we can construct the query via Constructor Expression and JPQL, as follows:
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Transactional(readOnly = true)
@Query(value = "SELECT new com.bookstore.dto.AuthorDto(a.name, a.age) FROM Author a")
List<AuthorDto> retrieveAuthors();
}
The comprehensive application is accessible on GitHub.
DTOs via Records and Hibernate ResultTransformer
In certain scenarios, we need to retrieve a DTO comprising a subset of properties (columns) from a parent-child association. For such cases, we can utilize a SQL JOIN
that can extract the desired columns from the involved tables. However, JOIN
returns a List<Object[]>
, and most likely, you will need to represent it as a List<ParentDto>
, where a ParentDto
instance has a List<ChildDto>
.
Such an example is the below bidirectional @OneToMany
relationship between Author
and Book
entities:
@Entity
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String genre;
private int age;
@OneToMany(cascade = CascadeType.ALL,
mappedBy = "author", orphanRemoval = true)
private List<Book> books = new ArrayList<>();
// getters and setters
}
@Entity
public class Book implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String isbn;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private Author author;
// getters and setters
}
To retrieve the id
, name
, and age
of each author, along with the id
and title
of their associated books, the application leverages DTO and the Hibernate-specific ResultTransformer
. This interface enables the transformation of query results into the actual application-visible query result list, supporting both JPQL and native queries, and is a remarkably powerful feature.
The initial step involves defining the DTO class. The ResultTransformer
can fetch data in a DTO with a constructor and no setters or in a DTO with no constructor but with setters. To fetch the name and age in a DTO with a constructor and no setters, a DTO shaped via JDK 14 Records is required:
import java.util.List;
public record AuthorDto(Long id, String name, int age, List<BookDto> books) {
public void addBook(BookDto book) {
books().add(book);
}
}
public record BookDto(Long id, String title) {}
However, assigning the result set to AuthorDto
is not feasible through a native ResultTransformer
. Instead, you need to convert the result set from Object[]
to List<AuthorDto>
, which necessitates the custom AuthorBookTransformer
, an implementation of the ResultTransformer
interface.
This interface specifies two methods — transformTuple()
and transformList()
. The transformTuple()
method facilitates the transformation of tuples, which constitute each row of the query result. The transformList()
method, on the other hand, enables you to perform the transformation on the query result as a whole:
public class AuthorBookTransformer implements ResultTransformer {
private Map<Long, AuthorDto> authorsDtoMap = new HashMap<>();
@Override
public Object transformTuple(Object[] os, String[] strings) {
Long authorId = ((Number) os[0]).longValue();
AuthorDto authorDto = authorsDtoMap.get(authorId);
if (authorDto == null) {
authorDto = new AuthorDto(((Number) os[0]).longValue(),
(String) os[1], (int) os[2], new ArrayList<>());
}
BookDto bookDto = new BookDto(((Number) os[3]).longValue(), (String) os[4]);
authorDto.addBook(bookDto);
authorsDtoMap.putIfAbsent(authorDto.id(), authorDto);
return authorDto;
}
@Override
public List<AuthorDto> transformList(List list) {
return new ArrayList<>(authorsDtoMap.values());
}
}
The bespoke DAO implementation that leverages this custom ResultTransformer
is presented below:
@Repository
public class DataAccessObject implements AuthorDataAccessObject {
@PersistenceContext
private EntityManager entityManager;
@Override
@Transactional(readOnly = true)
public List<AuthorDataTransferObject> retrieveAuthorWithBook() {
Query query = entityManager
.createNativeQuery(
"SELECT a.id AS author_id, a.name AS name, a.age AS age, "
+ "b.id AS book_id, b.title AS title "
+ "FROM author a JOIN book b ON a.id=b.author_id")
.unwrap(org.hibernate.query.NativeQuery.class)
.setResultTransformer(new AuthorBookTransformer());
List<AuthorDataTransferObject> authors = query.getResultList();
return authors;
}
}
In the end, we can obtain the data in the following service:
@Service
public class BookstoreBusinessService {
private final DataAccessObject dao;
public BookstoreBusinessService(DataAccessObject dao) {
this.dao = dao;
}
public List<AuthorDataTransferObject> retrieveAuthorWithBook() {
List<AuthorDataTransferObject> authors = dao.retrieveAuthorWithBook();
return authors;
}
}
@Service
public record BookstoreBusinessService(DataAccessObject dao) {
public List<AuthorDataTransferObject> retrieveAuthorWithBook() {
List<AuthorDataTransferObject> authors = dao.retrieveAuthorWithBook();
return authors;
}
}
The complete application is available on GitHub.
Starting with Hibernate 5.2,
ResultTransformer
is deprecated. Until a replacement is available (in Hibernate 6.0), it can be used.Read further here.
Data Transfer Objects via Records, JdbcTemplate, and ResultSetExtractor
Achieving a similar mapping via JdbcTemplate
and ResultSetExtractor
can be accomplished as follows. The AuthorDataTransferObject
and BookDataTransferObject
are the same from the previous section:
public record AuthorDto(Long id, String name, int age, List books) {
public void addBook(BookDto book) {
books().add(book);
}
}
public record BookDto(Long id, String title) {}
@Repository
@Transactional(readOnly = true)
public class AuthorExtractor {
private final JdbcTemplate jdbcTemplate;
public AuthorExtractor(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public List<AuthorDto> extract() {
String sql = "SELECT a.id, a.name, a.age, b.id, b.title "
+ "FROM author a INNER JOIN book b ON a.id = b.author_id";
List<AuthorDto> result = jdbcTemplate.query(sql, (ResultSet rs) -> {
final Map<Long, AuthorDto> authorsMap = new HashMap<>();
while (rs.next()) {
Long authorId = (rs.getLong("id"));
AuthorDto author = authorsMap.get(authorId);
if (author == null) {
author = new AuthorDto(rs.getLong("id"), rs.getString("name"),
rs.getInt("age"), new ArrayList());
}
BookDto book = new BookDto(rs.getLong("id"), rs.getString("title"));
author.addBook(book);
authorsMap.putIfAbsent(author.id(), author);
}
return new ArrayList<>(authorsMap.values());
});
return result;
}
}
The complete application is available on GitHub.
Refine the Implementation
Java Records allow us to validate the arguments of the constructor, therefore the following code is ok:
public record Author(String name, int age) {
public Author {
if (age <=18 || age > 70)
throw new IllegalArgumentException("...");
}
}
For a deeper understanding, I suggest exploring this valuable resource. Furthermore, you may also find it advantageous to examine over 150 key considerations for optimizing persistence performance in Spring Boot, as detailed in Spring Boot Persistence Best Practices: