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 Member | Description |
|---|---|
| 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
| Allowed | Not 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 Record | Lombok @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()andhashCode()consistency – the generatedequals()compares only the declared components. A subclass with additional fields would violate the Liskov Substitution Principle if it inherited the parent’sequals().- 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
switchexpressions 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 / Feature | Extensible? | Immutability | Notes |
|---|---|---|---|
| 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!