Course – LS – All

Get started with Spring and Spring Boot, through the Learn Spring course:

>> CHECK OUT THE COURSE

1. Introduction

Passing immutable data between objects is one of the most common, but mundane tasks in many Java applications.

Prior to Java 14, this required the creation of a class with boilerplate fields and methods, which were susceptible to trivial mistakes and muddled intentions.

With the release of Java 14, we can now use records to remedy these problems.

In this tutorial, we’ll look at the fundamentals of records, including their purpose, generated methods, and customization techniques.

Further reading:

Java 14 Record vs. Lombok

Learn the differences and similarities between Java 14 Records and Lombok.

New Features in Java 14

Explore various JEPs of Java 14.

Guide to the @Serial Annotation in Java 14

Learn how to apply the @Serial annotation in Java 14 to assist with compile-time checks on serializable properties of a class.

2. Purpose

Commonly, we write classes to simply hold data, such as database results, query results, or information from a service.

In many cases, this data is immutable, since immutability ensures the validity of the data without synchronization.

To accomplish this, we create data classes with the following:

  1. private, final field for each piece of data
  2. getter for each field
  3. public constructor with a corresponding argument for each field
  4. equals method that returns true for objects of the same class when all fields match
  5. hashCode method that returns the same value when all fields match
  6. toString method that includes the name of the class and the name of each field and its corresponding value

For example, we can create a simple Person data class with a name and address:

public class Person {

    private final String name;
    private final String address;

    public Person(String name, String address) {
        this.name = name;
        this.address = address;
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, address);
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        } else if (!(obj instanceof Person)) {
            return false;
        } else {
            Person other = (Person) obj;
            return Objects.equals(name, other.name)
              && Objects.equals(address, other.address);
        }
    }

    @Override
    public String toString() {
        return "Person [name=" + name + ", address=" + address + "]";
    }

    // standard getters
}

While this accomplishes our goal, there are two problems with it:

  1. There’s a lot of boilerplate code
  2. We obscure the purpose of our class: to represent a person with a name and address

In the first case, we have to repeat the same tedious process for each data class, monotonously creating a new field for each piece of data; creating equalshashCode, and toString methods; and creating a constructor that accepts each field.

While IDEs can automatically generate many of these classes, they fail to automatically update our classes when we add a new field. For example, if we add a new field, we have to update our equals method to incorporate this field.

In the second case, the extra code obscures that our class is simply a data class that has two String fields, name and address.

A better approach would be to explicitly declare that our class is a data class.

3. The Basics

As of JDK 14, we can replace our repetitious data classes with records. Records are immutable data classes that require only the type and name of fields.

The equalshashCode, and toString methods, as well as the private, final fields and public constructor, are generated by the Java compiler.

To create a Person record, we’ll use the record keyword:

public record Person (String name, String address) {}

3.1. Constructor

Using records, a public constructor, with an argument for each field, is generated for us.

In the case of our Person record, the equivalent constructor is:

public Person(String name, String address) {
    this.name = name;
    this.address = address;
}

This constructor can be used in the same way as a class to instantiate objects from the record:

Person person = new Person("John Doe", "100 Linda Ln.");

3.2. Getters

We also receive public getters methods, whose names match the name of our field, for free.

In our Person record, this means a name() and address() getter:

@Test
public void givenValidNameAndAddress_whenGetNameAndAddress_thenExpectedValuesReturned() {
    String name = "John Doe";
    String address = "100 Linda Ln.";

    Person person = new Person(name, address);

    assertEquals(name, person.name());
    assertEquals(address, person.address());
}

3.3. equals

Additionally, an equals method is generated for us.

This method returns true if the supplied object is of the same type and the values of all of its fields match:

@Test
public void givenSameNameAndAddress_whenEquals_thenPersonsEqual() {
    String name = "John Doe";
    String address = "100 Linda Ln.";

    Person person1 = new Person(name, address);
    Person person2 = new Person(name, address);

    assertTrue(person1.equals(person2));
}

If any of the fields differ between two Person instances, the equals method will return false.

3.4. hashCode

Similar to our equals method, a corresponding hashCode method is also generated for us.

Our hashCode method returns the same value for two Person objects if all of the field values for both objects match (barring collisions due to the birthday paradox):

@Test
public void givenSameNameAndAddress_whenHashCode_thenPersonsEqual() {
    String name = "John Doe";
    String address = "100 Linda Ln.";

    Person person1 = new Person(name, address);
    Person person2 = new Person(name, address);

    assertEquals(person1.hashCode(), person2.hashCode());
}

The hashCode value will most likely differ if any field values differ. But that’s not guaranteed by the hashCode() contract.

3.5. toString

Lastly, we also receive a toString method that results in a string containing the name of the record, followed by the name of each field and its corresponding value in square brackets.

Therefore, instantiating a Person with a name of “John Doe” and an address of “100 Linda Ln.” results in the following toString result:

Person[name=John Doe, address=100 Linda Ln.]

4. Constructors

While a public constructor is generated for us, we can still customize our constructor implementation.

This customization is intended to be used for validation and should be kept as simple as possible.

For example, we can ensure that the name and address provided to our Person record aren’t null using the following constructor implementation:

public record Person(String name, String address) {
    public Person {
        Objects.requireNonNull(name);
        Objects.requireNonNull(address);
    }
}

We can also create new constructors with different arguments by supplying a different argument list:

public record Person(String name, String address) {
    public Person(String name) {
        this(name, "Unknown");
    }
}

As with class constructors, the fields can be referenced using the this keyword (for example, this.name and this.address), and the arguments match the names of the fields (that is, name and address).

Note that creating a constructor with the same arguments as the generated public constructor is valid, but this requires that each field be manually initialized:

public record Person(String name, String address) {
    public Person(String name, String address) {
        this.name = name;
        this.address = address;
    }
}

Additionally, declaring a compact constructor and one with an argument list matching the generated constructor results in a compilation error.

Therefore, the following won’t compile:

public record Person(String name, String address) {
    public Person {
        Objects.requireNonNull(name);
        Objects.requireNonNull(address);
    }
    
    public Person(String name, String address) {
        this.name = name;
        this.address = address;
    }
}

5. Static Variables & Methods

As with regular Java classes, we can also include static variables and methods in our records.

We declare static variables using the same syntax as a class:

public record Person(String name, String address) {
    public static String UNKNOWN_ADDRESS = "Unknown";
}

Likewise, we declare static methods using the same syntax as a class:

public record Person(String name, String address) {
    public static Person unnamed(String address) {
        return new Person("Unnamed", address);
    }
}

We can then reference both static variables and static methods using the name of the record:

Person.UNKNOWN_ADDRESS
Person.unnamed("100 Linda Ln.");

6. Conclusion

In this article, we examined the record keyword introduced in Java 14, including the fundamental concepts and intricacies.

Using records with their compiler-generated methods, we can reduce boilerplate code and improve the reliability of our immutable classes.

The code and examples for this article can be found over on GitHub.

Course – LS – All

Get started with Spring and Spring Boot, through the Learn Spring course:

>> CHECK OUT THE COURSE
res – REST with Spring (eBook) (everywhere)
Comments are open for 30 days after publishing a post. For any issues past this date, use the Contact form on the site.