Java Records : quand les utiliser et quand les éviter

1. Aperçu général

Avant Java 14, pour avoir une classe contenant uniquement des données immuables, vous deviez écrire vous-même le constructeur, les getters, equals(), hashCode(), toString() — rien que du boilerplate répétitif. Un record résout exactement ce problème : vous déclarez une ligne, Java génère tout automatiquement.

Cet article parcourt la syntaxe basique du record, comment personnaliser la validation, une comparaison avec une classe ordinaire et Lombok, puis montre clairement quand utiliser un record et quand ce n’est pas approprié. Après la lecture, vous saurez comment appliquer les records au bon endroit dans une application Spring Boot réelle.

2. Qu’est-ce qu’un record

Un record est un type de classe spécial, utilisé pour modéliser des données immuables (immutable data). Déclarer un record revient à définir ses « composants » (components) :

public record Point(int x, int y) {
}

Avec seulement cette ligne, le compilateur génère automatiquement :

  • Un constructeur qui accepte x, y.
  • Des accesseurs x() et y() (pas de préfixe get).
  • equals(), hashCode() basés sur tous les composants.
  • toString() au format 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

Note : Un record est automatiquement final — vous ne pouvez pas le sous-classer, et ses champs sont aussi final, impossible de les réassigner après l’initialisation.

3. Les composants du record

3.1. Constructeur et validation

Un record a un constructeur canonique (canonical constructor) généré automatiquement, mais vous pouvez le réécrire pour ajouter une validation. C’est ce qu’on appelle un compact constructor — pas besoin de redéclarer les paramètres :

public record Order(String id, BigDecimal amount) {
    public Order {
        if (amount == null || amount.signum() < 0) {
            throw new IllegalArgumentException("amount doit être >= 0");
        }
    }
}

La ligne public Order { ... } n’a pas de liste de paramètres — elle partage les paramètres du record. La validation s’exécute avant que les champs ne soient assignés, donc vous pouvez rejeter les données invalides dès la création de l’objet.

new Order("O1", new BigDecimal("-10"));
Exception in thread "main" java.lang.IllegalArgumentException: amount doit être >= 0

3.2. Ajouter des méthodes personnalisées

Un record peut toujours contenir des méthodes, des champs statiques, des méthodes statiques comme une classe ordinaire :

public record Money(String currency, BigDecimal value) {

    public Money add(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalStateException("Devises différentes");
        }
        return new Money(currency, value.add(other.value));
    }

    public static Money zero(String currency) {
        return new Money(currency, BigDecimal.ZERO);
    }
}

Puisque value est final, la méthode add() ne modifie pas l’objet actuel — elle retourne un nouveau Money. C’est l’essence même de l’immuabilité que les records encouragent.

3.3. Implémenter une interface

Un record peut implémenter une interface (mais ne peut pas étendre une autre classe) :

public interface Shape {
    double area();
}

public record Rectangle(double width, double height) implements Shape {
    @Override
    public double area() {
        return width * height;
    }
}

4. Records dans Spring Boot : DTOs et requêtes/réponses

Le cas d’usage le plus courant du record est le DTO — les données transmises via une API REST.

<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 (pré-configuré par Spring Boot) désérialise le JSON en record via le constructeur canonique, sans besoin de setters. Les annotations Bean Validation (@NotBlank, @Email) placées directement sur les paramètres du record fonctionnent normalement car Spring détecte ces annotations au niveau du constructeur.

POST /customers
{"name": "", "email": "a@b.com"}

HTTP 400 Bad Request

Note : Si vous utilisiez un record comme entité JPA, vous rencontreriez des problèmes — JPA nécessite un constructeur sans arguments et des champs mutables pour que le lazy-loading fonctionne. Les records ne supportent pas cela, donc n’utilisez pas de records comme @Entity.

5. Records en comparaison avec une classe ordinaire

Critère Classe ordinaire Record
Boilerplate À écrire manuellement : getter, equals, hashCode, toString Généré automatiquement
Mutable Possible Non (champs toujours final)
Héritage Peut hériter d’une autre classe Ne peut pas hériter (final implicite)
Approprié pour Entités, classes avec état mutable DTOs, value objects, données immuables

Comparaison concrète avec un exemple du même value object FullName :

// Ancienne approche
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 + "]";
    }
}
// Approche avec record
public record FullName(String first, String last) {
}

Ces deux classes sont équivalentes en termes de comportement, mais le record est beaucoup plus court et élimine le risque de mal implémenter equals/hashCode (une erreur classique quand on écrit le code manuellement).

6. Records en comparaison avec Lombok @Value

Beaucoup d’équipes utilisaient Lombok @Value pour réduire le boilerplate avant l’arrivée des records. Ces deux approches résolvent un problème similaire, mais elles sont fondamentalement différentes :

