Java records, introduced as a standard feature in Java 16 (previewed in Java 14), provide a concise way to declare immutable data‑carrying classes. A single‑line record definition replaces the constructor, accessors, equals(), hashCode(), and toString() that you would otherwise write by hand.


The Boilerplate Problem

Consider a traditional Java class for an Address – it requires a lot of repetitive code just to hold a few values:

// Traditional class – lots of boilerplate
public class Address {
    private final String street;
    private final String city;
    private final int zip;

    public Address(String street, String city, int zip) {
        this.street = street;
        this.city = city;
        this.zip = zip;
    }

    public String getStreet() { return street; }
    public String getCity()   { return city; }
    public int    getZip()    { return zip; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Address)) return false;
        Address address = (Address) o;
        return zip == address.zip &&
               Objects.equals(street, address.street) &&
               Objects.equals(city, address.city);
    }

    @Override
    public int hashCode() {
        return Objects.hash(street, city, zip);
    }

    @Override
    public String toString() {
        return "Address{" +
               "street='" + street + '\'' +
               ", city='" + city + '\'' +
               ", zip=" + zip +
               '}';
    }
}

The record equivalent is a single line – the compiler does all the work:

public record Address(String street, String city, int zip) {}

What Records Auto‑Generate

From this concise declaration, the compiler automatically generates:

Generated MemberDescription
Canonical constructor Address(String street, String city, int zip) – assigns all components.
Accessor methods street(), city(), zip() – note the no get prefix.
equals() Compares all components for structural equality.
hashCode() Based on all components, consistent with equals().
toString() Returns a string like Address[street=123 Main St, city=Springfield, zip=12345].

Basic Usage Example

public record Address(String street, String city, int zip) {}

Address addr = new Address("1044 Main Street", "Springfield", 12345);

System.out.println(addr.street());   // 1044 Main Street
System.out.println(addr.city());     // Springfield
System.out.println(addr.zip());      // 12345
System.out.println(addr);            // Address[street=1044 Main Street, city=Springfield, zip=12345]

Address addr2 = new Address("1044 Main Street", "Springfield", 12345);
System.out.println(addr.equals(addr2)); // true

Compact Constructor – Validation Made Easy

A compact constructor lets you add validation without repeating the parameter list. The compiler automatically inserts the field assignments after your validation code:

public record Address(String street, String city, int zip) {

    public Address {
        if (street == null || street.isBlank()) {
            throw new IllegalArgumentException("street cannot be blank");
        }
        if (city == null || city.isBlank()) {
            throw new IllegalArgumentException("city cannot be blank");
        }
        if (zip < 10000 || zip > 99999) {
            throw new IllegalArgumentException("invalid zip code: " + zip);
        }
        // No need to write this.street = street; etc.
        // The compiler adds those assignments after this block.
    }
}

This is much cleaner than writing a full canonical constructor – you only focus on invariants.


Custom Constructors and Overriding Generated Methods

You can also add additional constructors (which must delegate to the canonical constructor) and override generated methods:

public record Person(String name, int age) {

    // Additional constructor with default age
    public Person(String name) {
        this(name, 0); // delegates to canonical constructor
    }

    // Override toString for a custom format
    @Override
    public String toString() {
        return name + " (" + age + " years)";
    }
}

Records Can Have Instance Methods and Implement Interfaces

A record is still a class, so you can add custom instance methods and implement interfaces:

public interface Printable {
    void print();
}

public record Point(int x, int y) implements Printable {

    // Custom method
    public double distanceFromOrigin() {
        return Math.sqrt(x*x + y*y);
    }

    @Override
    public void print() {
        System.out.println("Point(" + x + ", " + y + ")");
    }
}

Point p = new Point(3, 4);
p.print();                     // Point(3, 4)
System.out.println(p.distanceFromOrigin()); // 5.0

Records – What You Can and Cannot Do

AllowedNot Allowed
Implement interfaces Extend a class (records implicitly extend java.lang.Record)
Define static fields and methods Define instance fields beyond the record components
Define instance methods Be extended by another class (records are implicitly final)
Override generated methods Use native methods
Add custom constructors Have mutable components (components are effectively final)

Records as DTOs and Value Objects

Records are ideal for Data Transfer Objects (DTOs) – objects whose only purpose is to carry data between layers. They work seamlessly with JSON libraries (Jackson, Gson, etc.) and are a perfect match for value objects:

// API response DTO
public record UserResponse(long id, String name, String email) {}

// Database query result
public record OrderSummary(long orderId, String status, double total) {}

// Event payload
public record UserCreatedEvent(long userId, String email, Instant createdAt) {}

// Value object in a domain model
public record Money(String currency, BigDecimal amount) {}

Most modern serialization frameworks support records natively (Jackson 2.12+, Gson 2.8.9+), making them a drop‑in replacement for traditional DTO classes.


