Builder

Builder

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

Concern Builder Object initializer (required/init) Telescoping constructors
Required field enforcement Runtime (in Build()) Compile-time (required keyword) Compile-time
Optional parameters Named methods, self-documenting Named properties, self-documenting Combinatorial explosion
Validation Centralized in Build() Must use custom setter or factory Scattered across overloads
Computed fields Encapsulated in Build() Caller's responsibility Caller's responsibility
Fluent chaining Yes No No
Complexity High Low Medium

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


Whats next