Using Record to Model Immutable Data

The Java language gives you several ways to create an immutable class. Probably the most straightforward way is to create a final class with final fields and a constructor to initialize these fields. Here is an example of such a class.

public class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

Now that you have written these elements, you need to add the accessors for your fields. You will also add a toString() method and probably an equals() along with an hashCode() method. Writing all this by hand is quite tedious and error-prone, fortunately, your IDE is there to generate these methods for you.

If you need to carry the instances of this class from one application to another, either by sending them over a network or through a file system, you may also consider making this class serializable. If you do so, you may need to add some information on how the instances of this class are serialized. The JDK gives you several ways of controlling serialization.

In the end, your Point class may be a hundred lines long, mostly populated with code generated by your IDE, just to model an immutable aggregation of two integers that you need to write to a file.

Records have been added to the JDK to change this. Records give you all this with a single line of code. All you need to do is to declare the state of a record; the rest is generated for you by the compiler.

 

Calling Records to the Rescue

Records are here to help you make this code much simpler. Starting with Java SE 14, you can write the following code.

public record Point(int x, int y) {}

This single line of code creates the following elements for you.

  1. It is an immutable class with two fields: x and y, of type int.
  2. It has a canonical constructor, to initialize these two fields.
  3. The toString(), equals() and hashCode() methods have been created for you by the compiler with a default behavior that corresponds to what an IDE would have generated. You can modify this behavior if you need, by adding your own implementations of these methods.
  4. It can implement the Serializable interface, so that you can send instances of Point to other applications over a network or through a file system. The way a record is serialized and deserialized follows some special rules that are covered at the end of this tutorial.

Records are making the creation of immutable aggregates of data much simpler, without the help of any IDE. It reduces the risk of bugs because everytime you modify the components of a record, the compiler automatically updates the equals() and hashCode() methods for you.

 

The Class of a Record

A record is class declared with the record keyword instead of the class keyword. Let us declare the following record.

public record Point(int x, int y) {}

The class that the compiler creates for you when you create a record is final.

This class extends the java.lang.Record class. So your record cannot extend any class.

A record can implement any number of interfaces.

 

Declaring the Components of a Record

The block that immediately follows the name of the record is (int x, int y). It declares the components of the record named Point. For each component of a record, the compiler creates a private final field with the same name as this component. You can have any number of components declared in a record.

In this example, the compiler creates two private final fields of type int: x and y, corresponding to the two components you have declared.

Along with these fields, the compiler generates one accessor for each component. This accessor is a method that has the same name of the component, and returns its value. In the case of this Point record, the two generated methods are the following.

public int x() {
    return this.x;
}

public int y() {
    return this.y;
}

If this implementation works for your application, then you do not need to add anything. You may define your own accessor methods though. It may be useful in the case where you need to return a defensive copy of a particular field.

The last elements generated for you by the compiler are overrides of the toString(), equals() and hashCode() methods from the Object class. You may define your own overrides of these methods if you need.

 

Things you Cannot Add to a Record

There are three things that you cannot add to a record:

  1. You cannot declare any instance field in a record. You cannot add any instance field that would not correspond to a component.
  2. You cannot define any field initializer.
  3. You cannot add any instance initializer.

You can create static fields with initializers and static initializers.

 

Constructing a Record with its Canonical Constructor

The compiler also creates a constructor for you, called the canonical constructor. This constructor takes the components of your record as arguments and copies their values to the fields of the record class.

There are situations where you need to override this default behavior. Let us examine two use cases:

  1. You need to validate the state of your record
  2. You need to make a defensive copy of a mutable component.

 

Using the Compact Constructor

You can use two different syntax to redefine the canonical constructor of a record. You can use a compact constructor or the canonical constructor itself.

Suppose you have the following record.

public record Range(int start, int end) {}

For a record of that name, one could expect that the end is greater that the start. You can add a validation rule by writing the compact constructor in your record.

public record Range(int start, int end) {

    public Range {
        if (end <= start) {
            throw new IllegalArgumentException("End cannot be lesser than start");
        }
    }
}

The compact canonical constructor does not need to declare its block of parameters.

Note that if you choose this syntax, you cannot directly assign the record's fields, for example with this.start = start - that is done for you by code added by the compiler. But you can assign new values to the parameters, which leads to the same result because the compiler-generated code will then assign these new values to the fields.

public Range {
    // set negative start and end to 0
    // by reassigning the compact constructor's
    // implicit parameters
    if (start < 0)
        start = 0;
    if (end < 0)
        end = 0;
}

 

Using the Canonical Constructor

If you prefer the non-compact form, for example because you prefer not to reassign parameters, you can define the canonical constructor yourself, as in the following example.

public record Range(int start, int end) {

    public Range(int start, int end) {
        if (end <= start) {
            throw new IllegalArgumentException("End cannot be lesser than start");
        }
        if (start < 0) {
            this.start = 0;
        } else {
            this.start = start;
        }
        if (end > 100) {
            this.end = 10;
        } else {
            this.end = end;
        }
    }
}

