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, y akzeptiert.
  • Accessor-Methoden x() und y() (ohne get-Präfix).
  • equals(), hashCode() basierend auf allen Komponenten.
  • toString() in der Form Point[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 auch final, 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 @Entity verwenden.

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() (ohne get), während Lombok getSku() generiert.
  • Records unterstützen keine Vererbung oder einfache Erweiterung mit Feldern über das Builder-Pattern wie Lombok (@Builder ist immer noch verbreiteter für Objekte mit vielen optionalen Feldern).

Hinweis: Wenn dein Projekt bereits Lombok weit verbreitet nutzt und @Builder fü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.

javarecordsjava-21spring-bootbest-practices