Back to Blog
Java

Java Record vs Class: When to Use Each

java record vs class: Understand the key differences between Java records and classes, including syntax, immutability, equality, and when each is the better choice for...

Java recordsJava classesimmutabilitydata modelingJava 17
A visual comparison between a Java record and a Java class, showing a concise record declaration next to a more verbose class definition.

When you declare a Java record, you are telling the compiler that the type's identity is defined entirely by its state. A class, by contrast, leaves that decision to you. This core distinction drives most of the practical differences between the two, and it is the reason java record vs class is a decision that depends on how you intend to use the type.

Consider a simple value object that represents a point in a coordinate system. With a class, you write a constructor, getters, equals, hashCode, and toString manually or rely on an IDE to generate them. With a record, the compiler generates all of that from the component list in one line. The record also enforces immutability by making each component a private final field and providing only accessor methods, not setters.

What a Record Changes in Your Data Model

A record is a restricted form of a class that is designed to hold immutable data. Its declaration is concise, but that conciseness is built on strict behavior. Every record has a canonical constructor that takes the same parameters as its components. The compiler also generates equals, hashCode, and toString based on those components.

For example:

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

This single line gives you a class with two final fields, a constructor, accessor methods x() and y(), and value-based equality. The equivalent class would require roughly thirty lines of boilerplate. The record also prevents inheritance: it is implicitly final, and you cannot extend another class because records already extend java.lang.Record.

Syntax Differences Between a Record and a Class

A class gives you full control over the state and behavior. You can have private fields, mutable state, multiple constructors, and inheritance. A record restricts you to a fixed set of components, but you can still add static fields, static methods, and instance methods that do not modify the state.

Here is a class with the same external behavior as the Point record:

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); } @Override public String toString() { return "Point[" + x + ", " + y + "]"; } }

The record version is not just shorter; it also guarantees that the equality and hash code behavior are consistent with the components. If you add a component to the record, the generated methods update automatically. In a class, you must remember to update them manually, and forgetting to do so is a common source of bugs.

Equality and Hash Code Behavior

Records use value-based equality: two record instances are equal if all their components are equal. This is the behavior you typically want for data carriers. Classes, unless you override equals and hashCode, use identity equality, which compares object references.

This difference matters when you store objects in collections or use them as keys in maps. With a class that does not override equals, two distinct instances with identical field values are not equal. With a record, they are equal by default. If you are building a domain model where two objects should be considered the same because they hold the same data, a record gives you that semantics for free.

However, value-based equality also means that records are not suitable for objects that have identity, such as entities in a database or objects that represent a mutable session. If two instances with the same field values should not be treated as equal, a class with custom equals is the right choice.

When a Record Is Not Enough: Mutable State and Behavior

Records are immutable. Every component is final, and the canonical constructor is the only way to set the values. If you need to change the state after creation, you cannot use a record. A class allows you to have setters or methods that modify internal fields.

Consider a class that represents a bank account. The balance changes over time, so the object must be mutable. A record would force you to create a new instance for every transaction, which is possible but often impractical and error-prone. In such cases, a class is the natural fit.

Records also have limitations on behavior. You can add methods that compute values from the components, but you cannot add fields that are not part of the constructor. If you need to cache a derived value or maintain a non-constructor field, a record will not allow it. For example, you cannot add a distanceFromOrigin field that is computed once and stored; you would have to compute it on each call or use a separate class.

Inheritance and Extensibility Constraints

A record is implicitly final. You cannot extend a record, and a record cannot extend any other class. This is a deliberate design choice to preserve the semantics of value-based equality. If you need to model a hierarchy, such as a base Shape class with subclasses Circle and Rectangle, records are not suitable. You would use an abstract class or an interface.

Classes, on the other hand, support inheritance. You can create a base class with common fields and methods and extend it in subclasses. This allows for polymorphism and code reuse. However, inheritance also introduces coupling and can make equality and hash code tricky to implement correctly across a hierarchy. Records avoid this complexity by being final and self-contained.

If you have a closed set of types, you can use a sealed interface with record implementations. Java 17 introduced sealed classes and interfaces, which allow you to restrict which types can implement them. For example:

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

This gives you the benefits of records (immutability, value equality) while still supporting a limited form of polymorphism.

Performance and Memory Characteristics

Records do not have inherent performance advantages over classes. The generated methods are similar to what you would write manually. The main performance consideration is that records are immutable, which can lead to more object allocations if you frequently create new instances to represent state changes. In a class, you can mutate an existing object, avoiding allocation.

Memory usage is also similar. A record has one field per component, plus a reference to the Class object. The generated equals and hashCode methods iterate over the components, which is O(n) where n is the number of components. This is the same as a well-written class.

One subtle difference is that records do not have setters, so you cannot accidentally introduce mutable state. This can reduce the risk of concurrency issues because immutable objects are inherently thread-safe. If you are designing a system that shares data across threads, records eliminate the need for defensive copies or synchronization.

Choosing Between a Record and a Class for Your Domain Model

The decision between a record and a class comes down to whether the type represents a value or an entity. A value is defined by its data; two values with the same data are interchangeable. An entity has identity; two objects with the same data are not necessarily the same entity.

Use a record when:

  • The object is a simple data carrier, such as a DTO, a response, or a configuration value.
  • The data is immutable and will not change after creation.
  • You want value-based equality without writing boilerplate.
  • You do not need to extend the type.

Use a class when:

  • The object has mutable state, such as a domain entity that changes over time.
  • You need to control the implementation of equals and hashCode, perhaps to use a business key instead of all fields.
  • You need inheritance or polymorphism.
  • You need to add fields that are not part of the constructor.

There is no universal rule that one is always better. The choice depends on the role the type plays in your application. Records simplify the common case of immutable data carriers, while classes give you the flexibility to model complex behavior and identity.

Compatibility and Migration Considerations

If you are working with an existing codebase, introducing records can change behavior. For example, a class that relied on reference equality will now use value equality if you convert it to a record. This can break code that expects distinct instances to be unequal. Before converting a class to a record, check how instances are compared and used in collections.

Also, records are not compatible with frameworks that rely on no-arg constructors or setters, such as some ORMs or serialization libraries. Java's built-in serialization works with records, but third-party libraries may require adaptation. If you are using a framework that expects a mutable bean, a record may not be a drop-in replacement.

Finally, records require Java 16 or later (finalized in Java 16, with some features in Java 14 and 15 as preview). If your project targets an older Java version, you cannot use records. In that case, you would stick with classes or use a library like Lombok to generate similar boilerplate.

Advanced Record Features: Compact Constructors and Custom Accessors

Records are not completely rigid. You can add a compact constructor to validate or normalize the components before they are assigned. The compact constructor does not have a parameter list; it assigns to the fields implicitly.

public record Temperature(double celsius) { public Temperature { if (celsius < -273.15) { throw new IllegalArgumentException("Temperature below absolute zero"); } } }

You can also override the accessor methods to compute a value on the fly, but you cannot add new instance fields. This allows you to keep the immutability guarantee while providing derived data.

These features make records more than a simple data holder. They can encapsulate validation and derived behavior, which reduces the need for a separate class in many cases.

The real question in java record vs class is not which one is more powerful, but which one matches the semantics you need. Records are a tool for a specific job: immutable data carriers with value equality. Classes are the general-purpose tool for everything else. Choosing the right one makes your code clearer and less error-prone.

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