How to Use Java Records to Model Immutable Data

Anastasios Antoniadis

With the introduction of Java Records (previewed in Java 14 and standardized in Java 16), developers now have a concise and powerful way to represent immutable data. Records were designed to be a shorthand for simple data carriers that just contain data and little to no additional logic. They help reduce boilerplate associated with typical data classes (e.g., classes that need equals, hashCode, and toString methods).

1. What Are Java Records?

A Java Record is a special kind of class that is designed to hold immutable data. Traditionally, if you wanted a simple “data holder” class, you would write something like:

public class Person {
    private final String name;
    private final int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // Getters
    public String name() {
        return name;
    }

    public int age() {
        return age;
    }

    // equals, hashCode, toString
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Person)) return false;
        Person person = (Person) o;
        return age == person.age && Objects.equals(name, person.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }

    @Override
    public String toString() {
        return "Person{name='" + name + "', age=" + age + "}";
    }
}

This is a lot of code to represent a simple entity with two fields. Records solve this by automatically generating these features. In essence, a Record is a class with its primary goal being to hold data in a type-safe, immutable manner, without all the extra verbosity.

2. Key Features and Benefits of Records

  1. Immutable by Design
    Records require that their fields (called record components) are final, ensuring the immutability of the data once created.
  2. Concise Syntax
    All you need to do is declare the record with its components. Java automatically generates:
    • A private final field for each component.
    • A public constructor that initializes these fields.
    • Accessor methods (named after each component).
    • equals(), hashCode(), and toString() methods based on the components.
  3. Clear Semantics
    By declaring a record, you highlight your intent that this “class” is meant to be just a data container, not an entity with large amounts of mutable state or complex business logic.
  4. Validation and Custom Logic
    Although concise, you can still add custom constructors, static factories, validation logic, and even additional methods if needed.

3. Creating Your First Record

A minimal Java Record for the same Person class above can be declared as follows:

public record Person(String name, int age) {}

That’s it! By default, the Person record has:

  • A private final String name;
  • A private final int age;
  • A canonical constructor public Person(String name, int age) { ... }
  • Accessor methods public String name() and public int age()
  • Proper equals(), hashCode(), and toString() implementations.

You can use it just like a normal class:

public class RecordDemo {
    public static void main(String[] args) {
        Person alice = new Person("Alice", 30);
        System.out.println(alice.name()); // Alice
        System.out.println(alice.age());  // 30
        System.out.println(alice);        // Person[name=Alice, age=30]
    }
}

4. Modeling Immutable Data with Records

Because record components are final and set only via the constructor, you get immutability for free. Once a Person is created, its name and age cannot be changed. This makes them particularly suitable for:

  • Data Transfer Objects (DTOs) between services or layers.
  • Value Objects in domain-driven design.
  • Configuration or settings objects where immutability is preferred.
  • Events or messages that carry data around.

When you create a record, it signals to other developers (and yourself) that this type is purely about containing data.

Example: If you model a Point2D record in a graphics application or geometry library, it might look like this:

public record Point2D(double x, double y) {}

No need to worry about someone mutating your point’s coordinates—once instantiated, it’s fixed.

5. Advanced Features

5.1. Custom Constructors

Although the record syntax generates a canonical constructor, you can still define your own constructors for validation or transformation. For instance, if you want to ensure the age is never negative:

public record Person(String name, int age) {
    public Person {
        if (age < 0) {
            throw new IllegalArgumentException("Age cannot be negative!");
        }
    }
}

Here, public Person { ... } is the canonical constructor for a record with the same parameter list as the record components. You can add additional logic inside.

Alternatively, you can define a separate, non-canonical constructor if you want different parameter lists. Just keep in mind that every non-canonical constructor must call (directly or indirectly) the canonical constructor.

5.2. Compact Constructors (Canonical Constructors)

The example above is sometimes referred to as a compact constructor, because you don’t need to repeat the parameter list. When you write:

public Person {
    // constructor body
}

…it’s shorthand for:

public Person(String name, int age) {
    this.name = name;
    this.age = age;
    // constructor body
}

5.3. Additional Methods

Records can have additional methods to perform operations on their data. However, you typically keep them minimal to preserve the notion that a record’s primary focus is holding data.

public record Range(int start, int end) {
    public int length() {
        return end - start;
    }
}

5.4. Static Fields and Methods

You can declare static fields and methods in a record (though instance fields must match the components). For example, a record can have a helper method or constant:

public record Temperature(double value, char scale) {
    // Allowed scales: 'C' for Celsius, 'F' for Fahrenheit
    
    // Custom constructor with validation
    public Temperature {
        if (scale != 'C' && scale != 'F') {
            throw new IllegalArgumentException("Scale must be either 'C' or 'F'");
        }
    }

    // Additional method
    public double toCelsius() {
        if (scale == 'C') return value;
        // Convert Fahrenheit to Celsius
        return (value - 32) * 5 / 9;
    }
    
    // Static factory
    public static Temperature fromCelsius(double celsius) {
        return new Temperature(celsius, 'C');
    }

    public static Temperature fromFahrenheit(double fahrenheit) {
        return new Temperature(fahrenheit, 'F');
    }
}

Using static factories can be helpful for readability, especially when you want to provide a named alternative to a canonical constructor.

6. Best Practices and Limitations

6.1. When to Use Records

  • When your class is primarily data: If your primary goal is to store a set of values, and you do not expect them to change during the lifetime of the object, a record is a good fit.
  • For domain “value objects”: Value objects represent data in your domain model without identity concerns or side effects.
  • For DTOs: Passing data to and from service layers in an immutable manner can simplify debugging and reduce side effects.

6.2. Limitations

  1. No Changing Fields
    Record fields are final. If you need mutable state, a record is not the right choice.
  2. No Inheritance
    Records implicitly extend java.lang.Record and cannot extend another class. You can implement interfaces but cannot extend abstract classes. This is often a conscious choice to keep records simple.
  3. Cannot Hide Components
    Every component is publicly accessible via its accessor method. If you need to hide certain fields or provide partial data, consider using a regular class or other design patterns.
  4. Serialization Considerations
    If you use frameworks that rely on default constructors or reflective field access, ensure they support records (many modern frameworks, like Jackson, already do).

6.3. Performance

Records do not inherently improve or degrade performance compared to a traditional class with final fields. However, they can optimize developer time by removing boilerplate. The immutability aspect often provides benefits to concurrency and reduces the risk of accidental side effects.

Conclusion

Java Records bring simplicity and clarity to your codebase by letting you model immutable data with minimal fuss. By removing the boilerplate of implementing equals, hashCode, and toString, your classes become more concise and maintainable. The immutable nature of records can improve your program’s reliability, especially in concurrent environments where shared mutable state can introduce subtle bugs.

Key Points to Remember:

  • Records are designed for simple, immutable data modeling.
  • They automatically generate equals, hashCode, toString, and accessor methods.
  • You can still add custom constructors, validation logic, and additional methods if needed.
  • Records are not suitable for every scenario (e.g., if you need mutable state or complex hierarchy).

As you embrace records in modern Java (Java 16+), you’ll find them especially useful in data-centric and functional-style programming scenarios, helping you write cleaner, more robust code.

Anastasios Antoniadis
Find me on
Latest posts by Anastasios Antoniadis (see all)

Leave a Comment