The ticket says “checkout needs gift wrap, a tax region, and an optional loyalty id.” You open ChargeRequest and add a sixth constructor. Callers that only had currency keep compiling against the old one. Callers that pass (order, currency, region, wrap, loyalty) start swapping the two Strings. A test fails because true bound to giftWrap and the loyalty id went into the tax region. You also just made every checkout site a co-owner of the next optional flag.

That is Builder’s entire complaint. From the Design Patterns Roadmap: construction with many optional steps should not be a constructor argument list.

This post stays on one object that got too many knobs. The three families and the shared Order / PaymentGateway / DiscountPolicy lab live on the hub. Order itself stays a four-field record. The thing that grew is the checkout command you hand to OrderProcessor.

The constructor that grew every flag

Order is still the data. Checkout, though, accumulated options that are not the order: currency, tax region, gift wrap, loyalty. The honest first version was one extra argument. The fifth version is a telescope:

public final class ChargeRequest {

    private final Order order;
    private final String currency;
    private final String taxRegion;
    private final boolean giftWrap;
    private final String loyaltyId;

    public ChargeRequest(Order order) {
        this(order, "USD", "US", false, null);
    }

    public ChargeRequest(Order order, String currency) {
        this(order, currency, "US", false, null);
    }

    public ChargeRequest(Order order, String currency, String taxRegion) {
        this(order, currency, taxRegion, false, null);
    }

    public ChargeRequest(
            Order order,
            String currency,
            String taxRegion,
            boolean giftWrap,
            String loyaltyId) {
        this.order = order;
        this.currency = currency;
        this.taxRegion = taxRegion;
        this.giftWrap = giftWrap;
        this.loyaltyId = loyaltyId;
    }
}

OrderProcessor.process grew with it. Discount and charge still run, but the signature now carries every marketing flag:

public void process(
        Order order, String currency, String taxRegion, boolean giftWrap, String loyaltyId) {
    BigDecimal payable = discount.payable(order);
    // gift-wrap surcharge and loyalty sit next to the charge...
    PaymentResult result = gateway.charge(order, payable);
    // ...
}

Now price the gift-wrap ticket — and the next three:

Add gift wrap                 -> new parameter, every caller reordered
Add loyalty id                -> two Strings in a row, silent swap bugs
Default currency in tests     -> which overload did you hit?
Construct in a controller     -> copy a 5-arg new, hope the booleans line up

Four costs, and none of them are about charging a card. Optional checkout flags have become positional arguments on the paid-order workflow.

Note: The problem is not “more than three fields.” A record with four required components is fine — that is Order. The problem is a mix of required and optional, with defaults, where callers skip the middle. Telescoping constructors encode that mix as overload order.

What Builder actually is

Two parts, one promise:

PartJob
ProductThe finished, usually immutable object (ChargeRequest).
BuilderCollects steps. Knows which ones are required. Calls new once.

The builder is allowed to be mutable and chatty. The product is not. Callers never see a half-built charge. build() is the only place that may throw for a missing required field.

You do not need a Director. Gang of Four used one to replay the same steps for different products. Checkout has one product. A fluent type — nested on the record, or a dedicated ChargeRequestBuilder — is the whole pattern.

The product can still be a record. Records already give you a canonical constructor. The builder is for getting to that constructor without a telescope:

public record ChargeRequest(
        Order order,
        String currency,
        String taxRegion,
        boolean giftWrap,
        String loyaltyId) {

    public static Builder builder(Order order) {
        return new Builder(order);
    }

    public static final class Builder {
        // required vs optional live here
    }
}

Order stays new Order(id, email, items, total) — four required fields, no builder. Anti-ceremony starts at the product, not the pattern name.

Required through the factory, optional through setters

builder(Order) is the required step. Everything else has a default or is absent. build() copies into the record and nowhere else:

public static final class Builder {

    private final Order order;
    private String currency = "USD";
    private String taxRegion = "US";
    private boolean giftWrap = false;
    private String loyaltyId;

