Builder Pattern pour des objets avec plusieurs paramètres

1. Aperçu général

Quand une classe a six ou sept paramètres, dont certains sont optionnels, le constructeur standard devient rapidement difficile à lire et source d’erreurs. Le Builder Pattern résout ce problème. Vous nommez clairement chaque paramètre, avez des valeurs par défaut et validez à un endroit unique.

L’article couvre quatre parties. D’abord, le problème des constructeurs à plusieurs paramètres. Ensuite, vous écrirez vous-même un Builder complet et le testerez avec JUnit 5. Enfin, comment utiliser Lombok @Builder et combiner Builder avec record en Java 21.

2. Le problème des constructeurs avec plusieurs paramètres

2.1. Constructeur en cascade

Supposons que vous avez une classe Order avec les champs : customerId, une liste items, shippingAddress, couponCode, giftWrap et priority. L’approche courante est d’écrire plusieurs constructeurs surchargés, chacun ajoutant un paramètre. Ce style s’appelle constructeur en cascade :

Order order = new Order("C001", List.of("SKU-1"), null, null, true, 0);

En lisant cette ligne, il est difficile de savoir ce que true et 0 représentent. Pire encore, shippingAddress et couponCode sont tous deux du type String. Si vous inversez ces deux paramètres, le code compile quand même, et l’erreur n’apparaît qu’à l’exécution.

2.2. JavaBeans avec setter

La deuxième approche consiste à utiliser un constructeur sans paramètres et appeler des setter :

Order order = new Order();
order.setCustomerId("C001");
order.setGiftWrap(true);
// oubli de setItems(...) — le compilateur ne signale rien

Le code est plus lisible mais a trois inconvénients :

  • L’objet existe dans un état « à moitié » entre les appels de setter.
  • Vous ne pouvez pas déclarer les champs comme final, donc l’objet n’est pas immuable.
  • Il n’y a pas d’endroit naturel pour vérifier les contraintes entre les champs.

Le Builder garde l’avantage de lisibilité du setter et corrige les trois inconvénients ci-dessus.

3. Implémenter Builder soi-même

3.1. Structure de base

Builder est généralement une classe static imbriquée. Elle contient des copies des champs de la classe externe, a des méthodes qui retournent this pour permettre le chaînage, et une méthode build() pour créer l’objet final :

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public final class Order {

    private final String customerId;
    private final List<String> items;
    private final String shippingAddress;
    private final String couponCode;
    private final boolean giftWrap;
    private final int priority;

    private Order(Builder builder) {
        this.customerId = builder.customerId;
        this.items = List.copyOf(builder.items);
        this.shippingAddress = builder.shippingAddress;
        this.couponCode = builder.couponCode;
        this.giftWrap = builder.giftWrap;
        this.priority = builder.priority;
    }

    public static Builder builder(String customerId) {
        return new Builder(customerId);
    }

    public String getCustomerId() { return customerId; }
    public List<String> getItems() { return items; }
    public String getShippingAddress() { return shippingAddress; }
    public String getCouponCode() { return couponCode; }
    public boolean isGiftWrap() { return giftWrap; }
    public int getPriority() { return priority; }

    @Override
    public String toString() {
        return "Order[customerId=%s, items=%s, address=%s, coupon=%s, giftWrap=%s, priority=%d]"
                .formatted(customerId, items, shippingAddress, couponCode, giftWrap, priority);
    }

    public static final class Builder {
        private final String customerId;
        private final List<String> items = new ArrayList<>();
        private String shippingAddress;
        private String couponCode;
        private boolean giftWrap = false;
        private int priority = 0;

        private Builder(String customerId) {
            this.customerId = Objects.requireNonNull(customerId, "customerId ne peut pas être null");
        }

        public Builder item(String sku) {
            this.items.add(Objects.requireNonNull(sku));
            return this;
        }

        public Builder shippingAddress(String shippingAddress) {
            this.shippingAddress = shippingAddress;
            return this;
        }

        public Builder couponCode(String couponCode) {
            this.couponCode = couponCode;
            return this;
        }

        public Builder giftWrap(boolean giftWrap) {
            this.giftWrap = giftWrap;
            return this;
        }

        public Builder priority(int priority) {
            this.priority = priority;
            return this;
        }

        public Order build() {
            if (items.isEmpty()) {
                throw new IllegalStateException("La commande doit avoir au moins un article");
            }
            if (priority < 0 || priority > 5) {
                throw new IllegalStateException("priority doit être entre 0 et 5");
            }
            return new Order(this);
        }
    }
}

Quelques points importants dans le code ci-dessus :

  • Le constructeur de Order est private. La seule façon de créer une Order est de passer par le Builder, donc chaque objet a déjà été validé dans build().
  • Les paramètres obligatoires se trouvent dans builder(customerId). Vous ne pouvez pas oublier customerId car le code ne compilerait pas sans lui. Ne mettez ici que les paramètres vraiment obligatoires, au maximum un ou deux. Au-delà, vous revenez au problème du constructeur en cascade.
  • Les valeurs par défaut se trouvent dans le Builder (giftWrap = false, priority = 0), et non dispersées à travers les appels.
  • List.copyOf(builder.items) crée une copie immuable. Si le Builder est réutilisé après build(), la Order déjà créée n’est pas affectée.
  • Toutes les contraintes se trouvent dans build(). C’est l’endroit approprié pour vérifier les conditions impliquant plusieurs champs à la fois.

