Fix: Java Record Not Working — Compact Constructor Error, Serialization Fails, or Cannot Extend
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.
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
) {}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.
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.