Patterns you will actually be asked for · chapter 21 of 33
Builder: too many constructor arguments
Chapter 3.3 · Part 3, Patterns you will actually be asked for · about 25 minutes
What you need before this chapter: Part 1 in full, especially constructors (1.2) and generics (1.7). Part 2 in full, especially immutability (2.2). Chapter 3.2, Factory, since a builder solves a different half of the same problem: not who assembles an object, but what to do when most of what it is built from is optional.
When you finish this chapter you will be able to:
- Recognise when a growing, mostly-optional constructor is the actual problem, rather than treating every multi-argument constructor as one
- Write a builder whose required fields live in its own constructor and whose optional fields are chained methods, including one that accumulates a list
- Say when a record with a compact constructor is a straight replacement for a builder, and when it is not
- Explain what a staged builder buys over a plain one, and why most code does not need it
1. The situation
Your shopping cart needs a catalogue to price anything in it. Without one it cannot answer a single question about what something costs.
Everything else about a fresh cart has a sensible default. No coupons applied. No promotions running. No delivery charge.
So write a constructor that asks for what a cart cannot work without, and states the rest as arguments too.
Cart cart = new Cart(catalog, List.of(), List.of(), 0L);
2. Naive code that is fine
javac Step1.java
java Step1
coupons=0 promotions=0 delivery=0
For one cart with no coupons and no promotions this is completely fine. The catalogue is required and present. Everything else is a default, spelled out because you have nothing more interesting to pass yet.
3. A new requirement
A seasonal offer lands on you. Buy two, get one free, and it needs its own Promotion object.
A second stacks on top soon after: a loyalty discount for members.
Then a test wants a cart with only the loyalty discount, to check that one calculation in isolation. And a demo wants both running together, to show off the full experience.
Cart loyaltyOnly = new Cart(catalog, List.of(), List.of(new LoyaltyDiscount()), 0L);
Cart both = new Cart(catalog, List.of(), List.of(new LoyaltyDiscount(), new MultiBuyDiscount()), 0L);
javac Step2.java
java Step2
loyaltyOnly: coupons=0 promotions=1 delivery=0
both: coupons=0 promotions=2 delivery=0
Both carts build correctly, and you can read the difference at each call site.
But notice what getting a combination right now depends on. The caller builds a List by hand, in the right order, every single time.
4. Watch where it goes, and the real cost
Now a third combination shows up. Multi-buy, no loyalty discount, for a customer who is not a member. The caller writes List.of(new MultiBuyDiscount()) and hopes nobody transposes an argument.
Then a fourth.
Nothing about the constructor stops any of those calls from compiling. Nothing about it helps a reader tell at a glance which promotions a given cart is actually running.
The deeper problem is not the argument count. Four arguments read fine on one line.
The problem is that a constructor forces every caller who wants to add one thing to restate everything they are not changing, in a fixed position. And it gives you no way to add a fifth promotion later without either a longer parameter list or a List built somewhere else first.
Here is the signal to watch for. Two carts wanting genuinely different combinations of optional things means a constructor has stopped being the right tool, whatever its argument count.
5. The move
Give construction its own class. Its job is to collect your parts one call at a time, then hand back a finished cart.
The one thing a cart cannot work without goes in the builder's own constructor, where the compiler enforces it. Everything optional becomes a method that returns the builder itself, so calls chain.
final class CartBuilder {
private final Catalog catalog;
private final List<Promotion> promotions = new ArrayList<>();
private List<String> coupons = List.of();
private long deliveryChargeCents = 0L;
CartBuilder(Catalog catalog) {
this.catalog = Objects.requireNonNull(catalog, "catalog");
}
CartBuilder coupons(List<String> coupons) {
this.coupons = List.copyOf(coupons);
return this;
}
CartBuilder plusPromotion(Promotion promotion) {
promotions.add(Objects.requireNonNull(promotion, "promotion"));
return this;
}
CartBuilder deliveryChargeCents(long cents) {
this.deliveryChargeCents = cents;
return this;
}
Cart build() {
return new Cart(catalog, coupons, List.copyOf(promotions), deliveryChargeCents);
}
}
Now a test that wants only the loyalty discount calls plusPromotion once. A demo that wants both calls it twice.
javac Step3.java
java Step3
loyaltyOnly: coupons=0 promotions=1 delivery=0
both: coupons=0 promotions=2 delivery=0
Same result as section 3. But neither caller wrote a List by hand, and neither restated a default it was not changing.
Cart's own constructor is now package-private, so the builder is the only way in. That is the same "who is allowed to change this" question from chapter 2.1, asked about construction instead of mutation.
This is the Builder pattern. Now look closely at which half of it is doing the real work for you.
catalog sits in CartBuilder's own constructor. It does not get a catalog(Catalog) chained method the way the others do.
That detail is what people leave out when they first copy this shape, and leaving it out turns a builder into a constructor a caller can forget to finish. Whatever the object genuinely cannot exist without belongs where the compiler enforces it. Only the truly optional parts get a chained method.
6. What modern Java changes here
A record removes your need for a builder entirely, once a value has a handful of required fields and nothing that accumulates.
record Sku(String code, String name, long priceCents) {
Sku {
if (priceCents <= 0) {
throw new IllegalArgumentException("priceCents must be positive, got " + priceCents);
}
}
}
javac Step5.java
java Step5
Sku[code=SKU1, name=Chips, priceCents=65]
rejected: priceCents must be positive, got 0
That block right after the field list, with no parameter list repeated, is a compact constructor. It runs before the fields are assigned, which makes it the natural place to validate. Sku's generated constructor already states every field once.
Sku and Cart pull in opposite directions on purpose.
Sku has three required fields and nothing optional, so a record is the whole answer.
Cart has one required field, several optional ones, and one field — promotions — that grows over the object's lifetime. A record's fixed, all-at-once constructor cannot express that at all.
7. When naming it is wrong
Sku is your case for saying no. Wrap it in a SkuBuilder and you write more code for no guarantee a record does not already give you.
Worse, you open a gap. A half-built value can now exist where it could not before. Call `new SkuBuilder(), forget .priceCents(...), and .build()` either throws at run time or silently returns something wrong. The plain constructor makes that failure impossible.
Your threshold for a builder is three things being true together. One part is genuinely required. Several parts have real defaults. At least one part can be added more than once at the same call site.
Two or three required parameters with nothing that accumulates is a constructor. Adding a builder there is the same over-engineered (premature interface) failure the earlier chapters named for a needless interface.
Your turn
Add a third promotion, a flat seasonal discount, to the "both" cart from section 5. Do it without editing Cart or CartBuilder.
The answer.
final class SeasonalDiscount implements Promotion {
public long discountCents(long subtotalCents) { return 50L; }
}
Cart all3 = new CartBuilder(catalog)
.plusPromotion(new LoyaltyDiscount())
.plusPromotion(new MultiBuyDiscount())
.plusPromotion(new SeasonalDiscount())
.build();
javac Step4.java
java Step4
all3: coupons=0 promotions=3 delivery=0
One new class, one more chained call. You never opened CartBuilder.
Going deeper
The builder in section 5 has a gap of its own. A caller can call .build() before setting anything, because every method returns the same type.
A staged builder closes that gap with the type system. Each step returns an interface exposing only the next step, so skipping a step out of order will not compile.
interface NeedsSize {
NeedsCrust size(String size);
}
interface NeedsCrust {
Buildable crust(String crust);
}
interface Buildable {
String build();
}
final class PizzaBuilder implements NeedsSize, NeedsCrust, Buildable {
private String size;
private String crust;
private PizzaBuilder() {}
static NeedsSize start() {
return new PizzaBuilder();
}
@Override public NeedsCrust size(String size) { this.size = size; return this; }
@Override public Buildable crust(String crust) { this.crust = crust; return this; }
@Override public String build() { return size + " pizza, " + crust + " crust"; }
}
One class implements all three interfaces, and the caller never sees it by that name.
Follow the types. start() returns NeedsSize, so the only method available is size(...). That returns NeedsCrust, so the only method next is crust(...). Only after both have run does the caller hold something typed Buildable, which is the only type with build() on it.
javac Step6.java
java Step6
large pizza, thin crust
Now try to skip the size and call crust straight off start().
String order = PizzaBuilder.start().crust("thin").build();
Step6Bad.java:45: error: cannot find symbol
String order = PizzaBuilder.start().crust("thin").build();
^
symbol: method crust(String)
location: interface NeedsSize
Read that error carefully, because it is not a runtime check catching a missing field. It is the compiler refusing to build at all, since NeedsSize genuinely has no method called crust.
A half-built pizza with no size is not a value this design can represent. That is a stronger guarantee than Objects.requireNonNull gives you in CartBuilder, where a forgotten optional call just keeps its default.
The cost is real too: one interface per required field, in a fixed order. Pay it only when getting the order wrong would be a genuine bug rather than a matter of taste. CartBuilder's optional methods have no natural order to enforce, which is exactly why it does not use this shape.
Why this matters in an interview
The failure mode interviewers watch for is a builder reached for out of habit, on a type with two or three required fields and nothing optional.
Say "this is a record, not a builder," explain why in one sentence, and you read as more senior than someone who produces the builder boilerplate on request.
When a builder is right, say the split out loud before you write any code. Which fields are required, which are optional, and which one accumulates.
Next: chapter 3.4, Observer: telling other objects something happened. Builder and Factory both solve construction. The next problem starts after an object already exists: how does it tell other parts of the system, that it does not know about individually, that something just happened to it?
← 3.2 Factory: construction kept away from use · All chapters · 3.4 Observer: telling other objects something happened →