LLD Dojo

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:


1. The situation

A 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. A constructor that asks for exactly what a cart cannot work without, and states the rest as arguments too, is the obvious starting point.

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 there is nothing more interesting to pass yet.

3. A new requirement

A seasonal offer arrives, buy two and get one free, and it needs its own Promotion object. A second one stacks on top of it soon after: a loyalty discount for members. A test wants a cart with only the loyalty discount active, to check that one calculation in isolation. A demo, on the other hand, wants both promotions 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 the difference between them is visible at each call site. That is worth noticing already, though: getting a combination of optional things right now depends on the caller building a List by hand, in the right order, every single time.

4. Watch where it goes, and the real cost

A third combination shows up: a cart with the multi-buy offer but no loyalty discount, for a customer who is not a member. The caller writes List.of(new MultiBuyDiscount()) and hopes nobody transposes an argument. A fourth combination shows up after that. Nothing about the constructor stops any of these calls from compiling, and nothing about it helps a reader tell, at a glance, which promotions a particular cart is actually running.

The deeper problem is not the argument count. Four arguments read fine in 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. It also gives no way to add a fifth promotion later without either a longer parameter list, or a List built somewhere else first. Two carts wanting genuinely different combinations of optional things is the sign a constructor has stopped being the right tool, whatever its argument count.

5. The move

Give construction its own class, whose job is to collect the parts one call at a time and only 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 can be chained.

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);
    }
}

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 chapter 2.1 asked, applied to construction instead of mutation.

This is the Builder pattern. Notice which half of it is doing the real work: catalog sits in CartBuilder's own constructor, not behind a catalog(Catalog) chained method the way the rest do. That is the detail people leave out when they first copy this shape, and leaving it out is what turns a builder into a constructor a caller can forget to finish. Whatever a cart genuinely cannot exist without belongs where the compiler enforces it. Only the genuinely optional parts get a chained method.

6. What modern Java changes here

A record removes the 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

The block right after the field list, with no parameter list repeated, is a compact constructor. It runs before the fields are assigned, so it is the natural place to validate, and Sku's own generated constructor already states every field once. Sku and Cart are pulling 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 above is the case for saying no. Wrapping it in a SkuBuilder costs more code for no guarantee a plain constructor, or a record, does not already give. It also opens a gap where a half-built value can exist that could not exist before: call new SkuBuilder(), forget .priceCents(...), and .build() either throws at run time or silently returns something wrong — a failure mode the plain constructor makes impossible by construction.

The threshold: reach for a builder when three things are 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, and adding a builder there is the same over-engineered (premature interface) failure the earlier chapters have 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. CartBuilder was not opened.

Going deeper

The builder in section 5 lets a caller call .build() before setting anything at all, since every method on it returns the same type. A staged builder closes that gap using the type system itself: each step returns an interface that only exposes the next step, so a step skipped out of order does not compile, rather than failing at run time.

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, PizzaBuilder, implements all three interfaces, but the caller never sees it by that name. start() returns NeedsSize, so the only method available is size(...). That call returns NeedsCrust, so the only method available next is crust(...). Only after both have run does the caller hold something typed Buildable, 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

That is not a runtime check catching a missing field. It is the compiler refusing to build at all, because NeedsSize genuinely has no method called crust. A half-built pizza with no size is not a value this design can represent, which is a stronger guarantee than Objects.requireNonNull gives you in CartBuilder, where a forgotten optional call simply keeps its default. The cost is real, too: one interface per required field, in a fixed order, which is worth paying 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. Being able to say "this is a record, not a builder," and explain why in one sentence, reads as more senior than producing the builder boilerplate on request. When a builder is the right call, naming the required-versus-optional split, and which field accumulates, is the part worth saying out loud before writing any code.


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 →