In this case the constructor you write needs to assign values to the fields of your record.

If the components of your record are not immutable, you should consider making defensive copies of them in both the canonical constructor and the accessors.

 

Defining any Constructor

You can also add any constructor to a record, as long as this constructor calls the canonical constructor of your record. The syntax is the same as the classic syntax that calls a constructor with another constructor. As for any class, the call to this() must be the first statement of your constructor.

Let us examine the following State record. It is defined on three components:

  1. the name of this state
  2. the name of the capital of this state
  3. a list of city names, that may be empty.

We need to store a defensive copy of the list of cities, to ensure that it will not be modified from the outside of this record. This can be done by redefining the canonical constructor with a compact form that reassigns the parameter to the defensive copy.

Having a constructor that does not take any city is useful in your application. This can be another constructor, that only takes the state name and the capital city name. This second constructor must call the canonical constructor.

Then, instead of passing a list of cities, you can pass the cities as a vararg. To do that, you can create a third constructor, that must call the canonical constructor with the proper list.

public record State(String name, String capitalCity, List<String> cities) {

    public State {
        // List.copyOf returns an unmodifiable copy,
        // so the list assigned to `cities` can't change anymore
        cities = List.copyOf(cities);
    }

    public State(String name, String capitalCity) {
        this(name, capitalCity, List.of());
    }

    public State(String name, String capitalCity, String... cities) {
        this(name, capitalCity, List.of(cities));
    }

}

Note that the List.copyOf() method does not accept null values in the collection it gets as an argument.

 

Getting the State of a Record

You do not need to add any accessor to a record, because the compiler does that for you. A record has one accessor method per component, which has the name of this component.

The Point record from the first section of this tutorial has two accessor methods: x() and y() that return the value of the corresponding components.

There are cases where you need to define your own accessors, though. For instance, assume the State record from the previous section didn't create an unmodifiable defensive copy of the cities list during construction - then it should do that in the accessor to make sure callers can't mutate its internal state. You can add the following code in your State record to return this defensive copy.

public List<String> cities() {
    return List.copyOf(cities);
}

 

Serializing Records

Records can be serialized and deserialized if your record class implements Serializable. There are restrictions though.

  1. None of the systems you can use to replace the default serialization process are available for records. Creating a writeObject() and readObject() method has no effect, nor implementing Externalizable.
  2. Records can be used as proxy objects to serialize other objects. A readResolve() method can return a record. Adding a writeReplace() in a record is also possible.
  3. Deserializing a record always calls the canonical constructor. So all the validation rules you may add in this constructor will be enforced when deserializing a record.

This makes records a very good choice for creating data transport objects in your application.

 

Using Records in a Real Use Case

Records are a versatile concept that you can use in many contexts.

The first one is to carry data in the object model of your application. You can use records for what they have been designed for: acting as an immutable data carrier.

Because you can declare local records, you can also use them to improve the readability of your code.

Let us consider the following use case. You have two entities modeled as records: City and State.

public record City(String name, State state) {}
public record State(String name) {}

Suppose you have a list of cities, and you need to compute the state that has the greatest number of cities. You can use the Stream API to first build the histogram of the states with the number of cities each one has. This histogram is modeled by a Map.

List<City> cities = List.of();

Map<State, Long> numberOfCitiesPerState =
    cities.stream()
          .collect(Collectors.groupingBy(
                   City::state, Collectors.counting()
          ));

Getting the max of this histogram is the following generic code.

Map.Entry<State, Long> stateWithTheMostCities =
    numberOfCitiesPerState.entrySet().stream()
                          .max(Map.Entry.comparingByValue())
                          .orElseThrow();

This last piece of code is technical; it does not carry any business meanings; because is uses Map.Entry instance to model every element of the histogram.

Using a local record can greatly improve this situation. The following code creates a new record class, that aggregates a state and the number of cities in this state. It has a constructor that takes an instance of Map.Entry as a parameter, to map the stream of key-value pairs to a stream of records.

Because you need to compare these aggregates by the number of cities, you can add a factory method to provide this comparator. The code becomes the following.

record NumberOfCitiesPerState(State state, long numberOfCities) {

    public NumberOfCitiesPerState(Map.Entry<State, Long> entry) {
        this(entry.getKey(), entry.getValue());
    }

    public static Comparator<NumberOfCitiesPerState> comparingByNumberOfCities() {
        return Comparator.comparing(NumberOfCitiesPerState::numberOfCities);
    }
}

NumberOfCitiesPerState stateWithTheMostCities =
    numberOfCitiesPerState.entrySet().stream()
                          .map(NumberOfCitiesPerState::new)
                          .max(NumberOfCitiesPerState.comparingByNumberOfCities())
                          .orElseThrow();

Your code now extracts a max in a meaningful way. Your code is more readable, easier to understand and less error-prone and in the long run easier to maintain.

More Learning

Last update: January 5, 2024


Back to Tutorial List