Quick quiz: in null, null, null, null, which null is the gift message? If you had to go and check the constructor, you’ve just met smell S02. Builder replaces a long line of positional arguments with named steps, and checks that everything required is there before the object exists.

   
Category Creational
Smell fixed S02: Orders and invoices use eleven-argument constructors
Code patterns/creational/builder

The Scene

Every successful checkout creates two objects: an Order and its Invoice. Both have a lot of parts. An order has an ID, a customer, a status, lines, a quote, a payment type, and then a handful of optional extras like a promo code, a gift message, a note, a tracking number and a cancellation reason.

That’s eleven values. And in the baseline, they’re all passed by position.


The Smell

Here’s how OrderService creates an order:

        // SMELL S02: the order is assembled with eleven positional arguments and null optionals
        var order = new Order(
            ids.NextOrderId(), customer, "Pending",
            cart.Lines.Select(CopyLine).ToList(), quote, paymentType,
            cart.PromoCode == "" ? null : cart.PromoCode, null, null, null, null);

And the invoice gets the same treatment:

        var invoice = new Invoice(
            order.Id + "-I", order.Id, order.Customer, order.Lines,
            quote.Subtotal, quote.Discount, quote.AddOns, quote.Shipping, quote.Tax, quote.Total, null);

Look at the invoice line for a second. quote.Subtotal, quote.Discount, quote.AddOns, quote.Shipping, quote.Tax, quote.Total. Six decimals in a row. If someone swaps Discount and AddOns, it still compiles. The tests might even pass, if the values happen to be equal in the test data. And you’d find out when a customer’s invoice doesn’t add up.

Positional arguments are fine for two or three values. At eleven, the call site stops telling you what it’s doing.


The Idea

Builder separates how an object is assembled from the object itself. You set each part by name, in any order, and then ask for the finished product. Right before handing it over, the builder checks that nothing required is missing.

Think of ordering a custom sandwich at a counter. You don’t hand over a list of eleven ingredients in a fixed order. You say “sourdough”, “turkey”, “no onions”, “extra pickles”. The person behind the counter knows the assembly order. And if you forgot to pick a bread, they ask before they start, not after.


The Refactor

The order is now built with named steps:

var order = new OrderBuilder()
    .WithId(ids.NextOrderId())
    .WithCustomer(customer)
    .WithLines(cart.Lines.Select(CopyLine).ToList())
    .WithQuote(quote)
    .WithPaymentType(paymentType)
    .WithPromoCode(cart.PromoCode == "" ? null : cart.PromoCode)
    .Build();

Read it out loud. It says exactly what it does. And the four trailing nulls are gone: optional values you don’t set simply keep their default.

The safety comes from Build(). It refuses to create an order with a required part missing:

    public Order Build()
    {
        if (id is null)
        {
            throw new InvalidOperationException("order id is required");
        }

        if (customer is null)
        {
            throw new InvalidOperationException("order customer is required");
        }

The invoice benefits even more. Instead of copying six totals by hand, InvoiceBuilder reads them from the order’s quote itself:

        var invoice = new InvoiceBuilder()
            .ForOrder(order)
            .Build();

That swapped-Discount-and-AddOns bug? There’s nowhere left for it to happen.

classDiagram
    class OrderService
    class OrderBuilder {
        +WithId(value) OrderBuilder
        +WithCustomer(value) OrderBuilder
        +WithLines(value) OrderBuilder
        +WithQuote(value) OrderBuilder
        +WithPaymentType(value) OrderBuilder
        +Build() Order
    }
    class InvoiceBuilder {
        +ForOrder(value) InvoiceBuilder
        +WithNote(value) InvoiceBuilder
        +Build() Invoice
    }
    class Order
    class Invoice

    OrderService --> OrderBuilder : assembles
    OrderBuilder ..> Order : builds
    OrderService --> InvoiceBuilder : assembles
    InvoiceBuilder ..> Invoice : builds
    InvoiceBuilder --> Order : reads order and quote

In the book’s terms, OrderService plays the director: the part that knows which steps to call. The builders know how to put the parts together.


What It Cost

5 files changed: about 265 lines added and only 10 removed. That’s the most lopsided ratio in the series so far. Almost all of it is the two builder classes, with one With... method per field and the checks in Build().

That’s the honest trade: Builder trades a short, cryptic line for a long, readable class.


When Not to Use It

  • Few values, all required. A constructor with three required parameters is already clear. A builder just adds ceremony.
  • Modern C# already covers it. Object initializers, required members and records with with expressions handle many cases where you’d once write a builder. Reach for a builder when construction needs validation across several fields or has many optional parts, like CornerStore’s orders.
  • You only build it in one place. If there’s a single call site and it’s easy to read, the builder may never pay for itself.

Easily Confused With

Abstract Factory. Both are creational, and both hide construction details from the caller. The difference is what they produce and when. Abstract Factory returns finished objects from a family in one call. Builder assembles one complex object step by step, and only hands it over at the end. If you’re choosing which kind of object to make, think factory. If you’re struggling with how many parts one object has, think builder.


Try It Yourself

dotnet build patterns/creational/builder/Builder.slnx
dotnet test patterns/creational/builder/Builder.slnx
dotnet run --project patterns/creational/builder/src/CornerStore/CornerStore.csproj -- scenarios/trading-day.json

Try removing .WithQuote(quote) from the builder chain and run the tests. The failure tells you exactly what’s missing, which is far better than a quiet null that blows up three methods later.

The participant table and sequence diagram are in the Builder README, and the full change is in DIFF.md.


Previous: Abstract Factory · Next: Factory Method