Records vs Lombok @Data

Both reduce boilerplate, but they serve different purposes. Here’s a quick comparison:

Java RecordLombok @Data
Requires external dependency No – built into Java 16+ Yes – Lombok must be installed
Immutable by default Yes – all components are final No – generates setters (mutable)
Supports inheritance No (records are final) Yes – works with normal class hierarchies
Custom field logic Limited – only via compact constructor and methods Full control – you write the class body
Performance overhead Minimal – no reflection or annotation processing at runtime No runtime overhead – annotations are processed at compile time

Recommendation: Use records for simple, immutable data carriers on Java 16+. Use Lombok for mutable classes, inheritance, or when you need full control over the class structure.


Best Practices

  • Prefer records for DTOs, value objects, and events – they are concise and naturally encourage immutability.
  • Use compact constructors for validation – keep them short and focused on invariants.
  • Override toString() if the default format isn't suitable – but be careful to keep it informative.
  • Add static factory methods for common creation patterns (e.g., Person.of(name, age)).
  • Keep record components small – if a record has many fields (say >10), consider whether it’s a code smell and refactor.
  • Combine with pattern matching (Java 21+) – records work beautifully with deconstruction patterns.

Example: Record with Static Factory and Validation

public record Person(String firstName, String lastName, int age) {

    public Person {
        if (firstName == null || firstName.isBlank()) {
            throw new IllegalArgumentException("First name is required");
        }
        if (lastName == null || lastName.isBlank()) {
            throw new IllegalArgumentException("Last name is required");
        }
        if (age < 0 || age > 150) {
            throw new IllegalArgumentException("Invalid age: " + age);
        }
    }

    // Static factory method
    public static Person of(String firstName, String lastName, int age) {
        return new Person(firstName, lastName, age);
    }

    // Custom method
    public String fullName() {
        return firstName + " " + lastName;
    }

    // Override toString to hide sensitive data if needed
    @Override
    public String toString() {
        return "Person[" + firstName + " " + lastName + ", age=" + age + "]";
    }
}

Summary

Java records revolutionise the way we write simple data classes. They eliminate the boilerplate of constructors, accessors, equals(), hashCode(), and toString() – all with a single line of code. Use records for DTOs, value objects, and event payloads where immutability is a feature. The compact constructor gives you a clean place to add validation, and you can still add custom methods and implement interfaces. Records are implicitly final – they cannot be extended, but that's intentional: they model data, not behaviour.

If you're on Java 16 or later, start using records today – they make your code clearer, safer, and more maintainable.


Happy coding!

No — Java records cannot be extended. They are implicitly final and cannot be subclassed. Attempting to extend a record causes a compile error. This is a deliberate design decision that will not change in future versions.


Proof: Compile‑Time Error

Consider a simple record and a naive attempt to extend it:

record MobilePhone(String brand, String model, int osVersion) {}

// Compile error: cannot inherit from final 'MobilePhone'
class SmartPhone extends MobilePhone {
}

The compiler outputs:

Error: Cannot inherit from final 'MobilePhone'

This is because records are implicitly final – just like enums. The Java Language Specification (§8.1.1.1) explicitly states that a record declaration is implicitly final.


Why Are Records Final? The Design Rationale

The designers of Java (JEP 395) intentionally made records final for several compelling reasons:

  • Component integrity – a record is a transparent carrier of its state components. Allowing subclasses to add extra fields would break the guarantee that “the record’s state is exactly its components.”
  • equals() and hashCode() consistency – the generated equals() compares only the declared components. A subclass with additional fields would violate the Liskov Substitution Principle if it inherited the parent’s equals().
  • Pattern matching and exhaustiveness – sealed type hierarchies and pattern matching rely on knowing all possible subtypes at compile time. If records could be extended arbitrarily, exhaustive switch expressions would become impossible to verify.
  • Simplicity and predictability – records are designed to be simple, value‑based data carriers. Extensibility would add complexity and blur the line between data and behaviour.

The official JEP 395 states: “Records are intended to be simple data carriers. They are not intended to be extended or to serve as building blocks for complex inheritance hierarchies.”


Verifying with Reflection

You can programmatically check that a record is final and that it extends java.lang.Record:

import java.lang.reflect.Modifier;

record MobilePhone(String brand, String model, int osVersion) {}

public class RecordCheck {
    public static void main(String[] args) {
        Class<MobilePhone> clazz = MobilePhone.class;

        System.out.println("Is final:  " + Modifier.isFinal(clazz.getModifiers())); // true
        System.out.println("Is record: " + clazz.isRecord());                       // true
        System.out.println("Superclass: " + clazz.getSuperclass());                // class java.lang.Record
    }
}

