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
Orderestprivate. La seule façon de créer uneOrderest de passer par le Builder, donc chaque objet a déjà été validé dansbuild(). - Les paramètres obligatoires se trouvent dans
builder(customerId). Vous ne pouvez pas oubliercustomerIdcar 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èsbuild(), laOrderdé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 :
@Buildergénère la méthodeCustomer.builder(), les méthodes de paramétrage etbuild().@NonNullgénère une vérification null dans le constructeur. Siemailest absent,build()lève uneNullPointerException.@Builder.Defaultconserve la valeur d’initialisation"STANDARD". Si vous omettez cette annotation, Lombok ignore la valeur d’initialisation, ettierseranull.@Singulargénère la méthodetag(...)pour ajouter des éléments individuels. Lombok crée aussi une liste immuable lors de la construction.toBuilder = truepermet 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.copyOfouMap.copyOf. - Oublier d’appeler
build(). Vous recevez unBuilderà la place d’uneOrder. 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 avecvar, 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.
@Buildersur une entité JPA. Hibernate a besoin d’un constructeur sans paramètres.@Builderseul 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 commeMoney.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.