Back to Blog
Java

Java Record Inheritance: What Works and What Doesn't

java record inheritance: Learn how Java records handle inheritance: why they are final, how they implement interfaces, and when to use sealed hierarchies or composition.

Java recordsinheritancesealed interfacescompositionJava 17
Diagram showing a Java record implementing an interface while being final, with composition alternatives.

java record inheritance requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

Java records, introduced as a final feature in Java 16, provide a compact way to declare immutable data carriers. A common question is whether records support inheritance. The short answer is: records cannot extend other classes, but they can implement interfaces. This article explains the exact rules, shows how to build sealed hierarchies with records, and discusses composition as an alternative when you need shared behavior or state.

The Inheritance Rule for Records

A record declaration implicitly extends java.lang.Record. Because Java does not allow multiple class inheritance, a record cannot extend any other class. The compiler enforces this: if you try to write record MyRecord extends SomeClass(...), you get a compile-time error. This restriction is fundamental to the design of records, which are meant to be transparent carriers of data rather than participants in a polymorphic class hierarchy.

What records can do is implement one or more interfaces. This is the primary way to introduce abstraction and polymorphism with records. For example:

public interface Shape { double area(); } public record Circle(double radius) implements Shape { @Override public double area() { return Math.PI * radius * radius; } }

The record Circle implements the Shape interface and provides an implementation for area(). This works because records are allowed to implement interfaces, and the compiler generates the equals, hashCode, and toString methods based on the record components.

Why Records Are Implicitly Final

Records are implicitly final, meaning they cannot be subclassed. This is by design. The canonical constructor, accessors, equals, hashCode, and toString are all derived from the record components. If a subclass could add fields or override behavior, the guarantees that records provide—such as immutability and value-based equality—would be compromised.

Consider a scenario where you want to extend a record with additional state. For example, you might want a ColoredCircle that adds a color field. Because records are final, you cannot write record ColoredCircle extends Circle(...). The compiler rejects it. This forces you to think about whether inheritance is the right tool or whether composition or a sealed interface would be more appropriate.

The finality of records also means that you cannot use them as base classes for other records. This is a deliberate trade-off: records prioritize simplicity and predictability over extensibility.

Implementing Interfaces with Records

Implementing interfaces is the most common way to introduce polymorphism with records. Interfaces can define abstract methods, default methods, and static methods. Records can implement any number of interfaces, and they must provide implementations for all abstract methods unless those methods have default implementations.

Here is an example with multiple interfaces:

public interface Named { String name(); } public interface Describable { default String describe() { return "A " + name(); } } public record Person(String name, int age) implements Named, Describable { @Override public String name() { return name; } }

In this example, Person implements both Named and Describable. The describe() method is inherited from the default implementation in Describable, while name() is implemented by the record's accessor. This pattern allows you to define behavior that is shared across different record types without using class inheritance.

A key advantage of using interfaces with records is that you can write generic code that operates on the interface type, not the concrete record type. For instance, you can have a method that accepts a Named and works with any record that implements it.

Sealed Interfaces and Record Hierarchies

Sealed interfaces, introduced in Java 17, provide a way to restrict which types can implement an interface. This pairs naturally with records because records are final. By sealing an interface, you can create a closed hierarchy of record types, which is useful for modeling a fixed set of alternatives.

public sealed interface Payment permits CreditCardPayment, PayPalPayment, BankTransferPayment { double amount(); } public record CreditCardPayment(String cardNumber, double amount) implements Payment { @Override public double amount() { return amount; } } public record PayPalPayment(String email, double amount) implements Payment { @Override public double amount() { return amount; } } public record BankTransferPayment(String iban, double amount) implements Payment { @Override public double amount() { return amount; } }

The sealed interface Payment lists exactly three permitted implementations, all of which are records. This gives you exhaustive pattern matching in switch expressions:

public String describePayment(Payment payment) { return switch (payment) { case CreditCardPayment cc -> "Credit card ending " + cc.cardNumber().substring(cc.cardNumber().length() - 4); case PayPalPayment pp -> "PayPal account " + pp.email(); case BankTransferPayment bt -> "Bank transfer to IBAN " + bt.iban(); }; }

Because the hierarchy is sealed, the compiler can verify that the switch is exhaustive. This is a powerful pattern for domain modeling where you have a fixed set of variants, and records are a natural fit because they are immutable and final.

Composition as an Alternative to Inheritance

When you need to share behavior or state across multiple record types, composition is often a better choice than inheritance. Since records cannot extend classes, you can achieve reuse by having a record contain another object that encapsulates the shared logic.

For example, suppose you have several record types that all need a common createdAt timestamp and a method to check if they are recent. Instead of trying to inherit from a base record, you can create a separate component:

public record Timestamped<T>(T value, Instant createdAt) { public boolean isRecent(Duration maxAge) { return createdAt.isAfter(Instant.now().minus(maxAge)); } } public record Order(String id, double total, Timestamped<Order> timestamped) { public boolean isRecent(Duration maxAge) { return timestamped.isRecent(maxAge); } }

This approach keeps each record focused on its own data while delegating shared behavior to a contained object. It also avoids the complexity of a deep inheritance hierarchy, which is often harder to maintain.

Another composition pattern is to use an interface with default methods, as shown earlier. That gives you behavior reuse without state sharing. If you need to share state, composition is the way to go.

Choosing Between Records and Classes for Reuse

The decision to use records or traditional classes for polymorphic behavior depends on your requirements. The table below summarizes the key differences:

CriterionRecordClass
Extends another classNoYes
Implements interfacesYesYes
SubclassableNo (final)Yes (unless final)
State inheritanceNoYes
ImmutabilityEnforced by designOptional
Equality semanticsValue-basedIdentity-based unless overridden
Typical useData carriersFull object modeling

Use records when you need a simple, immutable data holder with value-based equality. Use a class when you need to model behavior that requires mutable state, inheritance, or complex lifecycle management. For polymorphism, prefer interfaces with records; for shared implementation, prefer composition or default methods.

A common mistake is trying to force records into a class hierarchy because it feels familiar. This often leads to awkward designs. Instead, step back and consider what you are trying to achieve. If you need a fixed set of variants, sealed interfaces with records are a clean solution. If you need to share code, composition is more flexible than inheritance and avoids the tight coupling that class hierarchies introduce.

Common Mistakes and Maintainability Concerns

One frequent error is attempting to declare a record with a superclass, which results in a compile-time error. Another is forgetting that records cannot have instance fields other than the components, so any additional state must be derived from the components or stored in a separate object. This constraint can be surprising when you are used to classes.

Maintainability also suffers when developers overuse interfaces with records to simulate inheritance. If you have many interfaces with default methods that all depend on the same internal state, you may end up with duplicated logic. In such cases, composition with a shared component is often clearer.

Another concern is that records are not designed for lazy initialization or caching of derived values. If you need to cache a computed value, you cannot add a field to the record. You would have to store the cache externally or use a class. This is a deliberate trade-off to keep records simple and immutable.

When you use sealed interfaces with records, be aware that adding a new permitted subtype is a breaking change. This is fine for a closed domain, but if your model is expected to evolve, a sealed hierarchy may be too restrictive. In that case, a non-sealed interface or a class hierarchy might be more appropriate.

Finally, remember that records are a Java 16+ feature. If you are working on a codebase that targets an older Java version, you cannot use records at all. For Java 17 and later, sealed interfaces are also available, which makes the combination of records and sealed types a powerful tool for modern Java development.

java record inheritance: Practical Usage and Code Examples | RYUSLOG DEV