Java Records

Java records, introduced as a preview feature in Java 14 [JEP-359] and finalized in Java 16 [JEP-395], act as transparent carriers for immutable data. Conceptually, records can be thought of as tuples that are already available via 3rd party libraries. Though, records are built in type in Java so they provide a more extended use and compatibility with other features in Java such as pattern matching with instanceof and switch case.

1. What is a Record?

Like enum, a record is also a special class type in Java. Records are intended to be used in places where a class is created only to act as a plain data carrier.

The important difference between class and record is that a record aims to eliminate all the boilerplate code needed to set and get the data from the instance. Records transfer this responsibility to the Java compiler, which generates the constructor, field getters, hashCode() and equals() as well toString() methods.

Note that we can override any of the default provided above methods in record definition to implement custom behavior.

2. How to Create a Record?

We need to use the keyword record to create such record class in Java. Just like we do in constructors, we need to mention the attributes and their types in the record.

In the following example, Employee is a record type and is used to hold employee information:

public record Employee(Long id, String firstName, String lastName, String email, int age) { }

To create a record, we call its constructor and pass all the field information in it. We can then get the record information using JVM-generated getter methods and call any of generated methods.

Employee e = new Employee(1l, "Lokesh", "Gupta", "howtodoinjava@gmail.com", 38);

System.out.println( e.id() );     //1
System.out.println( e.email() );  //howtodoinjava@gmail.com
System.out.println( e ); //Employee[id=1, firstName=Lokesh, ...]

In the above example, when we create Employee record, the compiler creates bytes code and includes the following in the generated class file:

  • An all-arguments constructor accepting all fields.
  • The toString() method that prints the state/values of all fields in the record.
  • The equals() and hashCode() methods using an invokedynamic based mechanism.
  • The getter methods whose names are similar to field names without the usual POJO/JavaBean get prefix i.e. id(), firstName(), lastName(), email() and age(). These methods can be overridden to perform defensive copies when needed. The only constraint is that Record::equals rules must not conflict.
  • The class extends java.lang.Record, which is the base class for all records. It means a record cannot extend the other classes.
  • The class is marked final, so we cannot create a subclass.
  • It does not have any setter method, meaning a record instance is designed to be immutable.

If we run the javap tool on generated class file, we will see the class file:

public final class com.howtodoinjava.core.basic.Employee extends java.lang.Record {
  //1
  public com.howtodoinjava.core.basic.Employee(java.lang.Long, java.lang.String, java.lang.String, java.lang.String, int);

  //2
  public java.lang.String toString();

  //3
  public final int hashCode();
  public final boolean equals(java.lang.Object);

  //4
  public java.lang.Long id();
  public java.lang.String firstName();
  public java.lang.String lastName();
  public java.lang.String email();
  public int age();
}

3. Canonical, Compact, and Custom Constructors in Java Records

JVM provides an all-arguments constructor by default that assigns its arguments to the corresponding fields. It is called the canonical constructor. Like accessors methods, we can override this constructor and add custom logic such as data validation. It helps build a valid record in a given business context.

public record Employee(Long id, String firstName, String lastName, String email, int age) {

  public Employee(Long id, String firstName, String lastName, String email, int age) {
    Objects.requireNonNull(id);
    Objects.requireNonNull(email);

    if (age < 18) {
      throw new IllegalArgumentException("You cannot hire a minor as employee");
    }
  }

  //Other methods
}

The canonical constructor is again a boilerplate that we can avoid using the compact constructor. In compact constructors, we omit all arguments, including the parentheses. The components will be assigned to their respective fields automatically.

We can rewrite the above canonical constructor in a more compact form:

public record Employee(Long id, String firstName, String lastName, String email, int age) {

  public Employee {
    Objects.requireNonNull(id);
    Objects.requireNonNull(email);

    if (age < 18) {
      throw new IllegalArgumentException("You cannot hire a minor person as employee");
    }
  }

  //Other methods
}

A ‘compact constructor does not cause a separate constructor to be generated by the compiler. Instead, the code that you specify in the compact constructor appears as extra code at the start of the canonical constructor. We do not need to specify the assignment of constructor parameters to fields as it happens in the canonical constructor in the usual manner.

Like with classes, we can declare additional custom constructors, but any custom constructor must start with an explicit invocation of the canonical constructor as its first statement. It helps set the default values of all components not present in the new custom constructor.

public record Employee(Long id, String firstName, String lastName, String email, int age) {

  public Employee(Long id, String firstName, String lastName){
    this(id, firstName, lastName, "nobody@domain.com", 18);
    
    //More code
  }

  //Other methods
}

4. Adding Fields and Methods to Records

Adding a new field and method is possible, but not recommended. A new field added to record (not added to the component list) must be static. Adding a method is also possible that can access the internal state of record fields.

The added fields and methods are not used in implicitly generated byte code by the compiler, so they are not part of any method implementation such as equals(), hashCode() or toString(). We must use them explicitly as required.

public record Employee(Long id, String firstName, String lastName, String email, int age) {

  static boolean minor;

  public boolean isMinor() {
    return minor;
  }

  public String fullName() {
    return firstName + " " + lastName;
  }

  public Employee {
    if (age < 18) {
      minor = true;
    }
  }
}

Now we can access the information similar to other Java classes:

