Fix: Java Record Not Working — Compact Constructor Error, Serialization Fails, or Cannot Extend
Part of: Java & JVM Errors
Quick Answer
How to fix Java record issues — compact constructor validation, custom accessor methods, Jackson serialization, inheritance restrictions, and when to use records vs regular classes.
The Problem
A record compact constructor doesn’t validate correctly:
public record Point(double x, double y) {
Point { // Compact constructor
x = Math.abs(x); // Doesn't work — won't update the field
}
}
Point p = new Point(-3.0, 4.0);
System.out.println(p.x()); // -3.0 — not 3.0 as expectedOr Jackson fails to deserialize a record from JSON:
public record User(String name, int age) {}
// ObjectMapper.readValue("{\"name\":\"Alice\",\"age\":30}", User.class)
// InvalidDefinitionException: Cannot construct instance of UserOr a record can’t be used where a class is expected:
public record Employee(String name) extends Person { // Does not compile
// Compile error: records cannot extend classes
}Why This Happens
Java records (introduced in Java 16 as a stable feature) have unique rules:
- Compact constructor parameters are not the fields — in a compact constructor,
xandyare parameters, not the final fields. You can reassign them (which updates what gets written to the fields), but the syntax is different from a regular constructor. - Records don’t have a no-argument constructor by default — Jackson, Hibernate, and other frameworks often require a no-arg constructor for deserialization/instantiation. Records only provide a canonical constructor (one per all components).
- Records implicitly extend
java.lang.Record— since Java uses single inheritance, records cannot extend any other class (including abstract classes). They can implement interfaces. - Record components are implicitly
final— you can’t mutate a record’s fields after construction, which breaks frameworks expecting mutable beans.
Records were designed as nominal tuples: a transparent carrier for immutable data with auto-generated equals, hashCode, toString, and accessor methods. The compiler enforces this transparency at the bytecode level. A class file marked ACC_RECORD carries a Record attribute listing each component, and reflection APIs like Class.getRecordComponents() expose that metadata. Frameworks that previously relied on bean conventions (getX()/setX() reflection) had to learn to call x() accessors and the canonical constructor instead. Jackson 2.12, Hibernate 6, and most validation frameworks have caught up, but older versions in transitive dependencies still trip up serialization.
Pattern matching is the second design pillar. Records combined with sealed interfaces give you Algebraic Data Types in Java — a closed set of variants the compiler can exhaustively check. The pattern matching for switch (stable in Java 21) and record deconstruction patterns (preview in Java 21, stable in 21+ depending on the JEP) let you write case Point(double x, double y) -> ..., binding the components in one step. This is genuinely new Java capability and unlocks designs that previously required visitor patterns or instanceof chains.
The biggest mental shift coming from regular classes is that records have no identity beyond their components. Two records are equal if all their components are equal, regardless of which constructor call produced them. That makes them safe as map keys, but dangerous if you’ve added a transient component like a creation timestamp — pull it out, or two requests created at different times will collide in caches.
Fix 1: Use Compact Constructor Correctly
In a compact constructor, assign to the parameters (not via this.field). The parameters are automatically assigned to the final fields after the constructor body:
// WRONG — trying to assign directly to the field (looks like a regular constructor)
public record Range(int min, int max) {
Range {
this.min = Math.min(min, max); // Compile error: cannot assign to final field
this.max = Math.max(min, max);
}
}
// ALSO WRONG — reassigning via normal field syntax doesn't work either
public record Point(double x, double y) {
Point {
x = Math.abs(x); // This DOES work — but the fix below is clearer
y = Math.abs(y);
}
}
// Wait — this actually DOES work in compact constructors.
// The parameters x and y ARE mutable in the compact constructor body.
// After the body, they're assigned to the final fields.
// CORRECT — reassign the parameters, they're automatically written to fields
public record Range(int min, int max) {
Range {
if (min > max) {
// Swap by reassigning the parameters
int temp = min;
min = max;
max = temp;
}
}
}
// CORRECT — validate in compact constructor
public record Email(String address) {
Email {
Objects.requireNonNull(address, "address must not be null");
if (!address.contains("@")) {
throw new IllegalArgumentException("Invalid email: " + address);
}
address = address.toLowerCase(); // Normalize — reassign the parameter
}
}
Email e = new Email("[email protected]");
System.out.println(e.address()); // [email protected]Explicit constructor vs compact constructor:
// Explicit canonical constructor — write assignment manually
public record Person(String firstName, String lastName) {
public Person(String firstName, String lastName) {
this.firstName = firstName.trim();
this.lastName = lastName.trim();
}
}
// Compact constructor — parameters are auto-assigned after body
public record Person(String firstName, String lastName) {
Person {
firstName = firstName.trim(); // Reassign parameter
lastName = lastName.trim(); // Reassign parameter
// Automatically: this.firstName = firstName; this.lastName = lastName;
}
}Fix 2: Fix Jackson Deserialization with Records
Jackson 2.12+ supports records natively, but requires proper setup:
// Jackson 2.12+ — records work out of the box with the right version
// Add to pom.xml:
// <dependency>
// <groupId>com.fasterxml.jackson.core</groupId>
// <artifactId>jackson-databind</artifactId>
// <version>2.14.0</version> <!-- 2.12+ required for records -->
// </dependency>
public record User(String name, int age) {}
ObjectMapper mapper = new ObjectMapper();
User user = mapper.readValue("{\"name\":\"Alice\",\"age\":30}", User.class);
System.out.println(user.name()); // AliceIf you’re on Jackson < 2.12 or see deserialization errors:
// Add @JsonProperty to help Jackson find the right constructor
public record User(
@JsonProperty("name") String name,
@JsonProperty("age") int age
) {}
// Or add the jackson-module-parameter-names for automatic name detection
// (requires compiling with -parameters flag)
ObjectMapper mapper = new ObjectMapper()
.registerModule(new ParameterNamesModule());
// Spring Boot auto-configures this — no extra setup neededCustom serialization:
public record Money(long amount, String currency) {
// Custom serializer: serialize as {"value": "100.00 USD"}
@JsonSerialize(using = MoneySerializer.class)
@JsonDeserialize(using = MoneyDeserializer.class)
// Or use @JsonValue for simple representations:
@JsonValue
public String toDisplay() {
return String.format("%.2f %s", amount / 100.0, currency);
}
@JsonCreator
public static Money fromDisplay(String display) {
String[] parts = display.split(" ");
return new Money((long)(Double.parseDouble(parts[0]) * 100), parts[1]);
}
}Fix 3: Use Interfaces Instead of Inheritance
Records can’t extend classes, but they can implement interfaces — and Java interfaces can have default methods:
// WRONG — records cannot extend classes
public record Employee(String name) extends Person {
// Compile error
}
// CORRECT — implement interfaces instead
public interface Identifiable {
String id();
}
public interface Displayable {
default String displayName() {
return toString();
}
}
public record Employee(String id, String name, String department)
implements Identifiable, Displayable {
// Override default method if needed
@Override
public String displayName() {
return name + " (" + department + ")";
}
}
// Interfaces with default methods provide shared behavior
public interface HasFullName {
String firstName();
String lastName();
default String fullName() {
return firstName() + " " + lastName();
}
}
public record Customer(String firstName, String lastName, String email)
implements HasFullName {
// fullName() is available via the interface
}
Customer c = new Customer("Alice", "Smith", "[email protected]");
System.out.println(c.fullName()); // Alice SmithFix 4: Custom Accessor Methods and Static Factories
Override the auto-generated accessor methods for custom behavior:
public record Password(String value) {
// CORRECT — compact constructor validates input
Password {
Objects.requireNonNull(value, "Password cannot be null");
if (value.length() < 8) {
throw new IllegalArgumentException("Password must be at least 8 characters");
}
}
// Override accessor to mask sensitive data
@Override
public String value() {
return "***MASKED***";
}
// Store actual value accessible only internally
public boolean matches(String raw) {
return this.value.equals(hashPassword(raw)); // Access the field directly
}
// Static factory methods are idiomatic for records
public static Password of(String raw) {
return new Password(hashPassword(raw));
}
private static String hashPassword(String raw) {
// bcrypt / argon2 implementation
return raw; // Placeholder
}
}Records with computed accessors:
public record Circle(double radius) {
// Validate in compact constructor
Circle {
if (radius <= 0) throw new IllegalArgumentException("Radius must be positive");
}
// Additional accessor methods — these are just regular methods
public double area() {
return Math.PI * radius * radius;
}
public double circumference() {
return 2 * Math.PI * radius;
}
// Records generate equals/hashCode based on components only
// area() and circumference() are NOT included in equals/hashCode
}Fix 5: Records with JPA and Hibernate
JPA requires mutable entities with a no-arg constructor — records can’t be JPA entities, but work well for DTOs and value objects:
// WRONG — trying to use record as JPA entity
@Entity
public record User(Long id, String name) {
// JPA requires @Id, no-arg constructor, mutable fields — records have none of these
}
// CORRECT — use regular class for entity, record for DTO
@Entity
@Table(name = "users")
public class UserEntity {
@Id @GeneratedValue
private Long id;
private String name;
private String email;
protected UserEntity() {} // Required by JPA
// Getters and setters...
}
// Record as DTO — converts from entity
public record UserDto(Long id, String name, String email) {
public static UserDto from(UserEntity entity) {
return new UserDto(entity.getId(), entity.getName(), entity.getEmail());
}
}
// In Spring Data — use records for projections
public interface UserRepository extends JpaRepository<UserEntity, Long> {
// Spring Data projection with record interface — works in Spring Boot 3+
@Query("SELECT u.id AS id, u.name AS name FROM UserEntity u")
List<UserSummary> findAllSummaries();
// Or use record directly as projection type (Spring Data 3+)
@Query("SELECT new com.example.UserDto(u.id, u.name, u.email) FROM UserEntity u")
List<UserDto> findAllAsDto();
}Fix 6: Records for Common Patterns
Records are ideal for value objects, result types, and DTOs:
// Option/Result type with record
public sealed interface Result<T> permits Result.Success, Result.Failure {
record Success<T>(T value) implements Result<T> {}
record Failure<T>(String error, Throwable cause) implements Result<T> {
Failure(String error) { this(error, null); }
}
static <T> Result<T> success(T value) { return new Success<>(value); }
static <T> Result<T> failure(String error) { return new Failure<>(error); }
}
// Usage
Result<User> result = userService.findById(1L);
switch (result) {
case Result.Success<User> s -> System.out.println("Found: " + s.value());
case Result.Failure<User> f -> System.err.println("Error: " + f.error());
}
// Coordinate / money / measurement types
public record Coordinate(double lat, double lon) {
Coordinate {
if (lat < -90 || lat > 90) throw new IllegalArgumentException("Invalid lat: " + lat);
if (lon < -180 || lon > 180) throw new IllegalArgumentException("Invalid lon: " + lon);
}
public double distanceTo(Coordinate other) {
// Haversine formula
double dLat = Math.toRadians(other.lat - this.lat);
double dLon = Math.toRadians(other.lon - this.lon);
double a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(Math.toRadians(this.lat)) * Math.cos(Math.toRadians(other.lat)) *
Math.sin(dLon/2) * Math.sin(dLon/2);
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); // km
}
}
// API request/response types
public record CreateOrderRequest(
@NotNull String customerId,
@NotEmpty List<OrderItem> items,
@Valid ShippingAddress shippingAddress
) {}
public record OrderCreatedResponse(
String orderId,
Instant createdAt,
String status
) {}Cross-Tool Comparison: Java Record vs Kotlin Data Class vs Scala Case Class vs C# Record vs TypeScript readonly
Records are Java’s late entry into a feature every other JVM-adjacent language already had. Understanding the differences helps when you’re choosing where to put data definitions in a polyglot codebase.
Java record (16+) is the most restrictive: components are implicitly final, the class is implicitly final, you can’t extend another class, and the auto-generated accessors use the component name (no getX() prefix). The compiler generates equals, hashCode, and toString based on every component. Validation goes in the compact constructor; defensive copying must also be explicit there. Records integrate with pattern matching (case Point(int x, int y)) and sealed interfaces, giving you JVM-native ADTs.
Kotlin data class is the closest equivalent and predates Java records by years. It generates equals, hashCode, toString, copy, and componentN() destructuring functions. Unlike Java records, data classes can extend non-final classes (though most don’t), support var mutable properties, and provide copy() for non-destructive updates. The copy() method is the biggest ergonomic win: user.copy(email = "[email protected]") produces a new instance with one field changed. Java records have no such helper — you write new User(u.id(), u.name(), "[email protected]") and update every call site when components change.
Scala case class is the original inspiration for all of the above. Like Kotlin’s data class, it provides copy, pattern matching, unapply for extraction, and structural equals. Case classes integrate with Scala’s pattern matching at a level Java still hasn’t reached — guards (case User(_, age) if age >= 18), nested patterns, and exhaustiveness checking on sealed traits. If you’re moving Scala code to Java, case classes generally map to a record plus a sealed interface.
C# record (introduced in C# 9) is much closer to Kotlin’s data class than to Java’s record. It supports with expressions (the equivalent of copy), positional and nominal syntax (record User(int Id, string Name) or record User { public int Id { get; init; } }), and explicitly distinguishes record class (reference equality off, value equality on) from record struct (value type). C# records can also inherit from other records, which Java records cannot.
TypeScript readonly interfaces aren’t records in the structural sense — TypeScript erases types at runtime, so there’s no auto-generated equals or hashCode. The closest pattern is readonly modifiers plus Object.freeze for runtime immutability, or libraries like Immer for ergonomic updates. The trade-off is performance: structural equality must be implemented by hand (lodash.isEqual), and freeze is shallow unless you walk the object graph yourself.
// Java record — no copy method
record User(long id, String name, String email) {}
User u = new User(1, "Alice", "[email protected]");
User updated = new User(u.id(), u.name(), "[email protected]");
// Kotlin data class — copy is built in
data class User(val id: Long, val name: String, val email: String)
val updated = u.copy(email = "[email protected]")
// C# record — with expression
public record User(long Id, string Name, string Email);
var updated = u with { Email = "[email protected]" };
// Scala case class — copy is built in
case class User(id: Long, name: String, email: String)
val updated = u.copy(email = "[email protected]")For Java codebases, the missing copy is genuinely painful. The workarounds are toString-style builders (verbose), Lombok’s @With (annotation processing), or upcoming JEPs that propose with expressions for records. Until then, treat Java records as best-fit for DTOs that rarely need partial updates — keep mutable domain logic in regular classes.
Immutability semantics also differ. Java records freeze references but not the objects they point to: new User(1, "Alice", mutableList) lets external code mutate the list. Defensive copying must be done in the compact constructor. C# records have the same trap. Scala case classes default to immutable collections from the standard library, which dodges the issue at the language level. When porting between ecosystems, audit defensive copying — it does not transfer.
Still Not Working?
Records and Serializable — records implement Serializable correctly if you declare implements Serializable. The serialization uses the canonical constructor for deserialization (not direct field access like regular classes). Custom readObject/writeObject methods are not allowed in records; use readResolve or a custom serializer framework instead.
equals() and hashCode() based on all components — records automatically generate equals() and hashCode() using all record components. If a component is a mutable object (like a List), two records with different list instances but equal content will be equal. If a component is an array, equals() uses reference equality (not Arrays.equals()). For array components, override equals() and hashCode() explicitly.
Records in switch expressions (Java 21+) — records work well with pattern matching in switch:
// Java 21 pattern matching with records
Object shape = new Circle(5.0);
double area = switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
default -> throw new IllegalArgumentException("Unknown shape");
};Records require Java 16+ — records were a preview feature in Java 14-15 and finalized in Java 16. If you’re on an earlier version, use Lombok’s @Value annotation as an alternative: @Value generates an immutable class with a constructor, equals(), hashCode(), and toString().
Records with mutable components leak references — a record like record Cart(String userId, List<Item> items) exposes the same List instance to every caller. If anyone mutates items, every holder of the record sees the change. Defensively copy in the compact constructor: items = List.copyOf(items);. List.copyOf returns an immutable copy that throws on mutation, surfacing the bug rather than silently mutating shared state. The same applies to maps, sets, and arrays — records do not deep-copy on construction.
Record deconstruction patterns require Java 21+ — case Point(int x, int y) -> x + y is a record deconstruction pattern, stable from Java 21 onward (preview earlier). If your project still targets Java 17, you must use the simpler case Point p -> p.x() + p.y() form. Don’t blindly copy switch examples from newer docs — verify your --release target supports the syntax, otherwise javac reports patterns in switch are a preview feature.
For related Java issues, see Fix: Java NullPointerException, Fix: Spring Boot Transaction Not Rolling Back, Fix: Java ClassCastException, and Fix: Java ConcurrentModificationException.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Go Test Not Working — Tests Not Running, Failing Unexpectedly, or Coverage Not Collected
How to fix Go testing issues — test function naming, table-driven tests, t.Run subtests, httptest, testify assertions, and common go test flag errors.
Fix: Kotlin Sealed Class Not Working — when Expression Not Exhaustive or Subclass Not Found
How to fix Kotlin sealed class issues — when exhaustiveness, sealed interface vs class, subclass visibility, Result pattern, and sealed classes across modules.
Fix: OpenTelemetry Not Working — Traces Not Appearing, Spans Missing, or Exporter Connection Refused
How to fix OpenTelemetry issues — SDK initialization order, auto-instrumentation setup, OTLP exporter configuration, context propagation, and missing spans in Node.js, Python, and Java.
Fix: Python contextmanager Not Working — GeneratorExit, Missing yield, or Cleanup Not Running
How to fix Python context manager issues — @contextmanager generator, __enter__ and __exit__, exception handling inside with blocks, async context managers, and common pitfalls.