    private Builder(Order order) {
        this.order = Objects.requireNonNull(order, "order");
    }

    public Builder currency(String currency) {
        this.currency = Objects.requireNonNull(currency, "currency");
        return this;
    }

    public Builder taxRegion(String taxRegion) {
        this.taxRegion = Objects.requireNonNull(taxRegion, "taxRegion");
        return this;
    }

    public Builder giftWrap() {
        this.giftWrap = true;
        return this;
    }

    public Builder loyaltyId(String loyaltyId) {
        this.loyaltyId = loyaltyId;
        return this;
    }

    public ChargeRequest build() {
        return new ChargeRequest(order, currency, taxRegion, giftWrap, loyaltyId);
    }
}

A dedicated ChargeRequestBuilder top-level type is the same design with a longer name. Use nested when the builder exists only to produce this record. Use a separate type when two products share steps — that is rare in application code, and it is how the Gang of Four Director story starts. Do not introduce a Director for one record.

Call sites now name the knobs they care about. Gift wrap is a method, not the fourth boolean in a row:

ChargeRequest request = ChargeRequest.builder(order)
        .currency("EUR")
        .taxRegion("DE")
        .giftWrap()
        .loyaltyId("gold-42")
        .build();

A test that only needs an order and a currency never mentions wrap or loyalty. The defaults stay in one place — the builder — not in five overloads.

Note: giftWrap() with no argument is a choice. giftWrap(boolean) is also fine when the value comes from a form. What you should not do is new ChargeRequest(order, "EUR", "DE", true, null) from six controllers that each remember the order of true and null differently.

The processor takes the product

OrderProcessor receives a finished ChargeRequest, not a builder and not a new parameter per flag. Discount and the gateway still see an Order; flags that affect price belong in DiscountPolicy or a tiny surcharge — not in build():

public class OrderProcessor {

    private final PaymentGateway gateway;
    private final DiscountPolicy discount;
    private final OrderRepository orders;

    public OrderProcessor(
            PaymentGateway gateway, DiscountPolicy discount, OrderRepository orders) {
        this.gateway = gateway;
        this.discount = discount;
        this.orders = orders;
    }

    public void process(ChargeRequest request) {
        Order order = request.order();
        BigDecimal payable = discount.payable(order);
        if (request.giftWrap()) {
            payable = payable.add(GIFT_WRAP_FEE);
        }
        PaymentResult result = gateway.charge(order, payable);
        if (!result.approved()) {
            throw new PaymentDeclinedException(order.id(), result.failureReason());
        }
        orders.markPaid(order.id(), result.reference());
    }
}

Selection of currency and region stays at the edge — the controller that already knows the customer’s locale. The builder is how that edge constructs. The processor is how checkout runs. Mixing them puts new Builder inside process, which is the telescope in fluent clothing.

If gift wrap later becomes a real pricing rule with its own tests, it is a DiscountPolicy (or a stacked policy from the Strategy post), not another setter. The builder’s job is “assemble the command,” not “invent promotions.”

What the diff looks like now

Same feature request, both designs:

Before — add gift wrap
  M ChargeRequest.java         new constructor + new parameter on every overload
  M OrderProcessor.java        new argument on process()
  M every checkout controller  positional true/false, argument-order bugs

After — add gift wrap
  M ChargeRequest.Builder      one method, one default
  M OrderProcessor             reads request.giftWrap() if price depends on it
  checkout call sites          .giftWrap() only where the product is wrapped

Call sites that do not wrap a gift never change. That is the scoreboard. A builder that every caller must still touch for a new required field is not buying you much — put required data in builder(...) or in the record header and fail in build().

Proving construction without charging

The seam pays a second dividend: you can unit-test defaults and required-field checks with no gateway.

@Test
void builderDefaultsCurrencyAndLeavesWrapOff() {
    Order order = new Order("o-1", "a@b.com", List.of(), new BigDecimal("19.99"));

    ChargeRequest request = ChargeRequest.builder(order).build();

    assertEquals("USD", request.currency());
    assertFalse(request.giftWrap());
    assertNull(request.loyaltyId());
}

