Blog

How to use Java Records

18 Nov, 2022
Xebia Background Header Wave

Records in Java (3 part series)

  1. What are Java Records?
  2. How to use Java Records
  3. Java Records as Data Transfer Objects (upcoming)

Table of Contents

Creating and using records

Using a record is almost exactly like using an immutable POJO.
Creating one is done using the constructor:

record Customer(UUID id, String name) {}

var customer = new Customer(UUID.randomUUID(), "John");

Retrieving the data is still done using getter methods, which have the same name as the record components.
Note that JavaBean conventions are not used, so a getter is called x() and not getX():

var name = customer.name();

Documentation

Documentation for records can be added like you would add it to a class. A component can be documented using the existing @param javadoc tag:

/**
* Documentation of the Customer class.
* @param id customer id
* @param name customer name
*/
record Customer(UUID id, String name) {}

Default values

Records can have additional constructors, next to their canonical constructor that has the same parameters as the components.
This can be useful to set default values for some components.

record Customer(UUID id, String name) {
    /** Create a new customer with a fresh id. */
    public Customer(String name) {
        this(UUID.randomUUID(), name);
    }
}

var customer = new Customer("John");
UUID generated = customer.id();

Validation

Often, you do not want to allow all values in a record component, but want to restrict them to what makes sense in the context of what your record represents.

Records provide a special construct called a compact constructor to facilitate this.
They work similar to a normal constructors, but you don’t need to specify parameters or set the components:

record Customer(UUID id, String name) {
    public Customer {
        if(name.isBlank()) {
          throw new IllegalArgumentException("name cannot be empty.");
        }
    }
}

// This will throw an IllegalArgumentException 
var invalidCustomer = new Customer(UUID.randomUUID(), " ");

Normalization

Besides validation, you can also modify data in a compact constructor.
This is useful to normalize your data:

record Customer(UUID id, String name) {
    Customer {
        name = name.trim();
    }
}

Customer customer = new Customer(UUID.randomUUID(), "John \n");
// This will be "John"
String name = customer.name();

Modifying records

Unfortunately records are not as easy to modify as their equivalents in other languages.
There are currently two viable options to modify records. A future version of Java might support the use case better.

Option 1 – Manually add wither methods

In plain Java, you can manually specify a method that returns a modified copy.
The most common naming convention for this is withX, hence the name wither methods.

record Customer(UUID id, String name) {
    Customer withName(String name) {
        return new Customer(id, name);
    }
}

var customer = new Customer(UUID.randomUUID(), "John");
var renamed = customer.withName("John Doe");

Option 2 – Use a compiler plugin

The above is not particularly user-friendly.
Luckily compiler plugins can provide the missing feature, most notably RecordBuilder:

@RecordBuilder
record Customer(UUID id, String name) {}

var customer = new Customer(UUID.randomUUID(), "John");
var renamed = customer.withName("John Doe");

Enforcing non-null

A special kind of validation is enforcing that record fields are not null. (Un)fortunately, records do not have any special behavior regarding nullability.
You can use tools like NullAway or Error Prone to prevent null in your code in general, or you can add checks to your records:

record Customer(UUID id, String name) {
    Customer {
        Objects.requireNonNull(id, "id cannot be null");
        Objects.requireNonNull(name, "name cannot be null");
    }
}

Derived data

Sometimes, you need to use the primary pieces of data in a record to derive another piece of data. Just like with POJOs, you can simply add a method:

record Customer(String firstName, String lastName) {
    public String fullName() {
        return String.format("%s %s", firstName, lastName);
    }
}

Just like with POJOs, these values are lazily calculated and not cached.
Unlike with POJOs, you cannot make this eager and cached by adding a field, because records are not allowed to have fields:

record Customer(String firstName, String lastName) {
    // This will give a compile error, records are not allowed to have fields
    private final String fullName = String.format("%s %s", firstName, lastName)
}

An alternative is to use validation and add the field as a record component:

record Customer(String firstName, String lastName, String fullName) {
    public Customer {
      fullName = String.format("%s %s", firstName, lastName);
    }

    public Customer(String firstName, String lastName) {
        this(firstName, lastName, null);
    }
}

var customer = new Customer("John", "Doe");
// This will be "John Doe", and is already computed and cached
var fullName = customer1.fullName();

However, be careful with doing this, as it might be surprising to users of the record. In general, I would recommend using a POJO with a private final field instead.

var customer = new Customer("John", "Doe", "Austin Powers");
// This will unexpectedly be "John Doe" instead of "Austin Powers"
var fullName = customer.fullName();
Questions?

Get in touch with us to learn more about the subject and related solutions

Explore related posts