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:

Executing javap on Author class

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 and final. (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 a private 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.

jdk-14-records-for-spring-devs_img_1.png

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 the  name() 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&lt;Book&gt; 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&lt;Book&gt; books) {}
public record Book(String title, String isbn) {}

Let’s consider the following data sample:

List&lt;Author&gt; 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&lt;Author&gt; fetchAuthors() {
    
    List&lt;Author&gt; 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&lt;Author&gt; fetchAuthors() {
    return bookstoreService.fetchAuthors();
  }
}

However, when we access the endpoint, localhost:8080/authors, we encounter the following result:

Authors endpoint response

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&lt;Author&gt; 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&lt;Author, Long&gt; {
  
  @Transactional(readOnly = true)    
  List&lt;AuthorDto&gt; 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&lt;Author, Long&gt; {
  
  @Transactional(readOnly = true)
  @Query(value = "SELECT new com.bookstore.dto.AuthorDto(a.name, a.age) FROM Author a")
  List&lt;AuthorDto&gt; 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:

One to Many relationship

@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&lt;Book&gt; books = new ArrayList&lt;&gt;();
     
  // 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&lt;AuthorDto&gt; 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&lt;AuthorDto&gt; result = jdbcTemplate.query(sql, (ResultSet rs) -&gt; {
      
      final Map&lt;Long, AuthorDto&gt; authorsMap = new HashMap&lt;&gt;();
      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&lt;&gt;(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 &lt;=18 || age &gt; 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:

jdk-14-records-for-spring-devs_img_4.png