Employee employee = new Employee(1l, "Amit", "Gupta", "email@domain.com", 17);

System.out.println(employee.isMinor());   //true
System.out.println(employee.fullName());  //Amit Gupta

5. Records with Generics

Record types support generics, similar to other types in Java. A typical example of generic record is as follows;

record Container<T>(int id, T value) {
}

We can use this record as follows to support multiple types:

Container<Integer> intContainer = new Container<>(1, Integer.valueOf(1));
Container<String> stringContainer = new Container<>(1, "1");

Integer intValue = intContainer.value();
String strValue = stringContainer.value();

6. Serialization and Deserialization with Java Records

Java serialization of records is different than it is for regular classes. The serialized form of a record object is a sequence of values derived from the final instance fields of the object. The stream format of a record object is the same as that of an ordinary object in the stream.

During deserialization, if the local class equivalent of the specified stream class descriptor is a record class, then first, the stream fields are read and reconstructed to serve as the record’s component values. Second, a record object is created by invoking the record’s canonical constructor with the component values as arguments (or the default value for the component’s type if a component value is absent from the stream).

The serialVersionUID of a record class is 0L unless it is explicitly declared. The requirement for matching serialVersionUID values is also waived for record classes.

The process by which record objects are serialized cannot be customized; any class-specific writeObject, readObject, readObjectNoData, readResolve, writeExternal, and readExternal methods defined by record classes are ignored during serialization and deserialization. However, the writeReplace method may be used to return a substitute object to be serialized.

Before performing any serialization or deserialization, we must ensure that record must be either serializable or externalizable.

public record Employee (...) implements Serializable { }
Employee e = new Employee(1l, "Lokesh", "Gupta", "howtodoinjava@gmail.com", 38);

writeToFile(e, "employee1");
Employee temp =  readFromFile("employee1");

static void writeToFile(Employee obj, String path) {
  try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(path))) {
    oos.writeObject(obj);
  } catch (IOException e) {
    e.printStackTrace();
  }
}

static Employee readFromFile(String path) {
  Employee result = null;
  try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(path))) {
    result = (Employee) ois.readObject();
  } catch (ClassNotFoundException | IOException e) {
    e.printStackTrace();
  }
  return result;
}

7. Technical Deep Dive

7.1. invokedynamic

If we see the bytecode generated by the Java compiler to check the method implementations of toString() (as well as equals() and hashCode()), they are implemented using an invokedynamic based mechanism.

invokedynamic is a bytecode instruction that facilitates the implementation of dynamic languages (for the JVM) through dynamic method invocation.

7.2. We cannot extend a Record class, explicitly

Though all records extend java.lang.Record class, still we cannot create a subclass of java.lang.Record explicitly. The compiler will not pass through.

final class Data extends Record {   // Compiler error : The type Data may not subclass Record explicitly
	private final int unit;
}

This means that the only way to get a record is to declare one explicitly and have javac create the class file.

7.3. Using Annotations in Records

We can add annotations to the components of a record. For example, we can apply @Transient annotation to the id field.

public record Employee(
		@Transient Long id,
		String firstName,
		String lastName,
		String email,
		int age) {
	// ...
}

Although keep in mind that records have automagically generated fields and component accessors. To support annotating these features, any annotations with the targets FIELDPARAMETER, or METHOD, are propagated to the corresponding locations if applied to a component.

In addition to the existing targets, the new target ElementType.RECORD_COMPONENT was introduced for more fine-grained annotation control in Records.

8. JDK Changes

8.1. java.lang.Class

The Class class added two methods – isRecord() and getRecordComponents(). The getRecordComponents() method returns an array of RecordComponent objects. The components are returned in the same order that they are declared in the record header.

RecordComponent is a new class in the java.lang.reflect package with eleven methods for retrieving things such as the details of annotations and the generic type.

8.2. ElementType enumeration

The ElementType enumeration has a new constant for records, RECORD_COMPONENT.

8.3. javax.lang.model.element

The ElementKind enumeration has three new constants for the records and pattern matching for instanceof features, namely BINDING_VARIABLE, RECORD and RECORD_COMPONENT.

9. When to Use?

  • Records are ideal candidates when modeling things like domain model classes (potentially to be persisted via ORM), or data transfer objects (DTOs).
  • The records are useful when storing data temporarily. An example can be during JSON deserialization. Generally, we do not expect the program to mutate data read from JSON during deserialization. We just read the data and pass it to the data processor or validator.
  • Also, records are not replacements for mutable Java beans because a record, by design, is immutable.
  • Use records when a class is intended to hold the data for some time, and we want to avoid writing lots of boilerplate code.
  • We can use records in various other situations e.g. hold multiple return values from a method, stream joins, compound keys and data structures such as tree nodes.

10. Conclusion

Java records are an extremely useful feature and a great addition to a Java-type system. It helps cut down boilerplate code written for simple data carrier classes almost completely.

But we should use it carefully and not try to customize its behavior. It is best used in default shape.If you ever start looking at ways to customize the record type in your application, first consider o convert it to the simple class type.

Happy Learning !!

Sourcecode Download

Comments

Subscribe
Notify of
guest
1 Comment
Most Voted
Newest Oldest
Inline Feedbacks
View all comments

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.

Our Blogs

REST API Tutorial

Dark Mode

Dark Mode