Java Records, tutorial and code examples

Updated:

Records, great as DTO but it's not a replacement for Lombok

Available in Java 17 LTS, Java Records were initially introduced in Java 14 as a new feature.

Here you can find the official proposal: JEP 359 Records.

The goal is to have a 'data carrier' class without the traditional 'Java ceremony' (boilerplate).

In other words, a record represents an immutable state. Record like Enum is a restricted form of class.

Examples:

Use Cases

A typical use case is to return multiple values from a method and replace some data structures and external frameworks used to fill this missing feature, e.g. tuples, Pair, Map.Entry.
DTO are another candidate to profit from records.

Records are not intended to replace (mutable) data objects or third libraries like Lombok.

Benefits

  • equals(), hashCode(), toString(), constructor() and read accessors are generated for you
  • interfaces can be implemented

Restrictions

  • a record cannot be extended, it’s a final class
  • a record cannot extend a class
  • the value (reference) of a field is final and cannot be changed

Record definition

Java record definition

The body is optional. The state description declares the components of the record.

This simple line of code is translated by the compiler in a class similar to this one:

public final class Person extends Record { 
  public final String name; 
  public final Integer yearOfBirth; 
  
  public Person(String name, Integer yearOfBirth) { 
    this.name = name; 
    this.yearOfBirth = yearOfBirth; 
  } 
   
  public String name() { 
      return name; 
  } 
 
    public Integer yearOfBirth() { 
    return yearOfBirth; 
  }; 
  
  public final int hashCode() { /* implementation according to spec */} 
  public final boolean equals(Object o) { /* implementation according t 

Beware

If the fields contain objects only the reference is immutable.
The referenced objects can change their value compromising the state of the record.
For this reason, you should use immutable objects in your record to avoid surprises.

Examples

How to use them

To execute the examples you can use JShell with the flag --enable-preview
or compiling your source using the flags javac --enable-preview --release 14 [source].java
and executing it using java --enable-preview [mainclass].

If you are using a single file program you need the source flag: java --enable-preview --source 14 [source].java

The code in this post has been tested with JShell and IntelliJ (EAP) using OpenJDK build 14-ea+32-1423.

minimalistic example, an 'empty' record

record Person(){} 

This is a minimalistic valid record without components.

var marco = new Person(); 
var jon = new Person(); 
 
marco.hashCode(); // => 0 
 
marco.equals(jon) // => true, the objects have the same field content 
marco == jon // => false, the objects have different references 
 
marco.toString() // => "Person[]", toString() is implemented by record. A default result for a standard class would have been something like: "Person2@573fd745" 

record with a single argument

record Person(String name){} 

In this example we add an argument (component) to the new record.

Java adds the private field (final String name;)
and the accessor (public String name() {return this.name;}) to the class and implements toString(), equals() and the constructor Person(String name) {this.name = name}.

Example records for Person class
// a new constructor is generated a mandatory parameter 'name'  
var marco = new Person("marco"); 
 
// the default no parameter constructor is not generated 
var andy = new Person(); // => constructor Person in record Person cannot be applied to given types; required: java.lang.String 
 
// to read the value of the name we access the field  
marco.name() // => "marco", no 'get' here! 
marco.toString() // => "Person[name=marco]" 
marco.hashCode() // => 103666250 
 
marco.equals(new Person("andy")); // => false 
 
// if we try to modify the state 
marco.name = "andy"; // => Error: name has private access in Person 
marco.setName("andy"); // => Error: cannot find symbol 

Noteworthy here:

  • the fields are private and final
  • an accessor is created for the fields without the traditional bean notation 'get'.

implementing an interface in a record

records can implement an interface, here an example:

interface Person { 
  String getFullName(); 
} 
 
record Developer(String firstName, String lastName) implements Person { 
  public String getFullName() { 
    return firstName + " " + lastName; 
  } 
} 
 
var marco = new Developer("Marco", "Molteni"); // => marco ==> Developer[firstName=Marco, lastName=Molteni] 
marco.getFullName(); // =>  "Marco Molteni" 
Java records interface example

implementing multiple constructors

records implements a constructor with the fields declared as parameters.

record Person(String name, Integer age){} 

This code generates something like:

public final class Person extends Record { 
  private final String name; 
  private final Integer age; 
   
  // constructor generated 
  public Person(String name, Integer age) { 
    this.name = name; 
    this.age = age; 
  } 
  // accessors 
  public String name() {return name;} 
  public String age() {return age;} 
   
  // ... other methods 
 
} 

If you try to instantiate the record without the two parameters an exception is thrown:

var marco = new Person(); 
constructor Person in record Person cannot be applied to given types; 
|    required: java.lang.String,java.lang.Integer 
|    found:    no arguments 
|    reason: actual and formal argument lists differ in length 
|  var marco = new Person(); 

You can add your own constructor if needed, e.g. not all the parameters are required.

record Person(String name, Integer age){ 
  public Person() { 
    this("unknown", null); 
  } 
} 

In this case you can instantiate an object using the extra constructor:

var marco = new Person(); // => marco ==> Person[name=unknown, age=null] 

mutating the state

In this example I show how it's possible to mutate the values inside a record.
The types used in a record should be immutable to be sure that the state won't change.

record Developer (String name, List<String> languages){} 
 
List<String> languages = new ArrayList<String>(Arrays.asList("Java")); 
languages; // ==> [Java]; 
 
var marco = new Developer("marco", languages); // marco ==> Developer[name=marco, languages=[Java]] 
marco.languages(); // => [Java] 
marco.hashCode(); // => -1079012009 
 
// we add one value to the array referenced in the record 
languages.add("JavaScript"); 
 
// the value of the record changes 
marco.languages(); // => [Java, JavaScript] 
marco.hashCode(); () // => 256362082 
 
// the Array list is mutable 
marco.languages().remove(1); 
marco.languages(); // => [Java] 
marco.hashCode(); // => -1079012009 

redefining an accessor

When you declare a record you can redefine an accessor method. This is useful if you need to add annotations or modify the standard behaviour.

record Developer(String name){ 
  // we modify the default accessor 
  public String name() { 
  // this.name refers to the field generated by record   
  return "Hi " + this.name; 
  } 
} 
 
var bill = new Developer("William"); 
bill.name(); // => "Hi William" 

annotations

Records components support annotation. The annotation requires has to declare RECORD_COMPONENT as target.

@Retention(RetentionPolicy.RUNTIME) 
@Target(ElementType.RECORD_COMPONENT) 
public @interface RecordExample { 
} 
 
record Person(@RecordExample String name){} 

Fullstack Angular / Java application quick start guide.
WebApp built by Marco using SpringBoot 3.2.4 and Java 21. Hosted in Switzerland (GE8).