Skip to content

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

FixDevs ·

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.

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
) {}

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().

For related Java issues, see Fix: Java NullPointerException and Fix: Spring Boot Transaction Not Rolling Back.

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