3.2. Utiliser le Builder

public class OrderDemo {
    public static void main(String[] args) {
        Order order = Order.builder("C001")
                .item("SKU-1")
                .item("SKU-2")
                .shippingAddress("12 Lý Thường Kiệt, Hà Nội")
                .giftWrap(true)
                .build();

        System.out.println(order);
    }
}

Chaque valeur est accompagnée de son nom, vous n’avez donc pas besoin de mémoriser l’ordre des paramètres. couponCode et priority ne sont pas appelés, ils prennent donc leurs valeurs par défaut :

Order[customerId=C001, items=[SKU-1, SKU-2], address=12 Lý Thường Kiệt, Hà Nội, coupon=null, giftWrap=true, priority=0]

Si vous oubliez d’appeler item(...), build() lève une exception immédiatement :

Exception in thread "main" java.lang.IllegalStateException: La commande doit avoir au moins un article

L’erreur apparaît immédiatement lors de la création de l’objet, pas trois couches plus tard quand un service quelconque rencontre une liste vide.

4. Tester le Builder

Vous devriez tester trois comportements : valeurs par défaut, validation et immuabilité de l’objet construit. Ajoutez la dépendance de test si votre projet ne l’a pas. Avec Spring Boot, spring-boot-starter-test inclut déjà JUnit 5 et AssertJ :

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class OrderBuilderTest {

    @Test
    void givenOnlyRequiredFields_whenBuild_thenDefaultsApplied() {
        Order order = Order.builder("C001").item("SKU-1").build();

        assertThat(order.isGiftWrap()).isFalse();
        assertThat(order.getPriority()).isZero();
        assertThat(order.getCouponCode()).isNull();
    }

    @Test
    void givenNoItems_whenBuild_thenThrowsException() {
        assertThatThrownBy(() -> Order.builder("C001").build())
                .isInstanceOf(IllegalStateException.class)
                .hasMessageContaining("au moins un article");
    }

    @Test
    void givenInvalidPriority_whenBuild_thenThrowsException() {
        assertThatThrownBy(() -> Order.builder("C001").item("SKU-1").priority(9).build())
                .isInstanceOf(IllegalStateException.class);
    }

    @Test
    void givenBuilderReused_whenAddMoreItems_thenBuiltOrderUnchanged() {
        Order.Builder builder = Order.builder("C001").item("SKU-1");
        Order first = builder.build();

        builder.item("SKU-2");

        assertThat(first.getItems()).containsExactly("SKU-1");
        assertThatThrownBy(() -> first.getItems().add("SKU-3"))
                .isInstanceOf(UnsupportedOperationException.class);
    }
}

Le dernier test est le plus important. Si vous remplacez List.copyOf(...) par this.items = builder.items, first.getItems() contiendra à la fois SKU-2 et le test échouera. C’est une erreur très courante lors de l’écriture manuelle d’un Builder.

5. Utiliser Lombok @Builder

Écrire un Builder à la main est plutôt verbeux. En pratique, de nombreuses équipes utilisent Lombok pour générer ce code. Ajoutez la dépendance comme suit (Spring Boot parent gère déjà la version) :

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>
import lombok.Builder;
import lombok.Getter;
import lombok.NonNull;
import lombok.Singular;
import lombok.ToString;

import java.util.List;

@Getter
@ToString
@Builder(toBuilder = true)
public class Customer {

    @NonNull
    private final String email;

    private final String fullName;

    @Builder.Default
    private final String tier = "STANDARD";

    @Singular
    private final List<String> tags;
}
Customer customer = Customer.builder()
        .email("lan@example.com")
        .fullName("Nguyễn Thị Lan")
        .tag("newsletter")
        .tag("early-adopter")
        .build();

Customer vip = customer.toBuilder().tier("VIP").build();

System.out.println(customer);
System.out.println(vip);
Customer(email=lan@example.com, fullName=Nguyễn Thị Lan, tier=STANDARD, tags=[newsletter, early-adopter])
Customer(email=lan@example.com, fullName=Nguyễn Thị Lan, tier=VIP, tags=[newsletter, early-adopter])

Chaque annotation gère une tâche spécifique :

  • @Builder génère la méthode Customer.builder(), les méthodes de paramétrage et build().
  • @NonNull génère une vérification null dans le constructeur. Si email est absent, build() lève une NullPointerException.
  • @Builder.Default conserve la valeur d’initialisation "STANDARD". Si vous omettez cette annotation, Lombok ignore la valeur d’initialisation, et tier sera null.
  • @Singular génère la méthode tag(...) pour ajouter des éléments individuels. Lombok crée aussi une liste immuable lors de la construction.
  • toBuilder = true permet de créer une copie avec modifications à partir d’un objet existant. C’est très pratique quand on travaille avec des objets immuables.

