How to use Java Records
David Hoepelman
Posted on November 18, 2022
Table of Contents
- Creating and using records
- Documentation
- Default Values
- Validation
- Normalization
- Modifying records
- Enforcing non-null
- Derived data
- Data Transfer Objects
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();
Data Transfer Objects
Another important use case for records is to transfer data with a REST API or to and from the database.
For JSON this is often done with the Jackson library.
Continue on to part 3: Records and Jackson.
Posted on November 18, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.