The two factory lessons solved which class to instantiate. Builder attacks a different problem: the class is clear — it is an Order, no doubt about it — but assembling it is torture: lots of data, most of it optional, invalid combinations, and a constructor that has kept swelling until it became unreadable. In this lesson we will fix the telescoping constructor of Order that we have been dragging along since the module introduction, we will see the original GoF structure (with its Director) and the modern fluent variant that dominates today's Java, and we will learn to use build() as a boundary of validation and immutability.

Contents

  1. The problem in PideYa: the telescoping constructor
  2. Intent of the pattern
  3. GoF structure: Builder and Director
  4. The modern fluent variant (the one you will use 95% of the time)
  5. Complete Java implementation of Order.Builder
  6. Immutability and validation in build()
  7. The Director in the modern variant
  8. When to use it and when not to
  9. Common mistakes, exercises, and conclusion

The problem in PideYa: the telescoping constructor

To come into existence, a PideYa Order needs: customer and restaurant (always), its lines (at least one), a delivery address (unless it is picked up at the restaurant), and optionally a time slot, instructions for the courier ("entrance 3, don't ring the bell"), a promo code, cutlery yes/no, and an alternative contact phone. The constructor's typical evolution across sprints:

// Sprint 3
public Order(Customer customer, Restaurant restaurant, List<OrderLine> lines) { ... }

// Sprint 7: home delivery
public Order(Customer customer, Restaurant restaurant, List<OrderLine> lines,
             Address deliveryAddress) { ... }

// Sprint 12: the creature is now frightening
public Order(Customer customer, Restaurant restaurant, List<OrderLine> lines,
             Address deliveryAddress, TimeSlot slot, String instructions,
             String promoCode, boolean includeCutlery, String contactPhone) { ... }

This is called a telescoping constructor: to cover the combinations of optionals you end up with a ladder of overlapping constructors (or one giant one that gets called with nulls). The resulting calls are cryptograms:

Order o = new Order(customer, rest, lines, address, null, null, "PIZZA10", true, null);
// What is "PIZZA10"? What does true mean? Which of the nulls was the time slot?

The pains, enumerated:

  • Unreadability: positional arguments do not say what they are; booleans and nulls are landmines.
  • Fragility: two adjacent parameters of the same type (instructions and promoCode, both String) get swapped without a peep from the compiler.
  • Combinatorial explosion: with 6 optionals you would need up to 64 constructors to cover every variant with clean signatures.
  • Nowhere to validate: rules like "home delivery requires an address" or "at most one promo code" end up scattered across the callers.
  • The JavaBeans alternative (empty constructor + setters) is even worse: the object passes through invalid intermediate states, anyone can mutate it afterwards, and immutability becomes impossible.

Intent of the pattern

Intent (GoF): separate the construction of a complex object from its representation, so that the same construction process can create different representations.

The idea: instead of handing all the data to a constructor at once, you accumulate it step by step in an intermediate object — the builder — that knows how to receive it by name, in any order, and that at the end manufactures the product in one piece, validated and complete. The second half of the GoF intent ("the same process, different representations") belongs to the classic variant with a Director; the modern variant mostly keeps the first half. Let's see both.

GoF structure: Builder and Director

classDiagram
    class Director {
        -builder : Builder
        +construct()
    }
    class Builder {
        <<interface>>
        +buildBase()
        +buildLines()
        +buildDelivery()
        +getResult()
    }
    class ConcreteBuilder1 {
        +buildBase()
        +buildLines()
        +buildDelivery()
        +getResult() Product1
    }
    class ConcreteBuilder2 {
        +buildBase()
        +buildLines()
        +buildDelivery()
        +getResult() Product2
    }
    Director o--> Builder : directs
    Builder <|.. ConcreteBuilder1
    Builder <|.. ConcreteBuilder2
    ConcreteBuilder1 ..> Product1 : creates
    ConcreteBuilder2 ..> Product2 : creates
GoF role Purpose
Builder Interface with the partial construction operations
ConcreteBuilder Implements the steps and knows how to assemble ONE representation; exposes getResult()
Director Knows the recipe (which steps and in what order), without knowing which representation comes out
Product The resulting complex object

The key to the division of labor: the Director owns the construction algorithm and the Builder owns the materialization of each step. With the same recipe and different builders you get different products. In PideYa this fits, for example, generating an order summary in several formats: an OrderSummaryDirector always walks through the same steps (header, lines, totals, delivery details) and, depending on whether you plug in an HtmlSummaryBuilder, a TextSummaryBuilder (for SMS), or a KitchenTicketBuilder, a different representation comes out.

That said, let's be frank: in everyday Java this full form, with an interface and interchangeable builders, is a minority. What you will find everywhere is its fluent descendant.

The modern fluent variant (the one you will use 95% of the time)

