DEV Community

Cover image for A Guide to Java Records
Abhinav Pandey
Abhinav Pandey

Posted on • Originally published at abhinavpandey.dev

A Guide to Java Records

In this tutorial, we will cover the basics of how to use records in Java.
Records were introduced in Java 14 as a way to remove boilerplate code around the creation of value objects while incorporating the benefits of immutable objects.

1. Basic Concepts

Before moving on to Records, let's look at the problem Records solve. To understand this, let's examine how value objects were created before Java 14.

1.1. Value Objects

Value objects are an integral part of Java applications. They store data that needs to be transferred between layers of the application.

A value object contains fields, constructors and methods to access those fields.
Below is an example of a value object:

public class Contact {
    private final String name;
    private final String email;

    public Contact(String name, String email) {
        this.name = name;
        this.email = email;
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }
}
Enter fullscreen mode Exit fullscreen mode

1.2. Equality between Value Objects

Additionally, the value objects may provide a way to compare them for equality.

By default, Java compares the equality of objects by comparing their memory address. However, in some cases, objects containing the same data may be considered equal.
To implement this, we can override the equals and hashCode methods.

Let's implement them for the Contact class:

public class Contact {

    // ...

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Contact contact = (Contact) o;
        return Object.equals(email, contact.email) &&
                Objects.equals(name, contact.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, email);
    }
}
Enter fullscreen mode Exit fullscreen mode

1.3. Immutability of Value Objects

Value objects should be immutable. This means that we should restrict ways to change the fields of the object.

This is advisable for the below reasons:

  • To avoid the risk of accidentally changing the value of a field.
  • To make sure equal objects remain equal throughout their lifetime.

The Contact class is already immutable. We have:

  1. Made the fields private and final.
  2. Provided only a getter for each field and no setters.

1.4. Logging Value Objects

We will often need to log the values that the objects contain. This is done by providing a toString method.
Whenever an object is logged or printed, the toString method is called.

The easiest way is to print each field's value. Here is an example:

public class Contact {
    // ...
    @Override
    public String toString() {
        return "Contact[" +
                "name='" + name + '\'' +
                ", email=" + email +
                ']';
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Reducing Boilerplate with Records

Since most value objects have the same needs and functionality, it was a good idea to make the process of creating them easier.
Let's look at how Records achieve this.

2.1. Converting Person Class to a Record

Let's create a record of the Contact class which has the same functionality as the Contact class defined above.

public record Contact(String name, String email) {}
Enter fullscreen mode Exit fullscreen mode

The 'record' keyword is used to create a record class. Records can be treated exactly like a class by a caller.
For e.g, to create a new instance of the record, we can use the new keyword.

Contact contact = new Contact("John Doe", "johnrocks@gmail.com");
Enter fullscreen mode Exit fullscreen mode

2.2. Default Behaviour

We have reduced the code to a single line. Let's list down what this includes:

  1. The name and email fields are private and final by default.
  2. It defines a constructor which takes the fields as parameters.
  3. The fields are accessible via getter-like methods - name() and email(). There is no setter for the fields so the data in the object becomes immutable.
  4. A toString method is implemented to print the fields the same as we did for the Contact class.
  5. The equals and hashCode methods are implemented. They include all the fields just like the Contact class.

3. Working with Records

We may want to change the behavior of the record in multiple ways. Let's look at some use cases and how to achieve them.

3.1. Overriding default implementations

Any default implementation can be changed by overriding it. E.g. if we want to change the behavior of the toString method, we can override it between the braces {}.

public record Contact(String name, String email) {
    @Override
    public String toString() {
        return "Contact[" +
                "name is '" + name + '\'' +
                ", email is" + email +
                ']';
    }
}
Enter fullscreen mode Exit fullscreen mode

Similarly, we can override the equals and hashCode methods as well.

3.2. Compact Constructors

Sometimes, we want constructors to do more than just initialize the fields.
We can add these operations to our record in a compact constructor. It's called compact because it does not need to define the initialization of fields or the parameter list.

public record Contact(String name, String email) {
    public Contact {
        if(!email.contains("@")) {
            throw new IllegalArgumentException("Invalid email");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Note that there is no parameter list and initialization of name and email takes place in the background before the validation is performed.

3.3. Adding Constructors

We can add more constructors to our record. Let's see a few examples and a couple of restrictions.

First, let's add new valid constructors:

public record Contact(String name, String email) {
    public Contact(String email) {
        this("John Doe", email);
    }

    // replaces the default constructor
    public Contact(String name, String email) {
        this.name = name;
        this.email = email;
    }
}
Enter fullscreen mode Exit fullscreen mode

In the first constructor, the default constructor is accessed using the this keyword.
The second constructor overrides the default constructor because it has the same parameter list. In this case, the record will not create a default constructor on its own.

There are a few restrictions on the constructors.

1. The default constructor should always be called from any other constructor.
E.g., the below code will not compile:

public record Contact(String name, String email) {
    public Contact(String name) {
        this.name = "John Doe";
        this.email = null;
    }
}
Enter fullscreen mode Exit fullscreen mode

This rule ensures that fields are always initialized. It also ensures that the operations defined in the compact constructor are always executed.

2. Cannot override the default constructor if a compact constructor is defined.
When a compact constructor is defined, a default constructor is automatically constructed with the initialization and compact constructor logic.
In this case, the compiler won't allow us to define a constructor with the same arguments as the default constructor.

E.g., this won't compile:

public record Contact(String name, String email) {
    public Contact {
        if(!email.contains("@")) {
            throw new IllegalArgumentException("Invalid email");
        }
    }
    public Contact(String name, String email) {
        this.name = name;
        this.email = email;
    }
}
Enter fullscreen mode Exit fullscreen mode

3.4. Implementing Interfaces

Just like any class, we can implement interfaces in our record.

public record Contact(String name, String email) implements Comparable<Contact> {
    @Override
    public int compareTo(Contact o) {
        return name.compareTo(o.name);
    }
}
Enter fullscreen mode Exit fullscreen mode

Important Note: To ensure complete immutability, records are not allowed to participate in inheritance. Records are final and cannot be extended. Nor can they extend other classes.

3.5. Adding Methods

In addition to constructors, overriding methods and implementing interfaces, we can also add any methods we want.

For example:

public record Contact(String name, String email) {
    String printName() {
        return "My name is:" + this.name;
    }
}
Enter fullscreen mode Exit fullscreen mode

We can also add static methods. For example, if we wanted to have a static method that returns the regex against which emails can be validated, we can define it as below:

public record Contact(String name, String email) {
    static Pattern emailRegex() {
        return Pattern.compile("^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$", Pattern.CASE_INSENSITIVE);
    }
}
Enter fullscreen mode Exit fullscreen mode

3.6. Adding Fields

We cannot add instance fields to our record. However, we can add static fields.

public record Contact(String name, String email) {
    private static final Pattern EMAIL_REGEX_PATTERN = Pattern
            .compile("^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$", Pattern.CASE_INSENSITIVE);

    static Pattern emailRegex() {
        return EMAIL_REGEX_PATTERN;
    }
}
Enter fullscreen mode Exit fullscreen mode

Note that there are no implicit restrictions on the visibility of static fields. They can be public if needed and may not be final.

Conclusion

Records are a great way to define data classes. They are a lot more powerful than the JavaBeans/POJO approach.
Because of their ease of implementation, they should be preferred over other approaches for creating value objects.


Thanks for reading. If you have any questions/suggestions, please feel free to mention them in the comments.

If you want to connect with me, you can find me on Twitter

Discussion (0)