Note : Lombok n’offre pas de lieu naturel pour la validation multi-champs comme le ferait un build() écrit manuellement. Si vous avez besoin de contraintes complexes, vous pouvez déclarer vous-même un constructeur avec vérification et appliquer @Builder à celui-ci, ou revenir à l’écriture manuelle.

Si vous utilisez Jackson pour désérialiser du JSON dans une classe avec @Builder, vous devez ajouter @Jacksonized. Sans cela, Jackson ne sait pas comment créer l’objet car la classe n’a pas de constructeur sans paramètres.

6. Builder combiné avec record

À partir de Java 16, record est le choix naturel pour une classe de données immuable. Vous pouvez toujours ajouter un Builder si le record a de nombreux champs optionnels. La validation se met dans le compact constructor, donc elle s’exécute même si quelqu’un appelle le constructeur directement :

import java.math.BigDecimal;
import java.util.Objects;

public record PaymentRequest(String orderId, BigDecimal amount, String currency, String note) {

    public PaymentRequest {
        Objects.requireNonNull(orderId, "orderId");
        if (amount == null || amount.signum() <= 0) {
            throw new IllegalArgumentException("amount doit être supérieur à 0");
        }
        currency = currency == null ? "VND" : currency;
    }

    public static Builder builder() {
        return new Builder();
    }

    public static final class Builder {
        private String orderId;
        private BigDecimal amount;
        private String currency;
        private String note;

        public Builder orderId(String orderId) { this.orderId = orderId; return this; }
        public Builder amount(BigDecimal amount) { this.amount = amount; return this; }
        public Builder currency(String currency) { this.currency = currency; return this; }
        public Builder note(String note) { this.note = note; return this; }

        public PaymentRequest build() {
            return new PaymentRequest(orderId, amount, currency, note);
        }
    }
}
PaymentRequest request = PaymentRequest.builder()
        .orderId("ORD-42")
        .amount(new BigDecimal("150000"))
        .build();

System.out.println(request);
PaymentRequest[orderId=ORD-42, amount=150000, currency=VND, note=null]

Avec cette approche, le Builder s’occupe de la lisibilité, tandis que le record s’occupe de l’immuabilité et de la validation. Les deux responsabilités sont séparées. Vous pouvez aussi appliquer @Builder de Lombok directement sur un record si vous voulez être plus concis.

7. Erreurs courantes et quand ne pas utiliser

Erreurs courantes :

  • Fuite de collection mutable. Assigner directement la liste du builder à l’objet fait que l’objet « immuable » peut toujours être modifié. Utilisez toujours List.copyOf, Set.copyOf ou Map.copyOf.
  • Oublier d’appeler build(). Vous recevez un Builder à la place d’une Order. Le compilateur détecte généralement cette erreur en raison d’une non-concordance de type, mais dans le code générique ou avec var, l’erreur peut passer inaperçue.
  • Validation dispersée. Les vérifications dans chaque setter du builder ne détectent pas les contraintes entre les champs. Regroupez-les dans build() ou dans un compact constructor.
  • Partager le builder entre plusieurs threads. Le Builder est un objet mutable et non thread-safe. Créez un nouveau builder chaque fois que vous créez un objet.
  • @Builder sur une entité JPA. Hibernate a besoin d’un constructeur sans paramètres. @Builder seul supprimera ce constructeur, vous devez donc ajouter @NoArgsConstructor(access = AccessLevel.PROTECTED) et @AllArgsConstructor. En général, évitez d’utiliser Builder sur les entités.

Note : N’utilisez pas le Builder pour une classe qui n’a que deux ou trois paramètres obligatoires, par exemple Money(amount, currency). Un constructeur ou une méthode factory statique comme Money.of(amount, currency) est plus concis. Le Builder n’est pas non plus approprié quand tous les paramètres sont obligatoires, car il repousse l’erreur « paramètre manquant » du temps de compilation au temps d’exécution.

Règle concise : envisagez le Builder quand une classe a quatre paramètres ou plus, dont la plupart sont optionnels, ou quand elle a de nombreux paramètres du même type qui peuvent être confondus.

8. Conclusion

Le Builder Pattern aide à créer des objets avec de nombreux paramètres de manière lisible, avec des valeurs par défaut claires et un seul point de validation. Quand vous écrivez un Builder vous-même, gardez le constructeur private, mettez les paramètres obligatoires dans la méthode builder(...), copiez les collections et vérifiez les contraintes dans build().

Lombok @Builder vous aide à vous débarrasser du code répétitif, mais n’oubliez pas @Builder.Default et @Singular. Avec Java 21, combiner record et Builder vous donne des objets immuables avec validation dans le compact constructor. Enfin, n’utilisez le Builder que quand le nombre de paramètres pose vraiment problème. Pour les petites classes, un constructeur ou une méthode factory statique restent un meilleur choix.

design-patternjavabuilderlombokclean-code