Back to Blog
Java

Java Record: Syntax, Usage, and Tradeoffs

java record: Learn how Java records reduce boilerplate for immutable data carriers, including syntax, customization, and when to use them.

JavaRecordsImmutable DataData CarriersBoilerplate Reduction
A Java record declaration showing concise syntax for immutable data carriers.

Java records, introduced as a preview in Java 14 and finalized in Java 16, provide a compact syntax for declaring classes that are primarily carriers of immutable data. Instead of writing a constructor, accessors, equals, hashCode, and toString manually, you declare the components and the compiler generates them for you.

Declaring a Java Record

The simplest record declaration looks like this:

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

This single line gives you a public constructor that takes both coordinates, accessor methods x() and y(), a sensible equals(), hashCode(), and toString(). The generated equals() compares every component, and toString() produces output like Point[x=3, y=4]. The accessors are named after the components, but they do not use the JavaBeans getX() convention.

You can use a record anywhere you would use a regular class, including as a local variable, a field, or a method parameter. The compact syntax makes the intent clear: this class exists to hold data, not to encapsulate complex behavior.

How Records Differ from Regular Classes

Records are not just syntactic sugar. They have specific constraints that distinguish them from ordinary classes:

  • A record is implicitly final, so it cannot be subclassed.
  • Every record extends java.lang.Record, which is an abstract class. You cannot explicitly extend another class.
  • All components are private final fields. You cannot add instance fields beyond the components, though you can add static fields and methods.
  • The canonical constructor is generated automatically, but you can customize it as shown later.
  • Records cannot be abstract and cannot declare native methods.

These constraints enforce immutability and structural equality. Because a record cannot be modified after creation, it is safe to share across threads without synchronization.

Customizing a Record with Validation and Methods

Although records generate the canonical constructor, you can override it using a compact constructor. This is useful for validation or normalization:

public record Point(int x, int y) { public Point { if (x < 0 || y < 0) { throw new IllegalArgumentException("Coordinates must be non-negative"); } } }

The compact constructor syntax omits parameters. Inside the body, you can access the component parameters directly. After the body completes, the compiler assigns the values to the fields. You cannot assign to the fields yourself; the compiler handles that.

You can also add additional methods, both instance and static:

public record Point(int x, int y) { public double distanceFromOrigin() { return Math.sqrt(x * x + y * y); } public static Point origin() { return new Point(0, 0); } }

These methods behave like any other class method. They can use the component accessors or access the private fields directly. The record's immutability means these methods cannot modify state, which makes them predictable and side-effect free.

Records and Serialization

Records can participate in Java serialization if they implement Serializable. The serialized form of a record is based on its components, and deserialization uses the canonical constructor. This means validation in the compact constructor is also enforced during deserialization, which is a useful safety property.

However, records are not automatically serializable. You must explicitly add implements Serializable:

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

The serialization mechanism for records is defined by the Java serialization specification, and it differs from traditional serialization. Because records are immutable, there is no need for custom readObject or writeObject methods in most cases. If you are using frameworks like Jackson or Gson, they often support records natively or through adapters, but you should check the specific version's documentation.

Performance and Memory Considerations

The performance of a record is comparable to a hand-written class with the same fields. The compiler-generated methods are efficient and do not rely on reflection. The main benefit is not raw speed but reduced boilerplate and improved maintainability. Because records are immutable, they can be safely cached and reused. The JVM may also apply optimizations for final classes, but you should not expect a dramatic performance difference.

Memory usage is similar to a regular class with the same number of fields. The generated equals() and hashCode() iterate over all components, which is the same cost as a manually implemented version. For most applications, records are not a bottleneck.

When to Use Records and When Not To

Use a record when your class is primarily a data carrier: DTOs, value objects, request/response models, or immutable configuration. Records are ideal when you want structural equality and a concise declaration.

Avoid records when you need inheritance, additional mutable state, or complex behavior that does not fit the data-carrier model. For example, an entity with a lifecycle and business methods may be better as a regular class. Also, records cannot be extended, so if you need polymorphic behavior, a traditional class hierarchy is required.

If you need to evolve a record's shape over time, consider that changing a component is a breaking change. Unlike a class with a builder, records do not have a built-in way to handle optional fields. In such cases, a builder pattern or a regular class might be more flexible.

Records vs Traditional Classes: A Quick Comparison

The following table summarizes the key differences:

AspectRecordTraditional Class
BoilerplateMinimalSignificant for data classes
ImmutabilityEnforcedOptional
InheritanceCannot extendCan extend
Additional fieldsNot allowedAllowed
Custom constructorCompact formFull constructor
equals/hashCodeGeneratedMust be implemented
toStringGeneratedMust be implemented
Use caseData carriersGeneral purpose

This comparison highlights that records are a specialized tool. They shine when your primary goal is to hold data without extra ceremony. For anything more complex, a traditional class gives you the freedom to model behavior and state as needed.

Records are a valuable addition to the Java language, but they are not a replacement for all classes. Understanding their constraints helps you decide when to reach for them and when to stick with a more flexible design.

java record: Practical Usage and Code Examples | RYUSLOG DEV