Ordering a custom sandwich at a sub shop is a Builder in action. You start with bread, add meat, choose veggies, pick a sauce, decide on a size — step by step, in any order, skipping what you don’t want. At the end, the sandwich artist assembles everything into a finished product. You never see a constructor that takes twelve arguments for bread-type, meat-type, lettuce-yes-no, tomato-yes-no.

The Builder pattern separates the construction of a complex object from its representation. A Builder class accumulates configuration through a fluent API — .WithShipping(address), .WithDiscount(code), .WithGiftWrap() — and a final Build() call validates constraints and assembles the finished object. Each step is optional, order-independent, and self-documenting. For simple objects, modern C# required properties and init setters with object initializers are often sufficient — Builder earns its complexity when construction involves cross-field validation, computed values, or a director-driven assembly sequence where the same process produces different representations.

flowchart LR
    Client -->|configures| Builder
    Builder -->|WithShipping| Builder
    Builder -->|WithDiscount| Builder
    Builder -->|WithGiftWrap| Builder
    Builder -->|Build and validate| Order["Order"]
    Director -->|drives build steps| Builder

Problem

OrderService.CreateOrder() takes a growing parameter list. Every new requirement adds another parameter:

public class OrderService
{
    // ⚠️ 12-parameter constructor — callers must know the correct order
    public Order CreateOrder(
        Customer customer,
        List<OrderItem> items,
        Address shippingAddress,
        Address? billingAddress,      // ⚠️ null means "same as shipping" — implicit convention
        string? discountCode,
        bool giftWrap,
        string? giftMessage,
        DateTime? requestedDeliveryDate,
        string shippingCarrier,
        bool signatureRequired,
        string? specialInstructions,
        bool isBusinessOrder)
    {
        // ⚠️ Validation scattered — which combinations are invalid?
        if (giftWrap && string.IsNullOrEmpty(giftMessage))
            throw new ArgumentException("Gift wrap requires a message");
        if (requestedDeliveryDate.HasValue && requestedDeliveryDate < DateTime.UtcNow.AddDays(1))
            throw new ArgumentException("Delivery date must be at least tomorrow");
 
        var order = new Order
        {
            Id = Guid.NewGuid(),
            Customer = customer,
            Items = items,
            // ⚠️ Computed fields mixed with assignment — easy to miss one
            Total = items.Sum(i => i.UnitPrice * i.Quantity),
            ShippingAddress = shippingAddress,
            BillingAddress = billingAddress ?? shippingAddress,
            // ... 8 more assignments
        };
        return order;
    }
}
 
// ⚠️ Call site — what does 'true, null, false, true' mean?
var order = orderService.CreateOrder(customer, items, shipping, null, "SAVE10",
    true, "Happy Birthday!", null, "FedEx", false, null, false);

Here’s what breaks when requirements change: adding a “priority shipping” flag means a 13th parameter, and every existing call site must be updated even if they don’t use priority shipping.

Solution

OrderBuilder accumulates configuration through named methods and validates on Build():

public class Order
{
    public Guid Id { get; init; }
    public Customer Customer { get; init; } = null!;
    public IReadOnlyList<OrderItem> Items { get; init; } = [];
    public decimal Subtotal { get; init; }
    public decimal DiscountAmount { get; init; }
    public decimal Total { get; init; }
    public Address ShippingAddress { get; init; } = null!;
    public Address BillingAddress { get; init; } = null!;
    public ShippingOptions Shipping { get; init; } = null!;
    public GiftOptions? Gift { get; init; }
    public string? SpecialInstructions { get; init; }
    public bool IsBusinessOrder { get; init; }
}
 
public class OrderBuilder(Customer customer)
{
    private readonly List<OrderItem> _items = [];
    private Address? _shippingAddress;
    private Address? _billingAddress;
    private string? _discountCode;
    private GiftOptions? _gift;
    private ShippingOptions _shipping = ShippingOptions.Standard;
    private string? _specialInstructions;
    private bool _isBusinessOrder;
 
    public OrderBuilder AddItem(Product product, int quantity)
    {
        if (quantity <= 0) throw new ArgumentOutOfRangeException(nameof(quantity));
        _items.Add(new OrderItem(product.Id, quantity, product.Price));
        return this; // ✅ fluent — enables chaining
    }
 
    public OrderBuilder ShipTo(Address address)
    {
        _shippingAddress = address;
        return this;
    }
 
    public OrderBuilder BillTo(Address address)
    {
        _billingAddress = address;
        return this;
    }
 
    public OrderBuilder WithDiscount(string code)
    {
        _discountCode = code;
        return this;
    }
 