The variant popularized by Effective Java (Bloch) simplifies things: a single builder, a static inner class of the product, with methods that return this so they chain (a fluent interface), no interface and no Director. The goal is no longer "same recipe, several representations" but taming telescoping constructors with readability, immutability, and validation. This is how the one we are about to write is used:

Order order = Order.builder(customer, restaurant)
        .line(margherita, 2)
        .line(water, 1)
        .homeDelivery(homeAddress)
        .timeSlot(TimeSlot.of(21, 0, 21, 30))
        .instructions("Entrance 3, don't ring the bell")
        .promoCode("PIZZA10")
        .withCutlery()
        .build();

Compare it with the cryptogram from the beginning: every value carries its name, the optionals only appear if they exist, the order is free, and the compiler stops you from confusing the address with the phone. You will run into this style daily: StringBuilder, Stream.builder(), HttpRequest.newBuilder() in the JDK; UriComponentsBuilder in Spring; and Lombok's @Builder annotations or records with generated builders in many codebases.

Complete Java implementation of Order.Builder

public final class Order {

    // ---- State: everything final. Once created, an Order is immutable ----
    private final Customer customer;
    private final Restaurant restaurant;
    private final List<OrderLine> lines;
    private final DeliveryType deliveryType;      // DELIVERY or PICKUP
    private final Address deliveryAddress;        // only if DELIVERY
    private final TimeSlot timeSlot;              // optional
    private final String instructions;            // optional
    private final String promoCode;               // optional
    private final boolean includeCutlery;
    private final String contactPhone;            // optional

    /** PRIVATE constructor: the only way in is through the builder. */
    private Order(Builder b) {
        this.customer = b.customer;
        this.restaurant = b.restaurant;
        this.lines = List.copyOf(b.lines);        // defensive, immutable copy
        this.deliveryType = b.deliveryType;
        this.deliveryAddress = b.deliveryAddress;
        this.timeSlot = b.timeSlot;
        this.instructions = b.instructions;
        this.promoCode = b.promoCode;
        this.includeCutlery = b.includeCutlery;
        this.contactPhone = b.contactPhone;
    }

    /** Entry point: the REQUIRED values are demanded right here. */
    public static Builder builder(Customer customer, Restaurant restaurant) {
        return new Builder(customer, restaurant);
    }

    // ---- getters (no setters: immutable) ----
    public BigDecimal getTotalBeforeTaxes() {
        return lines.stream()
                .map(OrderLine::getSubtotal)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
    // ... remaining getters ...

    // =====================================================================
    public static final class Builder {

        // Required: final, arrive through the builder's constructor
        private final Customer customer;
        private final Restaurant restaurant;

        // Accumulating and optional: mutable DURING construction
        private final List<OrderLine> lines = new ArrayList<>();
        private DeliveryType deliveryType = DeliveryType.PICKUP;   // default value
        private Address deliveryAddress;
        private TimeSlot timeSlot;
        private String instructions;
        private String promoCode;
        private boolean includeCutlery = false;
        private String contactPhone;

        private Builder(Customer customer, Restaurant restaurant) {
            this.customer = Objects.requireNonNull(customer, "customer");
            this.restaurant = Objects.requireNonNull(restaurant, "restaurant");
        }

        public Builder line(Product product, int quantity) {
            lines.add(new OrderLine(product, quantity));
            return this;                              // <- what makes chaining possible
        }

        public Builder homeDelivery(Address address) {
            this.deliveryType = DeliveryType.DELIVERY;
            this.deliveryAddress = Objects.requireNonNull(address, "address");
            return this;
        }

        public Builder pickupAtRestaurant() {
            this.deliveryType = DeliveryType.PICKUP;
            this.deliveryAddress = null;
            return this;
        }

        public Builder timeSlot(TimeSlot timeSlot) { this.timeSlot = timeSlot; return this; }
        public Builder instructions(String text) { this.instructions = text; return this; }
        public Builder promoCode(String code) { this.promoCode = code; return this; }
        public Builder withCutlery() { this.includeCutlery = true; return this; }
        public Builder contactPhone(String phone) { this.contactPhone = phone; return this; }

        /** The boundary: validates EVERYTHING and delivers the finished product. */
        public Order build() {
            if (lines.isEmpty()) {
                throw new IllegalStateException("An order needs at least one line");
            }
            if (deliveryType == DeliveryType.DELIVERY && deliveryAddress == null) {
                throw new IllegalStateException("Home delivery requires an address");
            }
            if (timeSlot != null && !restaurant.deliversIn(timeSlot)) {
                throw new IllegalStateException("The restaurant does not deliver in that time slot");
            }
            return new Order(this);
        }
    }
}

Fine points of the code, one by one:

