Featured resource
pluralsight tech forecast
2025 Tech Forecast

Which technologies will dominate in 2025? And what skills do you need to keep up?

Check it out
Hamburger Icon
  • Labs icon Lab
  • Core Tech
Labs

Guided: Java - Working with Records

In this hands-on lab, you will learn what Java records are: what their purpose is, the main concepts and the syntax. In a series of guided steps you'll learn what records are and how to write code that uses them. After completing this lab you'll be ready to use records in your own Java projects.

Labs

Path Info

Level
Clock icon Intermediate
Duration
Clock icon 1h 38m
Published
Clock icon Sep 07, 2023

Contact sales

By filling out this form and clicking submit, you acknowledge our privacy policy.

Table of Contents

  1. Challenge

    Data Transfer Objects

    Data Transfer Objects

    A common design pattern is the Data Transfer Object (DTO). The most straightforward way to define a DTO in Java is by creating a class with fields and corresponding getter and setter methods, and optionally equals(), hashCode() and toString() methods.

    Let's start by defining a simple DTO in this way.

  2. Challenge

    Immutability

    Immutability

    It is often a good idea to make Data Transfer Objects immutable. This means that you define the class in such a way that the content of the object cannot be modified after it has been created.

    This has a number of advantages, for example:

    • It makes programs easier to understand and helps you to avoid bugs.
    • Immutable objects can safely be cached and reused.
    • Immutable objects are thread-safe; they can be used across multiple threads without synchronization.
    • Hash-based collections such as HashMap expect that the hash code of objects used as keys does not change, which is automatically the case for immutable objects.

    The JDK contains a number of immutable classes. For example, class String, the wrapper classes for primitive types (Integer, Long etc.) and the classes in the package java.time are all immutable classes.

    There are a number of steps to make a Java class immutable:

    • Make the fields of the class final, so that they cannot be modified after the object is initialized in the constructor.
    • Provide a constructor that takes an argument for each field and that initializes the fields using these arguments.
    • Do not write setter methods.
    • Make the class itself final, so that it's not possible to make a mutable subclass.

    Let's change class Customer to make it immutable.

  3. Challenge

    Records

    Records

    As you have experienced in the previous steps, creating an immutable DTO with a regular class in Java is quite verbose and there are a number of things you need to remember to make sure that your class is really immutable. Records are a way to make this easier, with a more concise syntax and rules to ensure immutability.

    To define a record, use the keyword record instead of class. Instead of declaring fields, a constructor and getter methods, list the fields directly after the name of the record, between parentheses. Here is an example:

    import java.math.BigDecimal;
    import java.util.Currency;
    
    public record Amount(Currency currency, BigDecimal value) {
    }
    

    This is like defining a regular class with two final fields, a constructor that takes two arguments to initialize the fields and two getter methods for the fields.

    Let's define the Customer DTO as a record instead of as a class.

  4. Challenge

    Instantiating Records and Accessing Members

    Instantiating Records and Accessing Members

    Records are really just normal Java classes, defined with an alternative syntax. The compiler will generate a regular Java class for a record and it will automatically generate the following:

    • A private final field for each of the components of the record.
    • A constructor that takes an argument for each of the components and that initializes all the components with the argument values by assignment.
    • An accessor method for each component (similar to a getter method; see below).
    • equals() and hashCode() methods based on all the components of the record.
    • A toString() method.

    Because a record is really just a class, creating an instance of a record works exactly the same a creating an instance of a class, by using new. Here is an example:

    var amount = new Amount(Currency.getInstance("USD", new BigDecimal("89.95")));
    

    To access the components of a record, call the appropriate accessor method, which is a method that has the same name as its corresponding component. Note that the names of accessor methods do not start with get. For example:

    var currency = amount.currency(); // Note: currency(), not getCurrency()
    var value = amount.value();       // Note: value(), not getValue()
    

    Let's practice with this using our Customer record.

  5. Challenge

    The Canonical Constructor

    The Canonical Constructor

    The constructor that the compiler automatically generates for a record is called the canonical constructor. The parameter list of the canonical constructor matches the list of components of the record - it takes an argument for each component. What the automatically generated canonical constructor does is initialize each field by assigning the corresponding argument to it.

    You can choose to define the canonical constructor yourself. When you do this, the compiler will not automatically generate a constructor; the one you've defined yourself will be used instead.

    There are two main reasons why you might want to define the canonical constructor yourself:

    • To validate argument values.
    • To make a defensive copies of mutable argument objects. (We'll come back to this later).

    The syntax for defining your own canonical constructor is the same as in a regular class. Here is an example:

    import java.math.BigDecimal;
    import java.util.Currency;
    
    public record Amount(Currency currency, BigDecimal value) {
    
        public Amount(Currency currency, BigDecimal value) {
            // Validate argument values
            if (currency == null) throw new IllegalArgumentException("currency must not be null");
            if (value == null) throw new IllegalArgumentException("value must not be null");
    
            // Initialize fields
            this.currency = currency;
            this.value = value;
        }
    }
    

    Let's write our own canonical constructor for record Customer to validate values.

  6. Challenge

    The Compact Constructor

    The Compact Constructor

    Take a look again at how you define a canonical constructor yourself in a record.

    import java.math.BigDecimal;
    import java.util.Currency;
    
    public record Amount(Currency currency, BigDecimal value) {
    
        public Amount(Currency currency, BigDecimal value) {
            // Validate argument values
            if (currency == null)
                throw new IllegalArgumentException("currency must not be null");
            if (value == null)
                throw new IllegalArgumentException("value must not be null");
    
            // Initialize fields
            this.currency = currency;
            this.value = value;
        }
    }
    

    This syntax is more verbose than it needs to be. There are two issues:

    • We are repeating the list of components as the parameter list of the constructor.
    • We are explicitly assigning the argument values to the fields.

    The point of records is to provide you with a concise syntax to define immutable DTOs, yet when we define a constructor, it quickly becomes verbose again.

    To stay with the spirit of concise syntax, Java provides an alternative syntax to define the canonical constructor of a record, which is the compact constructor syntax. It looks like this:

    import java.math.BigDecimal;
    import java.util.Currency;
    
    public record Amount(Currency currency, BigDecimal value) {
    
        public Amount {
            // Validate argument values
            if (currency == null)
                throw new IllegalArgumentException("currency must not be null");
            if (value == null)
                throw new IllegalArgumentException("value must not be null");
        }
    }
    

    This tackles exactly the two points mentioned above by doing the following:

    • Specify the constructor without a parameter list.
    • Don't initialize the fields in the constructor. The compiler will automatically generate code to do this.

    Of course, in a compact constructor, you can still validate argument values.

    Let's apply this to record Customer.

  7. Challenge

    Additional Constructors

    Additional Constructors

    Just like a regular class can have multiple constructors with different parameter lists, a record can have more than one constructor. However, there is a special rule that you will have to keep in mind when defining an additional constructor for a record.

    The rule is this: Any constructor other than the canonical constructor must have a this(...) statement to call one of the other constructors.

    The consequence of this rule is that when you instantiate a record, ultimately the canonical constructor will always be called first, which initializes the fields of the record.

    Let's add an additional constructor to record Customer.

  8. Challenge

    Automatically Generated Methods

    Automatically Generated Methods

    The compiler automatically generates toString(), equals() and hashCode() methods for records.

    The automatically generated toString() method returns a string that contains the values of the components of the record, mainly useful for logging and debugging.

    The equals() method compares two instances of the record by calling equals() on all the components (considering the record instances equal if all components are equal), and the hashCode() method computes a hash code based on all the components.

    It's possible to implement these methods manually. If you do that, your own implementation will replace the version that the compiler would generate automatically.

    Let's call the toString() method on Customer to see what the automatically generated toString() method returns.

  9. Challenge

    Shallow Immutability

    Shallow Immutability

    One thing that is important to keep in mind when working with records is that they are only shallowly immutable.

    When you use a mutable object as a component for your record, then your record is not really immutable. You will need to take extra steps to make sure that instances of your record cannot be modified after being created.

    A common use case where this is important is if your record has a component that is for example a java.util.List. Java's standard collection classes are mutable.

    To make sure that instances of your record are unmodifiable, you must make a defensive copy of the argument in the constructor of the record.

    Here is an example:

    import java.util.List;
    
    public record KeyValues(String key, List<String> values) {
    
        public KeyValues {
            // Make a defensive, unmodifiable copy of the argument list
            values = List.copyOf(values);
        }
    }
    

    Here, we have implemented the canonical constructor of record KeyValues using the compact constructor syntax.

    Inside the constructor, we make a defensive copy of the list that is passed as an argument. Note that List.copyOf(...) creates an unmodifiable copy of the list.

    Also, we are re-assigning the return value of List.copyOf(...) back to the argument variable itself. Remember that when you use the compact constructor syntax, the compiler will automatically generate assignment statements to initialize the components of the record with the argument values.

    Let's continue with record Customer to see this in action.

  10. Challenge

    Implementing Interfaces

    Implementing Interfaces

    A record cannot extend a superclass, but it can implement interfaces. The way this works is the same as for regular classes.

    Let's try this with record Customer.

  11. Challenge

    Builders for Records

    Builders for Records

    A common design pattern when you work with immutable DTOs is the builder pattern.

    You create a special class, the builder, which contains methods to gather the values for the fields which can be used to create an immutable object using these values.

    Advantages of the builder pattern are:

    • When you call the methods of builder to supply values, it's very clear which value means what. Compare this to creating an object by calling a constructor; only the position of the values in the argument list tells you which value is which.
    • It is more flexible. You don't need to supply all values to the builder; the builder can decide to use a default if you don't supply a value. You don't need to implement multiple constructors to account for all possible combinations.

    A disadvantage is that requires you to write extra code for the builder itself.

    Take a look at record Customer for this step. Included in it is a Builder as a static nested class.

  12. Challenge

    Summary

    Summary

    Congratulations! You've learned what records are in Java and how to work with them. We've covered the following topics:

    • The most common use case for records is to define immutable Data Transfer Objects.
    • A record is just like a class, but defined with a different syntax.
    • The compiler automatically generates an accessor method for each record component, that has the same name as the component (it is not named get...()).
    • The automatically generated canonical constructor initializes all the components of the record.
    • You can choose to implement the canonical constructor yourself to validate arguments and make defensive copies of mutable arguments.
    • You can use the compact constructor syntax to keep your code mode concise.
    • You can define additional constructors, but any additional constructor must call another constructor with a this(...) call.
    • Records are only shallowly immutable. You must make defensive, unmodifiable copies of mutable objects.
    • Records can implement interfaces, just like regular classes.
    • The builder pattern is a useful design pattern when working with records.

Jesper de Jong is an independent, experienced software developer and architect who designs and builds efficient, scalable, and high-quality server-side software for the JVM in Java and Scala. He loves the creativity of inventing and building software systems and loves to teach and share his knowledge with the software development community.

What's a lab?

Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.

Provided environment for hands-on practice

We will provide the credentials and environment necessary for you to practice right within your browser.

Guided walkthrough

Follow along with the author’s guided walkthrough and build something new in your provided environment!

Did you know?

On average, you retain 75% more of your learning if you get time for practice.