Builder-Pattern für Objekte mit vielen Parametern in Java
1. Übersicht
Wenn eine Klasse sechs, sieben Parameter hat, von denen einige optional sind, wird ein normaler Konstruktor schnell unlesbar und Parameter können leicht verwechselt werden. Das Builder-Pattern löst dieses Problem. Sie benennen jeden Parameter klar, haben Standardwerte und validieren an einer Stelle.
Der Artikel behandelt vier Teile. Zunächst das Problem von Konstruktoren mit vielen Parametern. Dann schreiben Sie einen vollständigen Builder selbst und testen ihn mit JUnit 5. Abschließend die Verwendung von Lombok @Builder und die Kombination von Builder mit record in Java 21.
2. Das Problem von Konstruktoren mit vielen Parametern
2.1. Telescoping Constructor
Angenommen, Sie haben eine Klasse Order mit Feldern: customerId, Liste items, shippingAddress, couponCode, giftWrap und priority. Der übliche Ansatz ist, mehrere überladene Konstruktoren zu schreiben, wobei jeder Konstruktor einen Parameter hinzufügt. Dieser Ansatz wird Telescoping Constructor genannt:
Order order = new Order("C001", List.of("SKU-1"), null, null, true, 0);
Wenn Sie diese Zeile lesen, ist unklar, was true und 0 bedeuten. Schlimmer noch: shippingAddress und couponCode sind beide vom Typ String. Wenn Sie diese beiden Parameter vertauschen, wird der Code immer noch kompiliert und Fehler treten nur zur Laufzeit auf.
2.2. JavaBeans mit Settern
Der zweite Ansatz ist ein leerer Konstruktor und das Aufrufen von Settern:
Order order = new Order();
order.setCustomerId("C001");
order.setGiftWrap(true);
// Aufrufen von setItems(...) vergessen — Compiler meldet nichts
Der Code ist lesbarer, aber es gibt drei Nachteile:
- Das Objekt existiert zwischen den Setter-Aufrufen in einem „halbfertigen” Zustand.
- Sie können Felder nicht als
finaldeklarieren, daher ist das Objekt nicht unveränderlich. - Es gibt keinen natürlichen Ort, um Constraints zwischen Feldern zu überprüfen.
Builder behält die Lesbarkeit des Setter-Ansatzes und behebt alle drei Nachteile.
3. Builder selbst implementieren
3.1. Grundstruktur
Ein Builder ist normalerweise eine static verschachtelte Klasse. Sie enthält Kopien der Felder der äußeren Klasse, hat Methoden, die this zurückgeben für Method-Chaining, und eine build()-Methode zum Erstellen des finalen Objekts:
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 darf nicht null sein");
}
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("Order muss mindestens einen Artikel enthalten");
}
if (priority < 0 || priority > 5) {
throw new IllegalStateException("priority muss im Bereich 0..5 liegen");
}
return new Order(this);
}
}
}
Einige wichtige Punkte im obigen Code:
- Der Konstruktor von
Orderistprivate. Der einzige Weg,Orderzu erstellen, führt über den Builder, daher wird jedes Objekt inbuild()überprüft. - Erforderliche Parameter sind in
builder(customerId)enthalten. Sie könnencustomerIdnicht vergessen, da der Code ohne ihn nicht kompiliert. Fügen Sie hier nur wirklich erforderliche Parameter ein, maximal ein oder zwei. Wenn es mehr sind, haben Sie wieder das Telescoping-Problem. - Standardwerte sind im Builder definiert (
giftWrap = false,priority = 0), nicht verstreut an den Aufrufstellen. List.copyOf(builder.items)erstellt eine unveränderliche Kopie. Falls der Builder nachbuild()wiederverwendet wird, bleibt die erstellteOrderunverändert.- Alle Constraints sind in
build(). Das ist der richtige Ort, um Conditions zu prüfen, die mehrere Felder betreffen.
3.2. Builder verwenden
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);
}
}
Jeder Wert ist mit seinem Namen versehen, daher müssen Sie die Parameterreihenfolge nicht merken. couponCode und priority werden nicht aufgerufen, daher erhalten sie Standardwerte:
Order[customerId=C001, items=[SKU-1, SKU-2], address=12 Lý Thường Kiệt, Hà Nội, coupon=null, giftWrap=true, priority=0]
Wenn Sie vergessen, item(...) aufzurufen, wirft build() sofort einen Fehler:
Exception in thread "main" java.lang.IllegalStateException: Order muss mindestens einen Artikel enthalten
Der Fehler tritt unmittelbar auf, nicht drei Schichten später, wenn ein Service eine leere Liste antrifft.
4. Builder testen
Sie sollten drei Verhaltensweisen testen: Standardwerte, Validierung und die Unveränderlichkeit des erstellten Objekts. Fügen Sie die Test-Abhängigkeit hinzu, falls sie fehlt. Bei Spring Boot enthält spring-boot-starter-test bereits JUnit 5 und 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("ít nhất một item");
}
@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);
}
}
Der letzte Test ist der wichtigste. Wenn Sie List.copyOf(...) durch this.items = builder.items ersetzen, wird first.getItems() auch SKU-2 enthalten und der Test schlägt fehl. Dies ist ein häufiger Fehler beim manuellen Schreiben eines Builders.
5. Lombok @Builder verwenden
Das manuelle Schreiben eines Builders ist ziemlich verbose. In der Praxis verwenden viele Teams Lombok, um diesen Code zu generieren. Fügen Sie die Abhängigkeit wie folgt hinzu (Spring Boot Parent verwaltet bereits die 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])
Jede Annotation hat eine Aufgabe:
@Buildergeneriert die MethodeCustomer.builder(), Setter-Methoden undbuild().@NonNullgeneriert eine Null-Überprüfung im Konstruktor. Wennemailfehlt, wirftbuild()eineNullPointerException.@Builder.Defaultbehält den Initialisierungswert"STANDARD". Wenn diese Annotation fehlt, ignoriert Lombok den Initialisierungswert undtieristnull.@Singulargeneriert die Methodetag(...), um einzelne Elemente hinzuzufügen. Lombok erstellt auch automatisch eine unveränderliche Liste beim Bauen.toBuilder = trueermöglicht das Erstellen einer modifizierten Kopie aus einem bestehenden Objekt. Dies ist sehr praktisch bei der Arbeit mit unveränderlichen Objekten.
Hinweis: Lombok hat keinen einfachen Platz für Validierungen mit mehreren Feldern wie ein selbst geschriebener
build(). Bei komplexen Constraints können Sie einen Konstruktor mit Überprüfungen deklarieren und@Builderdarauf setzen, oder zur manuellen Implementierung zurückkehren.
Wenn Sie Jackson verwenden, um JSON in eine Klasse mit @Builder zu deserialisieren, müssen Sie @Jacksonized hinzufügen. Ohne sie weiß Jackson nicht, wie das Objekt erstellt wird, da die Klasse keinen leeren Konstruktor hat.
6. Builder kombiniert mit record
Seit Java 16 ist record die natürliche Wahl für unveränderliche Data-Klassen. Sie können immer noch einen Builder hinzufügen, wenn der Record viele optionale Felder hat. Die Validierung erfolgt im kompakten Konstruktor, daher läuft sie auch, wenn jemand den Konstruktor direkt aufruft:
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 muss größer als 0 sein");
}
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]
Auf diese Weise kümmert sich der Builder nur um die Lesbarkeit, während der Record sich um Unveränderlichkeit und Validierung kümmert. Zwei Verantwortungen sind klar getrennt. Lombok @Builder kann auch direkt auf record platziert werden, wenn Sie es prägnanter haben möchten.
7. Häufige Fehler und wann Sie Builder nicht verwenden sollten
Häufige Fehler:
- Leak von veränderbaren Sammlungen. Das direkte Zuweisen der Liste des Builders führt dazu, dass das „unveränderliche” Objekt dennoch modifiziert werden kann. Verwenden Sie immer
List.copyOf,Set.copyOfoderMap.copyOf. - Vergessenes Aufrufen von
build(). Sie erhaltenBuilderstattOrderzurück. Der Compiler fängt dies normalerweise ab, da der Typ nicht übereinstimmt, aber bei generischem Code oder der Verwendung vonvarkann der Fehler durchsickern. - Verteilte Validierung. Überprüfungen in jedem Setter des Builders erfassen keine Cross-Field-Constraints. Konzentrieren Sie sich auf
build()oder den kompakten Konstruktor. - Gemeinsame Nutzung des Builders zwischen mehreren Threads. Der Builder ist ein veränderbares Objekt und nicht Thread-sicher. Erstellen Sie jedes Mal einen neuen Builder.
@Builderauf JPA-Entity. Hibernate benötigt einen parameterlosen Konstruktor.@Builderallein entfernt diesen Konstruktor, daher müssen Sie@NoArgsConstructor(access = AccessLevel.PROTECTED)und@AllArgsConstructorhinzufügen. Allgemein sollte Builder bei Entities gemieden werden.
Hinweis: Verwenden Sie Builder nicht für Klassen mit nur zwei oder drei erforderlichen Parametern, z. B.
Money(amount, currency). Ein Konstruktor oder eine statische Factory-Methode wieMoney.of(amount, currency)ist prägnanter. Builder ist auch nicht geeignet, wenn alle Parameter erforderlich sind, da der Fehler „fehlender Parameter” von der Compile-Zeit zur Laufzeit verschoben wird.
Kurze Regel: Erwägen Sie Builder, wenn eine Klasse vier oder mehr Parameter hat, von denen die meisten optional sind, oder wenn viele Parameter desselben Typs vorhanden sind und leicht verwechselt werden können.
8. Fazit
Das Builder-Pattern hilft beim Erstellen von Objekten mit vielen Parametern auf lesbare Weise, mit klaren Standardwerten und einem einzigen Validierungspunkt. Halten Sie den Konstruktor beim manuellen Schreiben eines Builders private, setzen Sie erforderliche Parameter in die builder(...)-Methode, kopieren Sie Sammlungen und überprüfen Sie Constraints in build().
Lombok @Builder hilft Ihnen, wiederholten Code zu vermeiden, aber denken Sie an @Builder.Default und @Singular. Mit Java 21 gibt Ihnen die Kombination von record und Builder unveränderliche Objekte mit Validierung direkt im kompakten Konstruktor. Verwenden Sie Builder abschließend nur, wenn die Anzahl der Parameter wirklich problematisch ist. Bei kleinen Klassen ist ein Konstruktor oder eine statische Factory-Methode immer noch die bessere Wahl.