  • Order's private constructor: nobody can manufacture an order bypassing the builder (the same "lock the door" move as in Singleton, with a different purpose).
  • Required values in the builder's constructor, optionals as methods: impossible to forget the customer or the restaurant; impossible to dirty the call with nulls for what is not used.
  • return this: each method returns the builder itself; that is all it takes to make the chaining fluent.
  • Methods with semantics, not just setters: homeDelivery(address) and pickupAtRestaurant() group coherent changes (type + address) and make several absurd states inexpressible; better than a loose setDeliveryType() and setAddress().
  • List.copyOf in the constructor: a defensive copy; even if someone reuses the builder afterwards, the already-created order does not change.

Immutability and validation in build()

These two words are half the pattern's value, so they deserve their own section:

Immutability: the builder is mutable while construction lasts (that is what it is for), but the product is born complete and frozen: all fields final, no setters, collections copied. The practical consequence in PideYa is enormous: an Order can travel through promotion calculation, payment, and notifications — even on different threads — with the guarantee that nobody alters it along the way. The pattern thus resolves the classic tension "I want immutable objects but with many optionals": mutability confined to the scaffolding, immutability in the building.

Validation in build(): build() is the single boundary between "loose data" and "valid order". All the integrity rules — at least one line, delivery ⇒ address, time slot compatible with the restaurant — are checked there, once, in one place. The resulting guarantee is worth gold: if an Order exists, it is valid; no later code needs to re-check. And cross-field validation rules (the ones involving several fields at once) finally have a natural home, which neither the telescoping constructor nor setters ever offered.

A scoping note: we are talking about structural validation of the object. Dynamic business rules (is the promo code still active? is the restaurant open right now?) belong to domain services, not to the builder, which should not carry dependencies on repositories or clocks.

The Director in the modern variant

And the Director — did it get lost? No: it transformed. Its essence — a reusable recipe with a name — remains useful when certain combinations of steps repeat. In PideYa, the "typical orders" are recipes:

/** Modern Director: encapsulates frequent construction recipes. */
public class OrderRecipes {

    /** A restaurant's "menu of the day", ready in one call. */
    public static Order dailyMenu(Customer customer, Restaurant rest, Address addr) {
        Order.Builder builder = Order.builder(customer, rest)
                .homeDelivery(addr)
                .withCutlery();
        rest.getDailyMenu().forEach(prod -> builder.line(prod, 1));
        return builder.build();
    }

    /** Corporate order: pickup, no cutlery, invoiced to the company. */
    public static Order corporateOrder(Customer customer, Restaurant rest, List<Product> prods) {
        Order.Builder builder = Order.builder(customer, rest)
                .pickupAtRestaurant()
                .instructions("Corporate order - ask for reception");
        prods.forEach(p -> builder.line(p, 1));
        return builder.build();
    }
}

It is the same GoF division of roles (the recipe separated from the steps), without the interface ceremony that buys nothing here. When the "repeat order" feature needs to build an order from another one, we will see there is an even more direct alternative: the next lesson.

When to use it and when not to

Use it when:

  • A constructor exceeds ~4 parameters or mixes several of the same type (a guideline threshold, not dogma).
  • There are several optionals with free combinations: exactly the Order case.
  • You want immutability + centralized validation in data-heavy objects.
  • You are building something incremental by nature (a query, a report, an HTTP request).
  • (GoF form with Director) The same construction sequence must produce different representations.

Do not use it when:

  • The class has 2-3 required fields and no optionals: a normal constructor — or a Java record — is shorter, clearer, and sufficient. A builder there is pure ceremony (over-engineering).
  • The problem is which class to instantiate, not how to assemble it: that is factory territory.
  • You only want named parameters: first consider whether a record with with... methods, or parameters grouped into a small object, solves the case with less code.

Relation to other patterns (mention only): an Abstract Factory may return products that a builder assembles internally; the classic Director is a relative of Template Method (fixed recipe, variable steps); Composite structures are often built with builders; and Prototype is the alternative when the starting point is an existing object rather than loose data.

Common Mistakes and Tips

  • A builder without validation: a build() that only does new wastes half the pattern. If there is nothing to validate and no immutability to protect, maybe you did not need a builder.
  • A mutable builder as a setter substitute: passing the builder around the system and calling build() three times in different places reintroduces the half-baked object we wanted to avoid. The builder should live briefly: it is created, filled in, and dies at build().
  • Forgetting the defensive copy of collections: without List.copyOf, whoever keeps the original list can mutate the "immutable" order from outside. A silent, classic bug.
  • Reusing a builder for several products without thinking: after build(), the builder retains its state; a second build() creates another identical order (sometimes wanted, often not). Decide and document the policy; when in doubt, one builder = one product.
  • Positional booleans in disguise: builder.cutlery(true) repeats the telescoping problem in miniature. Better withCutlery() / nothing.
  • Tip: when a group of parameters always travels together (street, number, floor, city...), do not give them all a builder: extract the Address object first. Many "telescoping constructors" are really undiscovered value objects.

Exercises

Exercise 1: a builder for Invoice

PideYa's Invoice to restaurants has required fields restaurant, period, and commissionAmount, and optional fields volumeDiscount (BigDecimal), notes (String), and an alternative collection iban. Cross-field rule: if there is a volumeDiscount, it cannot exceed 20% of commissionAmount. Write the immutable class with its fluent builder.

Exercise 2: find the builder's flaws

This builder passed a code review with three serious flaws. Locate them:

public class TableReservation {
    public String restaurant;
    public LocalDateTime date;
    public int guests;

