Skip to content

Fix: Java Record Not Working — Compact Constructor Error, Serialization Fails, or Cannot Extend

FixDevs · (Updated: )

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 expected

Or 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 User

Or 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, x and y are 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());  // Alice

If 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 needed

Custom 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 Smith

Fix 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles