Back to Blog
Java

Java Record Class: Syntax and Practical Use

Learn to define Java record classes for immutable data: concise syntax, generated constructors and accessors, compact constructors, and practical tradeoffs.

JavaRecordsImmutabilityData ClassesJava 16
Java record class concept showing immutable data container with generated methods

A Java record class is a special kind of class that models immutable data with minimal boilerplate. It was introduced as a preview in Java 14 and finalized in Java 16. Records automatically generate a canonical 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. 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 x and y
  • accessor methods x() and y()
  • equals, hashCode, and toString implementations based on both components
  • a final class 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 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 accessor methods from the component list. The generated equals method compares all components. For reference-type components, it uses Objects.equals for 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

You can customize the canonical constructor directly. A compact constructor lets you 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.

Any additional constructor you declare must delegate to the canonical constructor:

public record Point(int x, int y) { public Point() { this(0, 0); } }

This is useful for providing default values while keeping validation in 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 component 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 record components
  • custom equals or hashCode behavior that does not align with the component list
  • lazy initialization or caching

The following table summarizes the main differences:

AspectRecord ClassTraditional Class
ImmutabilityEnforced by designRequires manual implementation
BoilerplateMinimalMore code for accessors and object methods
InheritanceCannot extend another classCan extend a superclass
Instance fieldsOnly the componentsCan declare additional fields
Custom equals/hashCodeGenerated from componentsMust be written manually

Use a record when the data is genuinely immutable and the component list fully represents the state. If you need to cache derived values or add mutable state, a traditional class with a private constructor and factory methods may be clearer.

Limitations and Compatibility Considerations

Records have a few restrictions. 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.

Record classes are a finalized feature 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 validation in a 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, be aware of how serialization 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 a 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 changes. Adding or removing a component can make older serialized data incompatible with the new record class. For persisted records, plan a migration strategy before changing the component list; an explicit serialVersionUID alone does not supply values for new or removed components.

From a maintenance perspective, records reduce the amount of code you need to review when a field is added or removed. The compiler regenerates 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, and there is no reflection overhead in normal usage. Records do not introduce any extra allocation beyond what a traditional class would.

Java Record Class: Syntax, Constructors, and Practical Use | RYUSLOG DEV