Back to Blog
Java

Java Record vs Immutable Class: Choosing the Right Data Carrier

java record vs immutable class: Compare Java records and immutable classes for data modeling: syntax, equality, serialization, and when each approach fits better.

Java recordsimmutable classesdata carriersvalue objectsJava 16
Side-by-side comparison of Java record and immutable class structures highlighting equality and data carrier design.

Java record vs immutable class is a common decision when modeling data carriers. Records, introduced in Java 16, provide a compact syntax for classes that are transparent holders for immutable data. Immutable classes have existed since the beginning of Java and require more boilerplate but offer greater flexibility. The choice affects equality, serialization, inheritance, and maintainability.

What a Java Record Provides

A record declaration creates a final class with private final fields, a canonical constructor, accessor methods, and implementations of equals, hashCode, and toString based on all components. For example:

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

This single line gives you an immutable data carrier with structural equality. The compiler generates the constructor that assigns each field, the accessors x() and y(), and the equals/hashCode methods that compare all components. Records cannot extend another class, cannot declare instance fields beyond the components, and are implicitly final.

What an Immutable Class Requires

An immutable class is a regular class that you design to prevent modification after construction. Typically you make fields private and final, provide a constructor that initializes them, expose getters instead of setters, and ensure no mutable objects are exposed. For example:

public final class Point { private final int x; private final int y; public Point(int x, int y) { this.x = x; this.y = y; } public int x() { return x; } public int y() { return y; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Point)) return false; Point p = (Point) o; return x == p.x && y == p.y; } @Override public int hashCode() { return Objects.hash(x, y); } }

The class is final to prevent subclassing, which could break immutability. You must implement equals and hashCode manually if you need value-based equality. This is where records reduce boilerplate significantly.

Equality and hashCode Behavior

Records derive equals and hashCode from all components. Two records are equal if their component values are equal. This is the behavior most data carriers need. For immutable classes, you decide which fields participate in equality. If you omit a field, two objects with different values for that field can still be equal, which may be intentional or a bug. Records force you to include every component, which simplifies reasoning but also means you cannot define a custom equality based on a subset of fields without adding extra logic.

Serialization and Deserialization

Records have special serialization rules. When a record is serialized, its components are serialized, and deserialization uses the canonical constructor. This ensures that the invariants enforced in the constructor are reapplied. For an immutable class, serialization uses the default mechanism unless you customize writeObject and readObject. If the class has validation in its constructor, that validation is not run during deserialization unless you implement readObject to call the constructor. Records avoid this pitfall by design.

Inheritance and Extensibility

Records cannot extend any class, and they are implicitly final. This is a deliberate restriction. If you need to model a hierarchy of value types, you must use an abstract class or interface with immutable implementations. An immutable class can be designed to allow inheritance, but doing so requires careful attention to prevent subclasses from introducing mutable state or breaking equals symmetry. In practice, most data carriers are leaf types, so the record restriction is rarely a problem.

When to Choose a Record

Use a record when the data carrier is a simple aggregation of values with no additional behavior beyond accessors and value-based equality. Typical examples include DTOs, API responses, configuration tuples, and domain events. Records are also a good fit when you want the compiler to generate consistent equals and hashCode without maintenance. The compact syntax reduces the chance of errors when fields are added or removed.

When an Immutable Class Is Better

Choose an immutable class when you need more control than a record provides. For example, you may want to hide the constructor behind a factory method, cache instances, or normalize values before assignment. You might also need to implement an interface that requires methods not derived from components, or you want to use inheritance. Immutable classes also allow you to have fields that are not part of the equality contract, such as a cached hash code or a transient derived value.

Performance and Memory Considerations

Records and immutable classes have similar runtime characteristics. Both allocate objects on the heap, and both can benefit from defensive copying of mutable component references. Records do not automatically perform defensive copies; if a component is a mutable object, the record stores the reference. The same is true for an immutable class unless you implement copying in the constructor. The main performance difference is negligible in most applications. Records may slightly reduce startup cost because there is less bytecode to load, but this is rarely measurable.

Common Pitfalls with Records

One common mistake is assuming that records are deeply immutable. A record with a component of type List or Date is not deeply immutable; the reference is final, but the referenced object can change. If you need deep immutability, you must either use immutable types or make defensive copies in the constructor. Another pitfall is relying on the default toString, which may expose sensitive data if the record contains credentials. Records do not provide any special protection; you must override toString if needed.

Making the Decision

The decision between a record and an immutable class comes down to whether you need the additional flexibility that a class provides. If a simple, final, value-based data carrier is sufficient, a record is the better choice because it reduces boilerplate and enforces consistent equality. If you need custom construction logic, inheritance, or fields outside the component list, an immutable class gives you the control you need. In most modern Java codebases, records are the default for new data carriers, and immutable classes are reserved for cases where records cannot express the required design.

java record vs immutable class: Practical Usage and Code Exa | RYUSLOG DEV