    public static class Builder {
        private final TableReservation r = new TableReservation();

        public Builder restaurant(String name) { r.restaurant = name; return this; }
        public Builder date(LocalDateTime d) { r.date = d; return this; }
        public Builder guests(int n) { r.guests = n; return this; }

        public TableReservation build() { return r; }
    }
}

Exercise 3: a Director recipe

Using the lesson's Order.builder(...), write the recipe officeRestock(Customer, Restaurant, Address): a home-delivery order for the office, with 10 units of water and 5 of coffee (assume Product WATER and COFFEE are available), a 9:00 to 9:30 time slot, instructions "Reception, 2nd floor", and no cutlery.

Solutions

Solution 1:

public final class Invoice {
    private final Restaurant restaurant;
    private final Period period;
    private final BigDecimal commissionAmount;
    private final BigDecimal volumeDiscount;   // may be null
    private final String notes;                // may be null
    private final String iban;                 // may be null

    private Invoice(Builder b) {
        this.restaurant = b.restaurant;
        this.period = b.period;
        this.commissionAmount = b.commissionAmount;
        this.volumeDiscount = b.volumeDiscount;
        this.notes = b.notes;
        this.iban = b.iban;
    }

    public static Builder builder(Restaurant r, Period p, BigDecimal commissions) {
        return new Builder(r, p, commissions);
    }

    public static final class Builder {
        private final Restaurant restaurant;
        private final Period period;
        private final BigDecimal commissionAmount;
        private BigDecimal volumeDiscount;
        private String notes;
        private String iban;

        private Builder(Restaurant r, Period p, BigDecimal commissions) {
            this.restaurant = Objects.requireNonNull(r);
            this.period = Objects.requireNonNull(p);
            this.commissionAmount = Objects.requireNonNull(commissions);
        }

        public Builder volumeDiscount(BigDecimal d) { this.volumeDiscount = d; return this; }
        public Builder notes(String n) { this.notes = n; return this; }
        public Builder iban(String iban) { this.iban = iban; return this; }

        public Invoice build() {
            if (volumeDiscount != null) {
                BigDecimal cap = commissionAmount.multiply(new BigDecimal("0.20"));
                if (volumeDiscount.compareTo(cap) > 0) {
                    throw new IllegalStateException("The discount exceeds 20% of the commissions");
                }
            }
            return new Invoice(this);
        }
    }
}

Solution 2: (a) TableReservation has public, mutable fields and no private constructor: anyone can create it empty with new TableReservation() or mutate it after build(): there is no immutability and no single door; (b) the builder mutates the final instance directly from the very start: if the builder is abandoned halfway, an invalid object has existed, and two build() calls return the same shared object (accidental aliasing); (c) build() validates nothing: you can build a reservation with no restaurant, a past date, or 0 guests. The fix: private final fields, a private constructor that copies from the builder, and validation of required fields and ranges in build().

Solution 3:

public static Order officeRestock(Customer customer, Restaurant rest, Address office) {
    return Order.builder(customer, rest)
            .line(WATER, 10)
            .line(COFFEE, 5)
            .homeDelivery(office)
            .timeSlot(TimeSlot.of(9, 0, 9, 30))
            .instructions("Reception, 2nd floor")
            .build();                 // no withCutlery(): the default is already false
}

Conclusion

Builder closes the flank the factories did not cover: when the difficulty lies not in choosing the class but in assembling it, step-by-step construction with named methods eliminates the telescoping constructor, and the final build() turns assembly into a boundary of validation and immutability — if an Order exists, it is valid and nobody will be able to corrupt it. You can now also tell the GoF form apart (a Director with a recipe + interchangeable builders, useful for multiple representations) from the fluent inner variant that dominates modern Java, and you know its proportionality rule: below four fields, it is probably overkill.

One last way of bringing objects into the world remains, and it is the most different of all: neither choosing a class nor assembling piece by piece, but copying an object that already exists. It is exactly what PideYa's "repeat my last order" feature calls for, and it has more sharp edges in Java than it appears. See you in Prototype.

© Copyright 2026. All rights reserved