Skip to main content

Command Palette

Search for a command to run...

Records in java : the illusion of immutability

Published
5 min read
Records in java :  the illusion of immutability
M

Moroccan software developer, Java/Spring. Love to learn, eager to write.

Java records, as I mentioned in my last article, offer a new way to define classes that hold immutable data. Records make it easier to create data carriers with fixed states at creation. However, this immutability can be misleading when records have fields with mutable objects.

While you can't reassign the record's fields, the objects they reference, like mutable collections, can still be modified. This creates an illusion of immutability—where the record's structure looks immutable, but its contents can still change, going against the main idea of immutability that records aim to promote.

In this article, we'll look at why Java records might not be as immutable as they seem, especially with mutable collections, and discuss ways to handle this issue.

Understanding Java Records: A Quick Recap

At its core, a record is a special kind of class in Java:

public record User(String name) {}

This User record automatically generates the following:

  • A constructor to initialize the field name.

  • Accessor method name().

  • equals(), hashCode(), and toString() methods.

You might think that since the fields are final, the object is fully immutable. However, immutability only applies to the references stored in those fields, not to the objects they point to.

The Problem: Mutable Objects

Let’s extend our User example to include a List<String> to store phone numbers:

import java.util.List;

public record User(String name, List<String> phoneNumbers) {}

On the surface, this seems immutable because we can’t reassign phoneNumbers to a different list. But the real problem lies in the fact that List<String> itself is mutable/immutable depending on the implementation we choose to use, for demonstration purpuses, we will be using the ArrayList (a mutable collection) implementation:

public class Main {
    public static void main(String[] args) {
        List<String> phoneNumbers = new ArrayList<>();
        phoneNumbers.add("+33 613-56-91-91");

        var user = new User("John", phoneNumbers);
        System.out.println(person.phoneNumbers()); // [+33 613-56-91-91]

        // Mutating the list from outside the 
        user.phoneNumbers().add("+33 615-25-40-49");
        System.out.println(user.phoneNumbers()); // [+33 613-56-91-91, +33 615-25-40-49]
    }
}

The record guarantees that the reference to phoneNumbers is immutable, but the contents of the list can still be changed. This breaks the illusion of immutability, as the User object can be modified indirectly.

To illustrate the immutability of the reference, let’s take a look at this code :

public static void main(String[] args) {
        List<String> phoneNumbers = new ArrayList<>();
        phoneNumbers.add("+33 613-56-91-91");

        var user = new User("John", phoneNumbers);

        // error: cannot assign a value to final variable phoneNumbers
        user.phoneNumbers = new ArrayList<>(user.phoneNumbers());
    }

Why Does This Happen?

Java’s immutability for records only applies to fields themselves, not to the objects those fields reference. Since List is mutable, we can modify its contents even though the reference itself is final. This applies to any mutable Object.

The mutability issue arises because collections in Java, by default, do not enforce immutability. While you can't reassign a new list to phoneNumbers in the User record, you can modify the list itself.

Alternatives: Enforcing Immutability

To preserve true immutability with mutable Collections inside a record, you have a few options:

1. Defensive Copying

One common approach is to make a defensive copy of the collection during record initialization. This ensures that even if the original collection is mutable, the copy stored in the record is isolated from external modifications. This can be achieved by adding the following code to the canonical constructor of the User record.

import java.util.List;
import java.util.ArrayList;
import java.util.Collections;

public record User(String name, List<String> phoneNumbers) {
    public User{
        // Defensive copy
        phoneNumbers = List.copyOf(phoneNumbers);
    }
}

In this example, List.copyOf(addresses) creates an unmodifiable copy of the input list. Any attempts to modify this copy will result in UnsupportedOperationException.

2. Using Unmodifiable Collections

If you’re dealing with a collection that should never be modified, you can use Java's unmodifiable collections:

javaCopy codeimport java.util.List;
import java.util.Collections;

public record Person(String name, int age, List<String> addresses) {
    public Person {
        // Wrapping the list as unmodifiable
        addresses = Collections.unmodifiableList(new ArrayList<>(addresses));
    }
}

Here, Collections.unmodifiableList() returns a read-only view of the list. While you can still pass a mutable list into the record, the record itself will only expose an unmodifiable version, preventing external changes.

3. Generic alternative :

If you need to maintain mutability outside of the record—meaning that the objects returned by the getters are still mutable but cannot affect the record’s internal state—you can achieve this by making defensive copies both during the record's initialization and in the getter method. This approach ensures that any mutations performed on the returned object don't affect the record’s internal data. Let’s look at the updated User record code :

public record User(String name, List<String> phoneNumbers) {

    // Defensive copy in the constructor
    public User {
        // Create a defensive copy to prevent external modifications
        addresses = new ArrayList<>(addresses);
    }

    // Defensive copy in the getter
    @Override
    public List<String> addresses() {
        // Return a mutable copy of the addresses
        return new ArrayList<>(addresses);
    }
}

Now let’s run our code :

public class Main {
    public static void main(String[] args) {
        List<String> phoneNumbers = new ArrayList<>();
        phoneNumbers.add("+33 613-56-91-91");

        var user = new User("John", phoneNumbers);
        System.out.println(person.phoneNumbers()); // [+33 613-56-91-91]

        // Mutating the list from outside the record
        user.phoneNumbers().add("+33 615-25-40-49");
        System.out.println(user.phoneNumbers()); // [+33 613-56-91-91]
    }
}

Conclusion

While Java records are designed to simplify the creation of immutable data classes, the immutability they promise can be misleading when dealing with mutable objects. Since objects like ArrayList remain mutable, they can undermine the immutability of records.

To address this, you can adopt several strategies:

  • Defensive copying.

  • Using unmodifiable collections provided by Java’s Collections utility class (Collection specific sollution).

  • Returning a copy of the object.

By incorporating these approaches, you can ensure that your Java records live up to the promise of immutability, even when mutable Objects are involved.

More from this blog

Loukmane's articles

9 posts

Software Developer @MyTower. Crafting clean code by day, iced coffee addict by choice. Casual gamer and chess enjoyer ♟️💻☕. Always learning, always coding.