Many companies enforce immutable domain objects โ objects that can only be set via constructor and expose only getters. The intent is good: thread safety, predictability, no accidental mutation. But as objects grow, the constructor approach breaks down fast.
The Problem with Constructors Alone
Imagine a Client object with 10 fields. When you need to create a client with only 4 of those fields set, you either:
- Create 10+ constructor overloads (a maintenance nightmare), or
- Pass
nullfor every unused field โ which pollutes every call site in the codebase.
new Client(1L, "Felix", null,
null, null, true,
null, null, null);
new ClientBuilder()
.withId(1L)
.withName("Felix")
.withPermanent(true)
.build();
The Solution: Immutable Object + Mutable Builder
The pattern uses two complementary classes: an immutable domain object and a highly mutable builder. The builder accumulates state via fluent setters, then constructs the final immutable object in one build() call.
Client.java โ the immutable object
public final class Client {
private final Long id;
private final String name;
private final boolean permanent;
Client(Long id, String name, boolean permanent) {
this.id = id;
this.name = name;
this.permanent = permanent;
}
public Long getId() { return id; }
public String getName() { return name; }
public boolean isPermanent() { return permanent; }
}
ClientBuilder.java โ the mutable builder
public class ClientBuilder {
private Long id;
private String name;
private boolean permanent;
public ClientBuilder withId(Long id) {
this.id = id;
return this;
}
public ClientBuilder withName(String name) {
this.name = name;
return this;
}
public ClientBuilder withPermanent(boolean permanent) {
this.permanent = permanent;
return this;
}
// Copy constructor โ create a modified version of an existing Client
public ClientBuilder from(Client client) {
this.id = client.getId();
this.name = client.getName();
this.permanent = client.isPermanent();
return this;
}
public Client build() {
Objects.requireNonNull(id, "id is required");
Objects.requireNonNull(name, "name is required");
return new Client(id, name, permanent);
}
}
Three Usage Scenarios
1. Build from scratch
Client client = new ClientBuilder()
.withId(1L)
.withName("Felix")
.withPermanent(true)
.build();
2. Copy and modify an existing object
// Create a non-permanent copy of an existing client
Client updated = new ClientBuilder()
.from(existingClient)
.withPermanent(false)
.build();
3. The anti-pattern (avoid)
// DON'T DO THIS โ package-private constructor, null chaos
Client bad = new Client(1L, "Felix", true); // fine now
Client worse = new Client(null, null, false, null, null, null, null); // nightmare
Why This Pays Off at Scale
When you need to add a new field to Client, you update the builder once and all existing call sites continue to work โ they just don't set the new field. With constructor-based construction, every single call site needs updating. On a large codebase this difference is worth days of refactoring.