Java Record Class: Syntax and Practical Use
java record class: Learn how to use Java record classes to model immutable data with concise syntax, including constructors, accessors, and practical tradeoffs.
A Java record class is a special kind of class that models immutable data with minimal boilerplate. Introduced as a preview in Java 14 and finalized in Java 16, records automatically generate a constructor, accessor methods, and implementations of equals, hashCode, and toString from the component list. This removes the repetitive code that typically accompanies simple data classes.
Declaring a Java Record Class
The syntax for a record class is concise. You declare the components in parentheses after the record name:
public record Point(int x, int y) {}
This single line produces a class with:
- a canonical constructor that takes
xandy - accessor methods
x()andy() equals,hashCode, andtoStringimplementations based on both components- a
finalclass that cannot be extended
The generated accessor methods do not use the get prefix. For a component named x, the accessor is x(), not getX(). This is a deliberate design choice that keeps the API consistent with the component names.
You can instantiate a record 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 toString output includes the record name and each component with its value, which is useful for logging and debugging.
How Records Generate Accessors and Object Methods
The compiler generates the canonical constructor and the accessor methods from the component list. The generated equals method performs a shallow comparison of all components. For reference types, this means the equals method uses the equals method of each component, not ==. The hashCode method combines the hash codes of all components.
Consider a record with a List component:
public record Basket(List<String> items) {}
Two Basket instances are equal only if their items lists are equal according to List.equals. This is the same behavior you would get from a hand-written implementation, but without the risk of forgetting to update it when a component changes.
Because records are immutable by design, the generated accessors simply return the component value. There is no defensive copy. If a component is a mutable object like a List, the record itself is not deeply immutable. The reference is final, but the referenced object can change. This is an important distinction when you use records in collections or as map keys.
Compact Constructors for Validation and Normalization
The canonical constructor is the only constructor that can be customized without additional factory methods. You can use a compact constructor to validate or normalize the components before they are assigned:
public record Range(int min, int max) { public Range { if (min > max) { throw new IllegalArgumentException("min must be <= max"); } } }
In a compact constructor, you cannot assign to the fields directly. The compiler assigns the parameters to the components after the body runs. You can also transform the parameters:
public record NormalizedText(String value) { public NormalizedText { value = value.trim(); } }
Here, the value parameter is reassigned to the trimmed version, and the record stores the trimmed string. This keeps validation and normalization in one place, preventing the same checks from being duplicated across call sites.
If you need additional constructors, you can define them as long as they delegate to the canonical constructor:
public record Point(int x, int y) { public Point() { this(0, 0); } }
This is useful for providing default values without losing the canonical constructor.
When to Use a Record Class Instead of a Traditional Class
Records are not a replacement for all classes. They are best suited for data carriers that are immutable and whose identity is based on their field values. Typical examples include:
- DTOs (Data Transfer Objects) for API responses
- value objects in domain models
- tuple-like results from methods
- configuration parameters
A traditional class is still the right choice when you need:
- mutable state
- inheritance or extension
- additional instance fields beyond the constructor parameters
- custom
equalsorhashCodebehavior that does not align with the component list - lazy initialization or caching
The following table summarizes the main differences:
| Aspect | Record Class | Traditional Class |
|---|---|---|
| Immutability | Enforced by design | Requires manual implementation |
| Boilerplate | Minimal | More code for accessors and object methods |
| Inheritance | Cannot extend another class | Can extend a superclass |
| Instance fields | Only the components | Can declare additional fields |
| Custom equals/hashCode | Generated from components | Must be written manually |
Use a record when the data is truly immutable and the component list fully represents the state. If you need to add behavior that depends on derived state, a traditional class with a private constructor and factory methods may be clearer.
Limitations and Compatibility Considerations
Records have a few restrictions that affect how you design your code. A record class is implicitly final, so it cannot be extended. It also cannot extend any other class, because it already extends java.lang.Record. This means records cannot participate in class hierarchies that require a superclass.
You cannot declare additional instance fields in a record. All state must be declared as components. Static fields and static methods are allowed, but instance fields other than the components are prohibited. This ensures that the generated equals and hashCode always cover the complete state.
Records are supported in Java 16 and later. If you are working on a codebase that targets Java 11 or earlier, you cannot use them directly. In that case, you can use a library like Lombok's @Value or manually write the boilerplate, but the behavior will not be identical.
Another compatibility concern is serialization. Records can implement Serializable, but the serialized form is different from a traditional class. When a record is deserialized, the canonical constructor is invoked, so any validation in the compact constructor is enforced. This is a security improvement over traditional deserialization, which can bypass constructors.
Records and Serialization: Runtime and Maintenance Concerns
If you plan to serialize records, you need to be aware of how the serialization mechanism interacts with the canonical constructor. When a record is serialized, only the component values are written. During deserialization, the runtime calls the canonical constructor with those values. This means the compact constructor runs again, which is generally desirable because it revalidates the data.
However, this also means that if you change the component list between versions, the serialized form may become incompatible. For example, adding a new component changes the serialized form, and older serialized data will fail to deserialize. You can mitigate this by using a serialVersionUID and by being careful about evolving the record's shape.
From a maintenance perspective, records reduce the amount of code you need to review when a field is added or removed. The compiler regenerates all the standard methods automatically. This is a real advantage in large codebases where data classes are frequently modified.
Performance-wise, records are plain classes. The generated methods are as efficient as hand-written ones. There is no reflection overhead in normal usage. The only additional cost is the class metadata, which is negligible. If you are concerned about allocation, records do not introduce any extra allocation beyond what a traditional class would.