// Lombok
@Value
public class ProductDto {
    String sku;
    BigDecimal price;
}
// Record
public record ProductDto(String sku, BigDecimal price) {
}

Différences principales :

  • Un record est une fonctionnalité native de Java, pas de dépendance supplémentaire, pas d’annotation processor.
  • Un record génère des accesseurs comme sku() (pas de get), tandis que Lombok génère getSku().
  • Un record ne supporte pas l’héritage ou l’ajout de champs optionnels via un builder pattern aussi simplement que Lombok (@Builder est toujours plus populaire pour les objets avec beaucoup de champs optionnels).

Note : Si votre projet utilise déjà Lombok largement et que vous avez besoin de @Builder pour les DTOs avec beaucoup de champs, vous pouvez garder Lombok pour ces cas. Les records sont plus appropriés quand le DTO est simple, avec peu de champs, et n’a pas besoin de builder.

7. Quand utiliser un record

Utilisez un record quand vos données :

  • Sont naturellement immuables : coordonnées, montants, intervalles de temps, résultats de calculs.
  • DTOs transmis entre les couches : requêtes/réponses d’API, données retournées par des requêtes, messages envoyés via une queue.
  • Value objects dans le modèle de domaine : Money, EmailAddress, Coordinates — des choses comparées par valeur, pas par identité.
  • Résultats temporaires dans la logique de traitement : regrouper plusieurs valeurs retournées par une méthode sans avoir besoin de créer une classe séparée.
public record ValidationResult(boolean valid, List<String> errors) {
    public static ValidationResult ok() {
        return new ValidationResult(true, List.of());
    }
}

8. Quand ne pas utiliser un record

8.1. Entité JPA

Comme indiqué à la section 4, JPA a besoin d’entités mutables, avec un constructeur sans arguments, et support des proxies pour le lazy loading. Les records ne peuvent pas y répondre.

// À éviter
@Entity
public record Customer(@Id Long id, String name) {
}

Hibernate rencontrera des erreurs ou un comportement inattendu quand il essaiera de créer un proxy pour un record. Utilisez une classe ordinaire (ou Lombok @Entity + @Getter/@Setter) pour les entités.

8.2. Objets ayant besoin de changer d’état continuellement

Si votre objet change d’état plusieurs fois au cours de sa vie (par exemple ShoppingCart qui ajoute/supprime continuellement des items), le forcer en record vous oblige à créer un nouvel objet à chaque changement — c’est coûteux et pas naturel.

// À éviter : crée un nouveau panier à chaque ajout d'item
public record ShoppingCart(List<Item> items) {
    public ShoppingCart addItem(Item item) {
        List<Item> newItems = new ArrayList<>(items);
        newItems.add(item);
        return new ShoppingCart(newItems);
    }
}

Dans ce cas, une classe mutable ordinaire avec List<Item> et une méthode addItem() qui modifie directement serait plus naturelle et efficace.

8.3. Avoir besoin d’héritage (inheritance)

Un record est implicitement final et ne peut pas hériter d’une autre classe (il peut seulement implémenter une interface). Si votre modèle de domaine a besoin d’une hiérarchie de classes (Employee → Manager, Developer), les records ne sont pas adaptés — utilisez une classe ordinaire ou une sealed interface/class combinée avec des records pour chaque nœud feuille.

public sealed interface Shape permits Circle, Square {}

public record Circle(double radius) implements Shape {}
public record Square(double side) implements Shape {}

C’est la façon appropriée d’utiliser les records dans une hiérarchie : les records pour les nœuds feuilles immuables, et une sealed interface pour gérer l’ensemble des types.

8.4. Avoir besoin de sérialisation complexe

Les records peuvent implémenter Serializable, mais si vous avez besoin d’une sérialisation personnalisée complexe (versioning, compatibilité rétroactive sur plusieurs années), une classe ordinaire avec plus de contrôle granulaire reste plus sûre.

9. Tester un record

Puisque equals()/hashCode() sont générés automatiquement basés sur les composants, les tests sont très simples avec 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("Devises différentes");
    }
}

assertThat(a).isEqualTo(b) vérifie correctement le comportement généré automatiquement de equals() — deux records avec les mêmes valeurs de composants doivent être égaux, même s’ils sont deux instances différentes en mémoire.

10. Conclusion

Un record est le meilleur outil que Java offre pour les données immuables : moins de code, moins d’erreurs, intention claire. Utilisez-les pour les DTOs, value objects, et les résultats retournés temporairement. Ne les utilisez pas pour les entités JPA, les objets avec état mutable constant, ou quand vous avez besoin d’une hiérarchie de classe complexe avec héritage. Choisissez le bon outil pour le bon travail — un record ne remplace pas complètement une classe ordinaire, il complète le choix avec une option plus légère pour un groupe spécifique de problèmes.

javarecordsjava-21spring-bootbest-practices