Java Record Implements Interface: Syntax and Use Cases
Learn how Java records can implement interfaces, how generated accessors map to interface methods, and when to use a record instead of a class.
When a Java record implements an interface, it combines two language features: the concise data-carrier semantics of records and the contract-based abstraction of interfaces. Records became a standard feature in Java 16 as final classes whose state is described by components, and they automatically generate equals, hashCode, toString, and accessor methods. An interface, on the other hand, defines a set of methods that a class must implement. A record can implement an interface just like any other class, but the interaction between record components and interface methods requires attention to how the generated accessors map to the interface contract.
Basic Syntax for a Record Implementing an Interface
The syntax is straightforward: after the record name and components, add implements followed by the interface name. For example:
public interface Named { String name(); } public record Person(String name, int age) implements Named { }
The record Person automatically provides an accessor method name() that matches the interface method. Because the record component name generates a public accessor with the same name, parameter list, and return type, the record satisfies the interface without any extra code. The age component is not part of the interface, but that does not matter.
You can implement multiple interfaces as well:
public interface AgeAware { int age(); } public record Employee(String name, int age, String department) implements Named, AgeAware { }
Here both name() and age() are satisfied by the derived accessors. This is the most common pattern: a record acts as an immutable data holder that conforms to one or more interface contracts.
Why Records Fit Interface Contracts for Data
Records are designed to be transparent carriers of immutable data. When you define an interface that describes data access (like name() or age()), a record provides a natural implementation because its accessors are public and derived from the components. The interface can be used to decouple code from the concrete record type. For example, a method can accept a Named instead of a specific Person or Employee, allowing any record that implements Named to be passed. This promotes polymorphism without sacrificing the immutability and value semantics that records offer.
A common use case is defining a repository interface that returns a record:
public interface UserSummary { String username(); String email(); } public record UserSummaryRecord(String username, String email) implements UserSummary { }
This pattern is useful when you want to expose a read-only view of data from a service layer. The record is immutable, so the caller cannot modify the data after retrieval.
Mapping Accessor Methods to Interface Methods
The generated accessor for a record component is a public method with the same name and return type as the component. For the record to implement an interface, the interface method must be compatible with that accessor: the method name, parameter list, and return type must line up. If the interface method returns a supertype of the component type, the generated accessor's covariant return is still a valid implementation.
public interface Labeled { String label(); } public record Item(String label, int quantity) implements Labeled { }
This compiles because label() returns String. If the interface instead declared Object label(), the generated String label() would still satisfy the interface as a covariant override. A mismatch only occurs when the required return type is not compatible with the component type. In practice, design interface methods to match the component types you intend to expose.
When the interface method has parameters, the record must provide an explicit implementation because the generated accessor takes no arguments. For instance:
public interface Describable { String describe(String prefix); } public record Product(String name, double price) implements Describable { @Override public String describe(String prefix) { return prefix + name + " (" + price + ")"; } }
Here the record must implement describe manually because it is not a simple component accessor. This is a normal method implementation, and you can use the record's components inside it.
Custom Implementations and Default Methods
Sometimes you want to implement an interface method in a way that is not a direct component accessor. You can declare the method explicitly in the record body, either as an additional method or with the same name as a generated accessor. For example, you might want to return a formatted value:
public interface Formatted { String display(); } public record Temperature(double celsius) implements Formatted { @Override public String display() { return celsius + "°C"; } }
The record still has a celsius() accessor, but the display() method is custom. An explicit implementation must still be compatible with the interface method. If it has the same name and parameter list as a generated accessor, its return type must match the component's declared type.
Default methods in interfaces work as expected. If the interface provides a default implementation, the record inherits it unless it overrides it:
public interface Greeter { String name(); default String greet() { return "Hello, " + name(); } } public record Guest(String name) implements Greeter { }
Guest automatically gets greet() from the default method, which calls the generated name() accessor. This is a clean way to add behavior to a data record without repeating code.
Canonical Constructor and Interface Validation
One of the strengths of records is the canonical constructor, which you can use to validate or normalize data. This constructor is invoked when the record is instantiated. You can combine it with an interface to enforce invariants. For example:
public interface PositiveAge { int age(); } public record Person(String name, int age) implements PositiveAge { public Person { if (age < 0) { throw new IllegalArgumentException("Age cannot be negative"); } } }
The canonical constructor ensures that every Person instance has a non-negative age. This validation happens before the record is used, so any code that receives a PositiveAge can trust that the age is valid. This is especially valuable when records are used as DTOs or value objects in a domain model.
Compatibility and Serialization Considerations
Records have well-defined serialization behavior: the serialized form is based on the record components, and deserialization invokes the canonical constructor. As a result, validation in the canonical constructor runs again when a record is deserialized. Additional methods declared by an interface are not serialized; they are computed from component values when called. If you rely on Java serialization with records, test with the Java version you target.
Another compatibility concern is evolving the interface. If you add a new method to an interface that a record implements, the record will no longer compile unless you provide an implementation. This is a breaking change for any implementing record. To avoid this, consider using default methods for new behavior that can be derived from existing components.
Common Pitfalls and How to Avoid Them
One common mistake is trying to implement an interface that requires a mutable setter method. Records are immutable, so they cannot provide a method like void setName(String name). If your interface requires mutation, use a class instead.
Another pitfall is assuming that the generated equals and hashCode match every interface's expectations. Record equality compares all components, so two records with the same component values are equal. If you need a different equality contract, you can declare equals and hashCode explicitly, but doing so replaces the generated value-based behavior. It is usually better to keep records as value objects and model identity-based equality separately.
Record classes are also final, so you cannot subclass a record. If an interface is designed for a class hierarchy in which implementations are expected to be extended, a record is the wrong tool.
Choosing Between a Record and a Class for an Interface
Use a record when the primary purpose is to carry immutable data and the interface methods align with component accessors. This is common for DTOs, query results, and domain value objects. Use a class when you need mutable state, methods that modify internal state, or a superclass other than java.lang.Record. Records can declare extra constructors, but they must delegate to the canonical constructor. Also, if you need to extend a base class, records cannot do that because they are final and their direct superclass is always java.lang.Record.
Consider the following decision criteria:
| Criterion | Record | Class |
|---|---|---|
| Immutability | Built-in | Requires manual design |
| Boilerplate | Minimal | More boilerplate |
| Subclassing | Not allowed | Allowed |
Custom equals/hashCode | Discouraged but possible | Fully flexible |
| Best for | Data carriers | Behavioral types |
In practice, if your interface is purely a contract for reading data, a record is often the better choice. If the interface includes mutation methods or complex behavior, a class is more appropriate.
Performance Notes
The generated accessors are simple field reads, so calling them is usually no more expensive than calling a getter on a class. The canonical constructor runs on every instantiation, including during deserialization, so any validation in it has a cost. In ordinary applications, this is rarely a reason to choose records over classes.
Sealed Interfaces and Records
Sealed interfaces, introduced in Java 17, work well with records. You can restrict which records can implement a sealed interface, ensuring that all implementations are known at compile time. For example:
public sealed interface Shape permits Circle, Rectangle { double area(); } public record Circle(double radius) implements Shape { @Override public double area() { return Math.PI * radius * radius; } } public record Rectangle(double width, double height) implements Shape { @Override public double area() { return width * height; } }
With a sealed interface, the compiler knows the full set of permitted types. This makes exhaustive handling possible in pattern-matching switch expressions in Java versions that support them, and it makes the domain model easier to reason about. When you combine sealed interfaces with records, you get a concise way to model domain hierarchies without the overhead of a class hierarchy. The records remain immutable, and the sealed interface guarantees that no unknown implementations can appear at runtime. This is particularly useful in domain-driven design and when modeling state machines.