And process can take a request built in one line, with a fake gateway, without five dummy arguments:

@Test
void giftWrapAddsFeeBeforeCharge() {
    FakePaymentGateway gateway = FakePaymentGateway.recording();
    OrderProcessor processor =
            new OrderProcessor(gateway, new NoDiscount(), new RecordingRepository());
    ChargeRequest request = ChargeRequest.builder(order()).giftWrap().build();

    processor.process(request);

    assertEquals(order().total().add(GIFT_WRAP_FEE), gateway.lastAmount());
}

If constructing a valid ChargeRequest in a test still needs a 5-arg constructor, the builder is not the API your tests use — and then it is not the API your production code will use either.

When Builder is the wrong move

Skip the builder when:

  • Two or three fields, all required. Order(id, email, items, total) is a record constructor. A nested OrderBuilder with four setters is four extra methods and a build() that can still forget email.
  • Every field is required and there are no defaults. A canonical constructor (or a compact record constructor that validates) is the API. Fluent setters that all must be called anyway recreate the telescope with more lines.
  • The object is a bag you mutate after new. Setters on a mutable ChargeRequest are not Builder. Builder produces the product once; it does not stay attached.
  • You are hiding an invalid state the type should not have. If currency is always required, do not default it to "USD" in a builder used by a European checkout and call it convenience. Fail in build(), or take it as a required argument to builder(order, currency).

The healthy trigger is optional steps you can point at in call sites that skip them. Gift wrap, loyalty, tax region with a sensible default — those are steps. id and customerEmail are not.

Java already has examples in the JDK: HttpRequest.newBuilder(), ProcessBuilder, StringBuilder (a different idea: a mutable buffer, not a GoF product). Looking like those is not a reason to wrap LineItem. Application DTOs with three fields stay records.

Builder also is not Factory Method. Factory Method answers “which subtype do I get?” Builder answers “how do I supply the many arguments of this type?” If checkout must pick StripeGateway vs PayPalGateway, that is a factory at the edge. If checkout must assemble currency and wrap flags, that is a builder. Do not merge them into ChargeRequestFactoryBuilder.

Cheat sheet

Product    ChargeRequest (record)    immutable; canonical constructor still exists
Builder    ChargeRequest.Builder     mutable; required in builder(), optional as steps
Caller     controller / test         names only the knobs it uses, then build()
Context    OrderProcessor            takes the product, never the builder

Trigger to apply: optional fields, defaults, or telescoping constructors
Trigger to stop:  2–3 required fields, no defaults, record constructor is enough
Scoreboard:       new optional flag = one setter; callers that skip it do not change
Not a factory:    same type, many arguments — not a family of subtypes

Do:

  • Keep the product a record (or a final class) and put mutability only on the builder.
  • Force required data through builder(...) or a build() check, not a comment.
  • Nest the builder when it produces one type; skip a Director until you have two products with the same steps.
  • Let OrderProcessor depend on ChargeRequest, DiscountPolicy, and PaymentGateway — not on Builder.

Don’t:

  • Write a builder for Order’s four required components.
  • Pass the builder into process and call build() inside the workflow.
  • Telescoping constructors plus a builder for the same type — pick one API.
  • Default a required field to a value that is wrong for half your customers.

Wrap-up

Builder is a mutable assembler that produces an immutable product, so optional checkout flags stop living as constructor overloads and as extra arguments on OrderProcessor. ChargeRequest can still be a record. builder(order) is the required step; .giftWrap() is optional; process reads the finished command. Order never needed this treatment, and neither does the next DTO with three required fields.

If the next pain is a global AppContext.getInstance(), that is Singleton used as a bag — read when one instance is actually required.

Next optional step in the series One instance, used carefully — and when constructor injection is the honest move. Singleton: One Instance, Used Carefully