What are Records in Java 14

Image from https://unsplash.com/.

Overview

1. Introduction

Java 14 Record keyword plays an essential role in modern Java applications, saving developers time and reducing bugs.

In this article, we'll look in detail at Records in Java 14, how to use them, and real-world applications.

2. Importance of Java Records in Modern Applications

Using Plain-Old Java Objects (POJO) to pass data along, such as an HTTP request body or a database query result, is very common in Java Applications.

The problem with POJOs is that they are not immutable by default and need a lot of boilerplate code. For instance, to encapsulate their fields, we must manually create getters, setters, and constructors. The same happens if we need an implementation of toString, hashCode, or the equals methods.

Copying and pasting boilerplate code over classes is a pain for any Java developer. Encouraged by that, Java 14 introduced Records, an immutable data structure that automatically generates helpful methods.

3. Main Features of Java Records

The main features of Java Records are immutability and boilerplate code generation. Together they create a powerful tool for data transmission inside our application.

3.1. Immutability

In Records, every field is final, so we can't modify them after calling the constructor. To modify a Record, we need to create another instance and copy all the data we want to keep, just like modifying a String.

Similar to Enums, Records are implicitly final in their declaration, so they can't be extended by any class. We can't create a mutable version of a Record.

The main advantage of an immutable object is that its variables are assigned once, so we don't need to keep its state consistent. That creates less error-prone and simpler code.

3.2. Boilerplate Code Generation

Records automatically generate the methods we need to create in our POJOs manually. In short, a Record generates all the methods below:

  • A canonical constructor (a constructor with all fields in order).
  • A set of getter methods for all fields.
  • An overriden version of toString, hashCode, and equals methods.

In the next section, we'll look at code examples for using a Record and how methods are generated.

4. How to Use Java Records

To create a Record, we use the record keyword and declare its fields right after its name, similar to a method list of arguments. Let's see an example of a Record named OrderRequest containing the price and item fields:

1public record OrderRequest(double price, String item) {
2}

Well, that's it. We created our Record with two final instance variables price and item.

Records are implicitly final, making them non-extensible by other classes or records. Therefore, the code below doesn't compile:

1public record FoodOrderRequest extends OrderRequest(String chefName){
2}

4.1. Constructors

If we don't define any constructor, the compiler generates one with the fields in the same order they appear in the Record declaration. Exploring the compiled code of OrderRequest, we'll find the following constructor generated:

1public OrderRequest(double price, String item) {
2    this.price = price;
3    this.item = item;
4}

If we don't want the auto-generated constructor, we can define two other types: the long or the compact constructors.

4.1.1. Long Constructor

To illustrate the long constructor, we'll add a validation logic that throws an exception if the price is negative:

1public OrderRequest(double price, String item) {
2    if (price < 0) throw new IllegalArgumentException("Price cannot be negative");
3    this.price = price;
4    this.item = item;
5}

Since we provided a canonical constructor, the compiler doesn't generate another one.

Note: Record fields are final, so the long constructor provided must set every field. Otherwise, the code doesn't compile.

4.1.2. Compact Constructor

Now, let's see the compact version of the same constructor that throws the IllegalArgumentException:

1public OrderRequest {
2    if (price < 0) throw new IllegalArgumentException("Price cannot be negative");
3}

We can omit the arguments and field's attribution using compact constructors.

It's also possible to transform data inside the compact constructor. Let's use a default value for price instead of throwing an exception:

1public OrderRequest {
2    if (price < 0) price = 0.0;
3}

Compact constructors execute the logic we define first, then calls the auto-generated canonical constructor to initialize the fields.

Note: The value modified in the example is the constructor argument, not the instance field price. Thus, declaring this.price = 0.0 doesn't compile.

4.2. Getters and Setters

Records generate the getter method for each field, using just the field name in the naming convention. Below are the getters generated by the OrderRequest Record:

1public double price() {
2    return this.price;
3}
4
5public String item() {
6    return this.item;
7}

Record's fields are final, so the compiler doesn't create any setter method. In fact, it is impossible to do something like this:

1public void setPrice(double price) {
2    this.price = price;
3}

The code doesn't compile with this message:

1Cannot assign a value to final variable 'price'.

4.3. equals()

The equals() method generated compares each field's value and returns true in terms of their equals() implementation. Let's verify that with this test:

1@Test
2void givenTwoRecords_whenComparedUsingEquals_ThenReturnCorrectResult() {
3    var order1 = new OrderRequest(2000.35, "Office Chair");
4    var order2 = new OrderRequest(2000.35, "Office Chair");
5    var order3 = new OrderRequest(300.21, "Refurbished Office Chair");
6    assertTrue(order1.equals(order2));
7    assertFalse(order1.equals(order3));
8    assertFalse(order2.equals(order3));
9}

4.4. hashCode()

A hashCode() method using each field to calculate a consistent hash is also generated:

1@Test
2void givenTwoRecords_whenCompareHashCodes_ThenReturnCorrectResult() {
3    var order1 = new OrderRequest(2000.35, "Office Chair");
4    var order2 = new OrderRequest(2000.35, "Office Chair");
5    var order3 = new OrderRequest(300.21, "Refurbished Office Chair");
6    assertEquals(order1.hashCode(), order2.hashCode());
7    assertNotEquals(order1.hashCode(), order3.hashCode());
8    assertNotEquals(order2.hashCode(), order3.hashCode());
9}

4.5. toString()

Java also creates a toString() method that prints each field in an easy-to-read formatted way:

1var order1 = new OrderRequest(2000.35, "Office Chair");
2System.out.println(order1);

The code above prints this:

1OrderRequest[price=2000.35, item=Office Chair]

5. Real-World Applications for Java Records

Records in modern Java applications can save developers time and create clean code if appropriately used. Two scenarios that Records fit in perfectly are Data Modeling and the DTO pattern.

5.1. Data Modeling

POJOs used as the model layer of our application are typically messy due to the amount of boilerplate code they contain. They usually need further investigation to understand what data it holds, slowing down the developer's work.

We can use records to model data concisely and expressively, making the code much easier to read and maintain. All fields are shown right in the Record declaration, so we don't need to navigate through the code to verify what data it contains.

For instance, if we glance at the OrderRequest Record, we can check what data it holds in seconds. Or, if someone needs our help to confirm the model data, we can simply copy the signature and send it to them. With POJOs, we need to check line-by-line.

5.1. Data Transfer Objects

The Data Transfer Object (DTO) pattern is typically found in enterprise applications. DTOs are immutable objects that carry data between components, modules, or services. One example of a DTO application is an HTTP request body.

Before Java 14, POJOs was the best-known way to implement DTOs. However, Records can easily substitute their usage to implement the DTO pattern. Records are immutable, less prone to errors, and easier to implement and maintain— a perfect candidate for a DTO.

6. Conclusion

In this article, we've seen the details about Records in Java 14 and how to use them in real-world applications.

Records save a lot of time and make applications less prone to error, making them a handy tool in modern Java applications.