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,
requiredmembers and records withwithexpressions 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