The isRecord() method (Java 16+) returns true for any record type. All records implicitly extend java.lang.Record – which is why they also cannot explicitly extend any other class.


What Records Can Do Instead: Implement Interfaces

While records cannot extend classes, they can implement interfaces. This is the primary mechanism for sharing behaviour across records:

interface Describable {
    String describe();
}

record MobilePhone(String brand, String model, int osVersion) implements Describable {
    @Override
    public String describe() {
        return brand + " " + model + " (OS v" + osVersion + ")";
    }
}

record Tablet(String brand, String model, boolean hasStylusSupport) implements Describable {
    @Override
    public String describe() {
        return brand + " " + model + (hasStylusSupport ? " [Stylus]" : "");
    }
}

Describable phone  = new MobilePhone("Samsung", "Galaxy S24", 14);
Describable tablet = new Tablet("Apple", "iPad Pro", true);

System.out.println(phone.describe());  // Samsung Galaxy S24 (OS v14)
System.out.println(tablet.describe()); // Apple iPad Pro [Stylus]

Composition Over Inheritance: Records Containing Records

When you want to build richer data structures, compose records instead of extending them:

record Address(String street, String city, String country) {}

record Person(String name, int age, Address address) {}

Person person = new Person(
    "Alice",
    30,
    new Address("1044 Main St", "Springfield", "USA")
);

System.out.println(person.name());               // Alice
System.out.println(person.address().city());     // Springfield
System.out.println(person);
// Person[name=Alice, age=30, address=Address[street=1044 Main St, city=Springfield, country=USA]]

Composition gives you the same structural relationship as inheritance without violating the record contract – and it’s often more flexible.


Sealed Interfaces + Records: A Powerful Combination

Records pair naturally with sealed interfaces to model closed type hierarchies – exactly where inheritance would have been tempting, but records give a cleaner solution:

sealed interface Device permits MobilePhone, Tablet, Laptop {}

record MobilePhone(String brand, String model) implements Device {}
record Tablet(String brand, boolean hasPen)    implements Device {}
record Laptop(String brand, int ramGb)         implements Device {}

// Exhaustive switch – the compiler verifies all cases are covered
String describeDevice(Device d) {
    return switch (d) {
        case MobilePhone p -> "Phone: " + p.brand() + " " + p.model();
        case Tablet t      -> "Tablet: " + t.brand() + (t.hasPen() ? " (with pen)" : "");
        case Laptop l      -> "Laptop: " + l.brand() + " (" + l.ramGb() + "GB RAM)";
    };
}

This pattern is increasingly popular in modern Java (especially with pattern matching in Java 21+) and provides exhaustive, type‑safe branching.


Comparison with Other Languages

Records are not the only immutable data carriers in the JVM ecosystem:

Language / FeatureExtensible?ImmutabilityNotes
Java Records ❌ No (implicitly final) ✅ Yes (all components final) Built into Java 16+
Kotlin Data Classes ❌ No (by default, can be open with care) ⚠️ Components can be var (mutable) or val More flexible, but can break equals() contract if extended
Scala Case Classes ❌ No (implicitly final, can be abstract but rarely) ✅ Yes (parameters are val by default) Similar to records, but with more features
Lombok @Data ✅ Yes (standard class) ❌ No (generates setters) Not a language feature, requires annotation processing

Can I Work Around This Limitation?

Since records are final, you cannot extend them directly. However, you have several alternatives:

  • Use a wrapper class – create a regular class that holds a record as a field. This gives you extensibility but introduces extra boilerplate.
  • Use a sealed hierarchy – define a sealed interface or abstract class and let each implementation be a record. This models a closed family of types without inheritance from a record.
  • Refactor to composition – if you need additional data, compose records inside larger records.
  • Use interfaces – define behaviour in interfaces, implement them in multiple records.

The wrapper approach is not recommended for most cases because it breaks the simplicity of records and often adds unnecessary complexity. The sealed‑interface approach is the idiomatic way to model a closed set of variants.


Best Practices

  • Accept that records are final – design your data models with composition and interfaces in mind.
  • Use sealed interfaces with records for domain hierarchies that would otherwise tempt you to extend records.
  • Prefer composition over inheritance – nest records inside records to build complex aggregates.
  • Implement interfaces to share behaviour across records.
  • Do not try to extend records via bytecode manipulation – it will break the invariants and may cause runtime errors.

Summary

Java records are implicitly final and cannot be extended – by design. This ensures component integrity, consistent equals()/hashCode(), and enables exhaustive pattern matching. Instead of inheritance, use interfaces to share behaviour, composition to build aggregates, and sealed interfaces to model closed type hierarchies. The language gives you all the tools you need to design rich, safe, and maintainable data models – just not through subclassing records.

Embrace the finality – it’s a feature, not a bug.


Happy coding!