Java Records: wann man sie nutzen sollte und wann nicht
1. Überblick
Vor Java 14 musste man, um eine Klasse zu haben, die nur unveränderliche Daten enthält, selbst Constructor, Getter, equals(), hashCode(), toString() schreiben – lauter sich wiederholender Boilerplate. Record löst genau dieses Problem: Du deklarierst eine Zeile, und Java generiert automatisch alles.
Dieser Artikel führt dich durch die grundlegende Syntax von Records, wie du Validierung anpasst, wie du Records mit normalen Klassen und Lombok vergleichst, und zeigt genau auf, wann du Records verwenden solltest und wann nicht. Nachdem du fertig bist, wirst du wissen, wie du Records richtig in einer echten Spring Boot-Anwendung einsetzt.
2. Was ist ein Record?
Record ist eine spezielle Art von Klasse, die verwendet wird, um unveränderliche Daten (immutable data) zu modellieren. Die Deklaration eines Records entspricht der Definition seiner “Komponenten”:
public record Point(int x, int y) {
}
Nur mit dieser einen Zeile generiert der Compiler automatisch:
- Einen Constructor, der
x,yakzeptiert. - Accessor-Methoden
x()undy()(ohneget-Präfix). equals(),hashCode()basierend auf allen Komponenten.toString()in der FormPoint[x=1, y=2].
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
System.out.println(p1);
System.out.println(p1.equals(p2));
System.out.println(p1.x());
Point[x=1, y=2]
true
1
Hinweis: Records sind automatisch
final– ein Record kann nicht erweitert werden, und seine Felder sind auchfinal, können nach der Initialisierung nicht neu zugewiesen werden.
3. Komponenten eines Records
3.1. Constructor und Validierung
Records haben einen kanonischen Constructor (canonical constructor), der automatisch generiert wird, aber du kannst ihn überschreiben, um Validierung hinzuzufügen. Das wird kompakter Constructor genannt – du brauchst die Parameter nicht erneut zu deklarieren:
public record Order(String id, BigDecimal amount) {
public Order {
if (amount == null || amount.signum() < 0) {
throw new IllegalArgumentException("amount muss >= 0 sein");
}
}
}
Die Zeile public Order { ... } hat keine Parameterliste – sie teilt sich die Parameter mit dem Record. Die Validierung läuft vor der Feldzuweisung ab, sodass du ungültige Daten bereits bei der Objekterstellung blockierst.
new Order("O1", new BigDecimal("-10"));
Exception in thread "main" java.lang.IllegalArgumentException: amount muss >= 0 sein
3.2. Benutzerdefinierte Methoden hinzufügen
Records können Methoden, statische Felder und statische Methoden wie normale Klassen enthalten:
public record Money(String currency, BigDecimal value) {
public Money add(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalStateException("Unterschiedliche Währungen");
}
return new Money(currency, value.add(other.value));
}
public static Money zero(String currency) {
return new Money(currency, BigDecimal.ZERO);
}
}
Da value final ist, ändert die Methode add() das aktuelle Objekt nicht – sie gibt ein neues Money-Objekt zurück. Dies entspricht dem Prinzip der Unveränderlichkeit, auf das Records abzielen.
3.3. Interfaces implementieren
Records können Interfaces implementieren (können aber keine andere Klasse erweitern):
public interface Shape {
double area();
}
public record Rectangle(double width, double height) implements Shape {
@Override
public double area() {
return width * height;
}
}
4. Records in Spring Boot: DTOs und Request/Response
Der häufigste Anwendungsfall für Records ist ein DTO – Daten, die über eine REST-API ein- und ausgehen.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
public record CustomerRequest(
@NotBlank String name,
@Email String email
) {
}
@RestController
@RequestMapping("/customers")
public class CustomerController {
@PostMapping
public ResponseEntity<CustomerResponse> create(@Valid @RequestBody CustomerRequest request) {
Customer saved = new Customer(1L, request.name(), request.email());
return ResponseEntity.ok(new CustomerResponse(saved.id(), saved.name()));
}
}
public record CustomerResponse(Long id, String name) {
}
Jackson (das von Spring Boot vorkonfiguriert ist) deserialisiert JSON zu Records über den kanonischen Constructor, ohne dass Setter nötig sind. Bean Validation Annotations (@NotBlank, @Email) können direkt auf den Record-Parametern platziert werden und funktionieren normal, da Spring diese Annotations im Constructor automatisch erkennt.
POST /customers
{"name": "", "email": "a@b.com"}
HTTP 400 Bad Request
Hinweis: Wenn du Records als JPA-Entity verwendest, wirst du auf Probleme stoßen – JPA benötigt einen no-arg Constructor und veränderbare Felder für Lazy-Loading. Records unterstützen dies nicht, daher solltest du Records nicht als
@Entityverwenden.
5. Records im Vergleich zu normalen Klassen
| Kriterium | Normale Klasse | Record |
|---|---|---|
| Boilerplate | Getter, equals, hashCode, toString selbst schreiben |
Automatisch generiert |
| Veränderbar | Ja | Nein (Felder sind immer final) |
| Vererbung | Kann andere Klassen erweitern | Kann nicht erweitert werden (implizit final) |
| Geeignet für | Entities, Klassen mit veränderlichem State | DTOs, Value Objects, unveränderliche Daten |
Direkter Vergleich über das gleiche Value Object FullName:
// Alte Schreibweise
public final class FullNameOld {
private final String first;
private final String last;
public FullNameOld(String first, String last) {
this.first = first;
this.last = last;
}
public String getFirst() { return first; }
public String getLast() { return last; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof FullNameOld other)) return false;
return Objects.equals(first, other.first) && Objects.equals(last, other.last);
}
@Override
public int hashCode() {
return Objects.hash(first, last);
}
@Override
public String toString() {
return "FullNameOld[first=" + first + ", last=" + last + "]";
}
}
// Schreibweise mit Record
public record FullName(String first, String last) {
}
Diese beiden Klassen verhalten sich gleichwertig, aber der Record ist viel kürzer und es besteht nicht die Möglichkeit, equals/hashCode falsch zu schreiben (ein klassischer Fehler beim manuellen Programmieren).
6. Records im Vergleich zu Lombok @Value
Viele Teams haben Lombok @Value verwendet, um Boilerplate vor der Einführung von Records zu reduzieren. Diese beiden Ansätze lösen ähnliche Probleme, unterscheiden sich aber grundlegend:
// Lombok
@Value
public class ProductDto {
String sku;
BigDecimal price;
}
// Record
public record ProductDto(String sku, BigDecimal price) {
}
Hauptunterschiede:
- Records sind eine native Java-Funktion, benötigen keine zusätzliche Abhängigkeit und keinen Annotation Processor.
- Records generieren Accessor-Methoden in der Form
sku()(ohneget), während LombokgetSku()generiert. - Records unterstützen keine Vererbung oder einfache Erweiterung mit Feldern über das Builder-Pattern wie Lombok (
@Builderist immer noch verbreiteter für Objekte mit vielen optionalen Feldern).
Hinweis: Wenn dein Projekt bereits Lombok weit verbreitet nutzt und
@Builderfür DTOs mit vielen Feldern benötigt, kannst du Lombok für solche Fälle behalten. Records sind besser geeignet, wenn DTOs einfach sind, wenige Felder haben und keinen Builder benötigen.
7. Wann solltest du Records verwenden
Verwende Records, wenn deine Daten:
- Ihrer Natur nach unveränderlich sind: Koordinaten, Geldbeträge, Zeitspannen, Rechenergebnisse.
- DTOs zwischen verschiedenen Layern: Request/Response einer API, Daten, die von einer Query zurückgegeben werden, Nachrichten, die über eine Queue gesendet werden.
- Value Objects im Domain Model:
Money,EmailAddress,Coordinates– Dinge, die nach Wert verglichen werden, nicht nach Identität. - Temporäre Ergebnisse in der Verarbeitungslogik: Mehrere Werte von einer Methode zurückgeben, ohne eine aufwendige separate Klasse zu erstellen.
public record ValidationResult(boolean valid, List<String> errors) {
public static ValidationResult ok() {
return new ValidationResult(true, List.of());
}
}
8. Wann solltest du Records NICHT verwenden
8.1. JPA-Entities
Wie bereits in Abschnitt 4 erwähnt, benötigt JPA veränderliche Entities mit einem no-arg Constructor und Unterstützung für Proxies für Lazy Loading. Records erfüllen diese Anforderungen nicht.
// NICHT so machen
@Entity
public record Customer(@Id Long id, String name) {
}
Hibernate wird Fehler bekommen oder sich unerwartet verhalten, wenn es versucht, einen Proxy für einen Record zu erstellen. Verwende eine normale Klasse (oder Lombok @Entity + @Getter/@Setter) für Entities.
8.2. Objekte, deren State sich ständig ändert
Wenn dein Objekt während seines Lebenszyklus seinen State häufig ändert (z.B. ShoppingCart, dem Items hinzugefügt/entfernt werden), zwingt dich die Record-Struktur, ein neues Objekt jedes Mal zu erstellen, wenn sich etwas ändert – das ist verschwenderisch und unnatürlich.
// Nicht sinnvoll: neuen Cart jedes Mal erstellen, wenn Item hinzugefügt wird
public record ShoppingCart(List<Item> items) {
public ShoppingCart addItem(Item item) {
List<Item> newItems = new ArrayList<>(items);
newItems.add(item);
return new ShoppingCart(newItems);
}
}
In diesem Fall ist eine normale veränderliche Klasse mit einer List<Item> und einer addItem()-Methode, die direkt ändert, natürlicher und effizienter.
8.3. Vererbung erforderlich
Records sind implizit final und können keine andere Klasse erweitern (implementieren nur Interfaces). Wenn dein Domain Model eine Klassenhierarchie benötigt (Employee → Manager, Developer), sind Records nicht geeignet – verwende normale Klassen oder kombiniere sealed interfaces/Klassen mit Records für jeden Leaf-Branch.
public sealed interface Shape permits Circle, Square {}
public record Circle(double radius) implements Shape {}
public record Square(double side) implements Shape {}
Dies ist die sinnvolle Nutzung von Records in einer Hierarchie: Records als unveränderliche Leaf-Branches, sealed interface zur Verwaltung der Typenmenge.
8.4. Komplexe Serialisierung erforderlich
Records implementieren Serializable, aber wenn du komplexe benutzerdefinierte Serialisierung benötigst (Versioning, Rückwärtskompatibilität über viele Jahre), ist eine normale Klasse mit detaillierterem Kontrol immer noch sicherer.
9. Records testen
Da equals()/hashCode() automatisch basierend auf den Komponenten generiert werden, ist das Testen sehr einfach mit AssertJ:
class MoneyTest {
@Test
void shouldBeEqualWhenSameValues() {
Money a = new Money("VND", new BigDecimal("1000"));
Money b = new Money("VND", new BigDecimal("1000"));
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
void shouldThrowWhenAddingDifferentCurrency() {
Money vnd = new Money("VND", BigDecimal.TEN);
Money usd = new Money("USD", BigDecimal.ONE);
assertThatThrownBy(() -> vnd.add(usd))
.isInstanceOf(IllegalStateException.class)
.hasMessage("Unterschiedliche Währungen");
}
}
assertThat(a).isEqualTo(b) überprüft das richtige Verhalten des automatisch generierten equals() – zwei Records mit gleichen Komponentenwerten müssen gleich sein, auch wenn es zwei verschiedene Instanzen im Memory sind.
10. Fazit
Records sind das beste Werkzeug, das Java für unveränderliche Daten bietet: weniger Code, weniger Fehler, klare Absicht. Verwende sie für DTOs, Value Objects und temporäre Rückgabeergebnisse. Verwende sie nicht für JPA-Entities, Objekte mit häufig änderndem State oder wenn du komplexe Klassenhierarchien durch Vererbung benötigst. Wähle das richtige Werkzeug für die richtige Aufgabe – Records ersetzen normale Klassen nicht vollständig, sie bieten einfach eine prägnantere Alternative für eine bestimmte Gruppe von Problemen.