Back to Blog
Java

Java Records: Immutable Data Carriers

java records: Learn how Java records reduce boilerplate for immutable data carriers, including syntax, compact constructors, restrictions, and when to prefer them over...

Java RecordsImmutabilityData ClassesJava 16Object-Oriented Design
Java record syntax showing a compact data class declaration with immutable fields and generated accessors.

Java records, introduced as a final feature in Java 16, provide a compact way to declare classes that are transparent carriers for immutable data. Instead of writing the usual boilerplate of private final fields, public constructor, getters, equals, hashCode, and toString, a record gives you all of that from a single line. But records are more than syntactic sugar: they impose a strict semantic model that affects how you design your domain objects.

The Core Syntax of a Java Record

A record declaration looks like a class declaration but uses the keyword record and lists the components in parentheses. Each component automatically becomes a private final field, a public accessor method, and a parameter in the canonical constructor.

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

This single line produces a class with the following behavior:

  • Fields x and y are private final.
  • Accessor methods x() and y() are generated.
  • A canonical constructor that takes both components.
  • equals, hashCode, and toString are generated based on all components.

You can use the record exactly like any other class:

Point p = new Point(3, 4); System.out.println(p.x()); // 3 System.out.println(p); // Point[x=3, y=4]

The generated equals performs a shallow comparison of the components. For a record holding primitive values or immutable references, this gives value-based equality, which is often what you want for data carriers.

Customizing with the Compact Constructor

If you need to validate or normalize the state before storing it, you can define a compact constructor. Unlike a normal constructor, it does not list parameters—the record provides them implicitly. You only write the validation logic and assign the components.

public record Range(int start, int end) { public Range { if (start > end) { throw new IllegalArgumentException("start must be <= end"); } } }

In a compact constructor, the parameters are available as the component names. You can also reassign them before the implicit assignment, which is useful for normalization:

public record NormalizedPoint(int x, int y) { public NormalizedPoint { x = Math.abs(x); y = Math.abs(y); } }

This constructor runs before the fields are set, so the values you assign become the actual field values. The compact constructor is a clean place to enforce invariants without duplicating code across multiple overloaded constructors.

Accessors and the Restriction on Instance Fields

Records automatically generate accessor methods that match the component names. You can override an accessor if you need to compute a derived value, but you cannot add instance fields outside the components. The record class is implicitly final, and it already extends java.lang.Record. This means:

  • You cannot declare additional instance fields.
  • You cannot extend another class.
  • You cannot declare the record as abstract.
  • You cannot declare native methods.

These restrictions are intentional. A record is meant to be a shallowly immutable data carrier. If you need mutable state or additional fields, a traditional class is more appropriate.

You can still declare static fields and static methods, as well as instance methods that compute values from the components. For example:

public record Celsius(double value) { public static final double ABSOLUTE_ZERO = -273.15; public double toFahrenheit() { return value * 9 / 5 + 32; } }

When to Use a Record Instead of a Class

The decision between a record and a traditional class depends on whether your type is a passive data container or an active part of a behavior-rich domain model. Records fit well when:

  • The identity of an object is determined by its state, not by a mutable reference.
  • You need value-based equality, such as for use as map keys or in collections.
  • The data is immutable by design, or you can guarantee that referenced objects are never mutated.
  • You want to reduce boilerplate without losing clarity.

Traditional classes remain the better choice when you need:

  • Additional mutable fields or non-final state.
  • Inheritance or polymorphic behavior.
  • Custom equals or hashCode that ignores some fields.
  • Lazy initialization or caching.
  • A constructor that takes different parameters than the final fields.

Records do not support lazy evaluation because all fields must be set at construction time. If you need to defer expensive computation, a class with a private field and a getter that computes on first access is still the way to go.

Serialization and Records

Java serialization treats records specially. Since records are immutable and their state is fully defined by the components, serialization uses the canonical constructor to reconstruct the object. This avoids the reflection-based mechanism that can bypass constructors and leave an object in an inconsistent state.

For a record to be serializable, it must implement java.io.Serializable. The serialized form is based on the component names and values. If you later change the record's components, the serialized form changes, and deserialization may fail unless you handle compatibility explicitly. Unlike traditional classes, records cannot customize the serialized form with writeObject and readObject methods; the mechanism is fixed. This makes records safer for serialization but less flexible.

If you need to control serialization, consider using a different mechanism such as JSON or a custom Data Transfer Object (DTO) that you can evolve independently.

Performance and Memory Considerations

Records are not a performance magic bullet. They compile to ordinary classes, and the generated methods are similar to what you would write manually. The main performance benefit is reduced code size and the potential for better optimization by the JVM because the class structure is simple and final.

One subtle point: records are shallowly immutable. If a component is a reference to a mutable object, the record does not protect that object from mutation. For example:

record Person(String name, List<String> emails) {}

The List referenced by emails can be modified even though the record itself is immutable. To achieve deep immutability, you must either use immutable collection types or defensively copy in the compact constructor. This is a common source of bugs when records are used in concurrent code.

Records also generate equals and hashCode that iterate over all components. For records with many components, this is slightly more work than a manually tuned implementation, but it is rarely a bottleneck. If you have a record with dozens of fields and you use it heavily in hash-based collections, you might consider a traditional class with a custom hash code, but that is an optimization you should measure rather than assume.

Interoperability with Existing Code and Libraries

Records are ordinary classes, so they work with most Java libraries that rely on reflection, such as ORMs, JSON serializers, and bean mappers. However, some libraries expect a no-argument constructor or mutable fields. For example, JPA entities typically require a no-arg constructor and often rely on field mutation, which records do not provide. In such cases, records are not a drop-in replacement.

For JSON binding, libraries like Jackson and Gson have added support for records. Jackson, for instance, can deserialize into records using the canonical constructor, but you may need to configure the module to handle parameter names correctly. Always verify that the library version you use supports records before adopting them in a persistence or communication layer.

Records also work with generics and can be used as type parameters. You can define a generic record:

record Pair<K, V>(K key, V value) {}

This is useful for small, immutable key-value pairs, but be aware that the generated equals and hashCode will call the corresponding methods on the key and value, so they must have consistent implementations.

Common Pitfalls and How to Avoid Them

One frequent mistake is assuming that records are automatically deeply immutable. As mentioned, a record only guarantees that the reference fields are not reassigned. If you expose a mutable collection through an accessor, callers can modify the internal state. To prevent this, you can make a defensive copy in the accessor:

public record Person(List<String> emails) { public Person { emails = List.copyOf(emails); } @Override public List<String> emails() { return List.copyOf(emails); } }

This ensures that the internal list is never exposed directly and that the record remains effectively immutable even when the caller passes a mutable list.

Another pitfall is using records for types that need to evolve frequently. Since the canonical constructor and the component list are fixed, adding a field is a breaking change for any code that constructs the record. If your data structure is likely to change, a class with a builder pattern may be more maintainable.

Finally, remember that records cannot be extended. If you have a hierarchy of data types, you cannot use records for the base type. You would need to use a sealed interface or an abstract class, with records as the final implementations. For example:

sealed interface Shape permits Circle, Rectangle {} record Circle(double radius) implements Shape {} record Rectangle(double width, double height) implements Shape {}

This pattern gives you the conciseness of records while still allowing polymorphic behavior through the sealed interface.

java records: Practical Usage and Code Examples | RYUSLOG DEV