    public OrderBuilder WithGiftWrap(string message) // ✅ gift wrap and message are one concept
    {
        _gift = new GiftOptions(message);
        return this;
    }
 
    public OrderBuilder WithShipping(ShippingOptions options)
    {
        _shipping = options;
        return this;
    }
 
    public OrderBuilder AsBusinessOrder()
    {
        _isBusinessOrder = true;
        return this;
    }
 
    public Order Build()
    {
        // ✅ All validation in one place
        if (_shippingAddress is null)
            throw new InvalidOperationException("Shipping address is required");
        if (_items.Count == 0)
            throw new InvalidOperationException("Order must contain at least one item");
        if (_shipping.RequestedDeliveryDate.HasValue &&
            _shipping.RequestedDeliveryDate < DateTime.UtcNow.AddDays(1))
            throw new InvalidOperationException("Delivery date must be at least tomorrow");
 
        var subtotal = _items.Sum(i => i.UnitPrice * i.Quantity);
        var discount = _discountCode is not null ? CalculateDiscount(subtotal, _discountCode) : 0m;
 
        return new Order
        {
            Id = Guid.NewGuid(),
            Customer = customer,
            Items = _items.AsReadOnly(),
            Subtotal = subtotal,
            DiscountAmount = discount,
            Total = subtotal - discount,                    // ✅ computed field, not caller's responsibility
            ShippingAddress = _shippingAddress,
            BillingAddress = _billingAddress ?? _shippingAddress, // ✅ default logic encapsulated
            Shipping = _shipping,
            Gift = _gift,
            SpecialInstructions = _specialInstructions,
            IsBusinessOrder = _isBusinessOrder
        };
    }
 
    private static decimal CalculateDiscount(decimal subtotal, string code) =>
        code == "SAVE10" ? subtotal * 0.10m : 0m;
}
 
// ✅ Call site is self-documenting
var order = new OrderBuilder(customer)
    .AddItem(laptop, 1)
    .AddItem(mouse, 2)
    .ShipTo(shippingAddress)
    .WithDiscount("SAVE10")
    .WithGiftWrap("Happy Birthday!")
    .WithShipping(ShippingOptions.Express)
    .Build();

Adding priority shipping now means one new WithPriorityShipping() method — existing call sites are unaffected.

You Already Use This

WebApplicationBuilder / IHostBuilder — the canonical .NET Builder. builder.Services.AddDbContext<>(), builder.Configuration.AddJsonFile(), builder.Logging.AddConsole() accumulate configuration; builder.Build() assembles the WebApplication. The director pattern is implicit: Program.cs is the director.

StringBuilder — the original .NET Builder. Append(), AppendLine(), Insert() accumulate string segments; ToString() produces the final string. Avoids the O(n²) allocation cost of string concatenation.

IQueryable<T> LINQ chain — each LINQ operator (Where, OrderBy, Select) adds to the query expression tree; ToListAsync() is the Build() call that executes it. EF Core translates the accumulated expression tree into SQL.

UriBuilder — builds Uri objects from scheme, host, port, path, and query components without string manipulation.

Pitfalls

Over-engineering simple objects — if an object has 3-4 required fields and no validation logic, a Builder adds indirection for no benefit. Use C# required properties with object initializers: new Order { Customer = customer, Items = items, Total = total }. The compiler enforces required fields at the call site.

Mutable builder state leaking into the product — if Build() returns the builder’s internal list directly (not a copy), callers can mutate the order’s items after construction. Always copy or wrap collections: _items.AsReadOnly() or new List<OrderItem>(_items).

Missing validation on Build() — builders that don’t validate produce invalid objects silently. Validate all invariants in Build(), not in individual setter methods (where partial state is expected). The builder’s job is to accumulate; Build()’s job is to validate and assemble.

Tradeoffs

ConcernBuilderObject initializer (required/init)Telescoping constructors
Required field enforcementRuntime (in Build())Compile-time (required keyword)Compile-time
Optional parametersNamed methods, self-documentingNamed properties, self-documentingCombinatorial explosion
ValidationCentralized in Build()Must use custom setter or factoryScattered across overloads
Computed fieldsEncapsulated in Build()Caller’s responsibilityCaller’s responsibility
Fluent chainingYesNoNo
ComplexityHighLowMedium

Decision rule: Start with required properties and object initializers for new objects. Introduce Builder when: (1) construction requires multi-step validation, (2) computed fields depend on multiple inputs, (3) the same assembly process produces different representations, or (4) a director needs to drive construction programmatically. The signal is when Build() does real work beyond assignment.

Questions

References