GoF Creational Pattern

Builder

Build complex objects step by step, without a monster constructor. Configure only what you need, skip what you don't.

26 Q&As 6 Bug Studies 10 Pitfalls 4 Testing Strategies C# / .NET
Section 1

TL;DR

You already use Builder every day — you just don't call it that. Every time you write WebApplication.CreateBuilder(args) in Program.cs, or use StringBuilder to assemble a string piece by piece, or chain methods like .AddLogging().AddDatabase().Build() — that's the Builder pattern. It's one of the most common patterns in .NET.

What: Ever had a class that needs 10+ things configured, but most of them are optional? Without Builder, you'd end up with a monster constructor — new Pizza(true, false, true, null, "large", null, true) — where nobody knows what each true means. Builder fixes this: you create the object step by step, setting only what you need with clearly named methods. When you're done, call Build() to get the finished product.

When: Use it whenever an object has many optional settings, multiple valid configurations, or the setup involves several steps. Classic examples: building an HTTP request (URL, headers, body, method), configuring an email (to, cc, bcc, subject, body, attachments), or assembling a complex order.

In C# / .NET: The most popular approach is the fluent builderA builder where each method returns the builder itself, so you can chain calls: builder.SetName("x").SetAge(25).Build(). Reads almost like English and makes the construction process very clear. — methods that chain together, ending with .Build(). The framework uses this everywhere: WebApplicationBuilder, IHostBuilder, StringBuilder. For your own code, build fluent builders for domain objectsObjects representing business concepts — Order, Invoice, User. These often have complex construction rules (required fields, validation, optional extras) that builders handle cleanly. that have complex construction rules.

Quick Code:

Builder.cs
// The Product — immutable once built
public sealed class HttpRequest
{
    public string Url { get; }
    public string Method { get; }
    public IReadOnlyDictionary<string, string> Headers { get; }
    public string? Body { get; }

    internal HttpRequest(string url, string method,
        Dictionary<string, string> headers, string? body)
    {
        Url = url; Method = method;
        Headers = new ReadOnlyDictionary<string, string>(
            new Dictionary<string, string>(headers)); // truly immutable
        Body = body;
    }
}

// The Builder — step-by-step construction with fluent API
public sealed class HttpRequestBuilder
{
    private string _url = "";
    private string _method = "GET";
    private readonly Dictionary<string, string> _headers = new();
    private string? _body;

    public HttpRequestBuilder SetUrl(string url)
    { _url = url; return this; }

    public HttpRequestBuilder SetMethod(string method)
    { _method = method; return this; }

    public HttpRequestBuilder AddHeader(string key, string value)
    { _headers[key] = value; return this; }

    public HttpRequestBuilder SetBody(string body)
    { _body = body; return this; }

    public HttpRequest Build()
    {
        if (string.IsNullOrWhiteSpace(_url))
            throw new InvalidOperationException("URL is required");
        return new HttpRequest(_url, _method, _headers, _body);
    }
}

// Usage — fluent, readable, validated
var request = new HttpRequestBuilder()
    .SetUrl("https://api.example.com/users")
    .SetMethod("POST")
    .AddHeader("Authorization", "Bearer token123")
    .SetBody("{\"name\":\"Alice\"}")
    .Build();
Section 2

Prerequisites

Before diving in:
Interfaces & abstract classes — Builders often follow a contract (interface) so different builders can construct different products using the same set of steps. Method chaining — Fluent builders return this from each method, so you can write builder.SetA().SetB().Build() in one flowing line. If you've used LINQ (.Where().Select().ToList()), you already know this style. Immutability basics — The whole point of Builder is to end up with a finished, unchangeable productOnce Build() returns the object, nobody should be able to modify it. This is achieved with readonly fields, init-only properties, or record types in C#. The builder is mutable (you configure it step by step), but the product is immutable (locked down after construction).. The builder is mutable while you're configuring it, but the final product should be locked down. The problem with big constructors — Ever seen new Pizza(true, false, true, null, "large", null)? That's called a telescoping constructorWhen a class has constructors like Foo(a), Foo(a,b), Foo(a,b,c), Foo(a,b,c,d)... each adding one more parameter. It's unreadable and error-prone — was that true for cheese or pepperoni? Builder eliminates this by using named methods instead. and it's the main reason Builder exists. Understanding why that's painful is the motivation.
Section 3

Real-World Analogies

Subway Sandwich Order

Walk into Subway. The sandwich artist asks you step by step — bread type, protein, cheese, veggies, sauce, size. You don't hand them a 12-item order form all at once. Each choice is independent: skip cheese, double meat, extra sauce. The sandwich is assembled only after all your choices are made. Two customers making different choices in the same process get completely different sandwiches.

SubwayWhat it meansIn code
Sandwich artistThe one who guides the build processDirector
The order process itselfThe steps you can configureBuilder interface
"Italian BMT" recipeOne specific configurationConcrete Builder
Finished sandwichThe final, ready-to-eat resultProduct
Each topping choiceOne step in the build.AddCheese(), .AddSauce()

Building a custom PC: you pick a CPU, motherboard, RAM, GPU, storage, PSU, and case — each independently. A "gaming build" director picks high-end GPU + fast RAM. A "workstation build" director picks ECC RAM + multi-core CPU. Same building process, different configurations, different final products. You wouldn't pass all 8 components as constructor parameters — you'd build step by step and validate compatibility at the end.

Real WorldCode Concept
PC build guideDirector
Component selection processIPcBuilder
"Gaming PC" configGamingPcBuilder
Assembled PCComputer (Product)

Resume builder websites let you fill in sections independently — personal info, education, experience, skills, projects. Some sections are required (name, contact), others optional (hobbies, references). You configure each section, then click "Generate PDF" (the Build() call). The same data can produce a "Modern" template, a "Classic" template, or a "Minimal" template — same construction steps, different representations.

Quick-Fire Analogies

Coffee Order

Size → Milk → Syrup → Extras → Build() = your latte. Skip any step. The barista (Director) knows the "seasonal special" recipe.

Car Configurator

Engine → Color → Wheels → Interior → Build(). The factory won't start until ALL required choices are made. That's Build() validation.

House Blueprint

Foundation → Framing → Roof → Plumbing. Can't do electrical before framing — the Director enforces order. Same process, different materials = different house.

Section 4

Core Pattern & UML

GoF Definition: "Separate the construction of a complex object from its representation so that the same construction process can create different representations."

The key insight: instead of a telescoping constructorAnti-pattern where a class has multiple constructors with increasing parameter counts — new Order(a), new Order(a,b), new Order(a,b,c) — leading to unreadable and error-prone code. with 10+ parameters (where you forget which null means what), you build the object step by step through method chainingTechnique where methods return the current object (return this) so multiple calls can be written in a single expression: builder.A().B().C().. The Builder accumulates configuration, validates it, and then produces the finished ProductThe complex object being constructed. In Builder pattern, the Product is typically immutable once the Build() method creates it.. An optional DirectorAn object that orchestrates the building steps in a specific order. In modern .NET, this role is often played by a factory method or the client itself. can orchestrate a specific sequence of build steps for common configurations.

UML Class Diagram

Builder Pattern — UML Class Diagram Director + Construct(builder): void <<interface>> IBuilder + BuildPartA(): IBuilder + BuildPartB(): IBuilder + GetResult(): Product ConcreteBuilder - product: Product + BuildPartA(): IBuilder + BuildPartB(): IBuilder + GetResult(): Product Product + PartA: string + PartB: string Product --> ━━▶ uses ā•Œā•Œā–· implements ā•Œā•Œā–¶ creates

Participant Roles

ParticipantRoleResponsibility
BuilderInterfaceDeclares steps for constructing parts of the Product
ConcreteBuilderImplementationImplements the build steps, keeps the product under construction, provides Build()/GetResult()
DirectorOrchestratorCalls builder steps in a specific order. Optional in fluent builders — the client often plays this role
ProductResultThe complex object being constructed. Typically immutableAn object that cannot be modified after creation — all properties are set during construction and cannot change afterwards. once built
GoF vs Modern Builder

The GoF version has a formal Director that orchestrates build steps on a Builder interface. Modern .NET almost exclusively uses the fluent builder variant — no Director, no interface, just a concrete builder with method chaining and a Build() method. Both solve the same problem; the fluent version is more ergonomic for APIs.

Section 5

Code Implementations

ClassicBuilder.cs
// ── Product ─────────────────────────────────────────────
public sealed class Report
{
    public string Title { get; init; } = "";
    public string Header { get; init; } = "";
    public IReadOnlyList<string> Sections { get; init; } = [];
    public string Footer { get; init; } = "";

    public override string ToString()
        => $"{Title}\n{Header}\n{string.Join("\n", Sections)}\n{Footer}";
}

// ── Builder interface ───────────────────────────────────
public interface IReportBuilder
{
    IReportBuilder SetTitle(string title);
    IReportBuilder AddHeader(string header);
    IReportBuilder AddSection(string section);
    IReportBuilder AddFooter(string footer);
    Report Build();
    void Reset();
}

// ── ConcreteBuilder: Plain Text ─────────────────────────
public sealed class PlainTextReportBuilder : IReportBuilder
{
    private string _title = "";
    private string _header = "";
    private readonly List<string> _sections = new();
    private string _footer = "";

    public IReportBuilder SetTitle(string title)
    { _title = title; return this; }

    public IReportBuilder AddHeader(string header)
    { _header = $"=== {header} ==="; return this; }

    public IReportBuilder AddSection(string section)
    { _sections.Add($"  - {section}"); return this; }

    public IReportBuilder AddFooter(string footer)
    { _footer = $"--- {footer} ---"; return this; }

    public Report Build() => new()
    {
        Title = _title,
        Header = _header,
        Sections = new List<string>(_sections),
        Footer = _footer
    };

    public void Reset()
    {
        _title = ""; _header = "";
        _sections.Clear(); _footer = "";
    }
}

// ── ConcreteBuilder: HTML ───────────────────────────────
public sealed class HtmlReportBuilder : IReportBuilder
{
    private string _title = "";
    private string _header = "";
    private readonly List<string> _sections = new();
    private string _footer = "";

    public IReportBuilder SetTitle(string title)
    { _title = $"<h1>{title}</h1>"; return this; }

    public IReportBuilder AddHeader(string header)
    { _header = $"<header>{header}</header>"; return this; }

    public IReportBuilder AddSection(string section)
    { _sections.Add($"<section>{section}</section>"); return this; }

    public IReportBuilder AddFooter(string footer)
    { _footer = $"<footer>{footer}</footer>"; return this; }

    public Report Build() => new()
    {
        Title = _title,
        Header = _header,
        Sections = new List<string>(_sections),
        Footer = _footer
    };

    public void Reset()
    {
        _title = ""; _header = "";
        _sections.Clear(); _footer = "";
    }
}

// ── Director ────────────────────────────────────────────
public sealed class ReportDirector
{
    public Report BuildMonthlyReport(IReportBuilder builder)
    {
        builder.Reset(); // Always reset before building!
        return builder
            .SetTitle("Monthly Report")
            .AddHeader("Performance Summary")
            .AddSection("Revenue: $1.2M (+12%)")
            .AddSection("Users: 45,000 (+8%)")
            .AddSection("Uptime: 99.97%")
            .AddFooter("Generated automatically")
            .Build();
    }

    public Report BuildIncidentReport(IReportBuilder builder)
    {
        builder.Reset();
        return builder
            .SetTitle("Incident Report")
            .AddHeader("Post-Mortem")
            .AddSection("Timeline of events")
            .AddSection("Root cause analysis")
            .AddSection("Action items")
            .AddFooter("Confidential")
            .Build();
    }
}

// ── Usage ───────────────────────────────────────────────
var director = new ReportDirector();

// Same director, different builders → different representations
var textReport = director.BuildMonthlyReport(new PlainTextReportBuilder());
var htmlReport = director.BuildMonthlyReport(new HtmlReportBuilder());

Console.WriteLine(textReport);
Console.WriteLine(htmlReport);
FluentBuilder.cs
// ── Product: Immutable Email ────────────────────────────
public sealed record Email
{
    public required string From { get; init; }
    public required string To { get; init; }
    public string? Cc { get; init; }
    public string? Bcc { get; init; }
    public required string Subject { get; init; }
    public required string Body { get; init; }
    public bool IsHtml { get; init; }
    public IReadOnlyList<string> Attachments { get; init; } = [];
    public IReadOnlyDictionary<string, string> CustomHeaders { get; init; } = new Dictionary<string, string>();
    public Priority Priority { get; init; } = Priority.Normal;
}

public enum Priority { Low, Normal, High, Urgent }

// ── Fluent Builder ──────────────────────────────────────
public sealed class EmailBuilder
{
    private string? _from;
    private string? _to;
    private string? _cc;
    private string? _bcc;
    private string? _subject;
    private string? _body;
    private bool _isHtml;
    private readonly List<string> _attachments = [];
    private readonly Dictionary<string, string> _headers = new();
    private Priority _priority = Priority.Normal;

    // ── Required fields ─────────────────────────────────
    public EmailBuilder From(string from)
    { _from = from; return this; }

    public EmailBuilder To(string to)
    { _to = to; return this; }

    public EmailBuilder Subject(string subject)
    { _subject = subject; return this; }

    public EmailBuilder Body(string body, bool isHtml = false)
    { _body = body; _isHtml = isHtml; return this; }

    // ── Optional fields ─────────────────────────────────
    public EmailBuilder Cc(string cc)
    { _cc = cc; return this; }

    public EmailBuilder Bcc(string bcc)
    { _bcc = bcc; return this; }

    public EmailBuilder Attach(string filePath)
    { _attachments.Add(filePath); return this; }

    public EmailBuilder WithHeader(string key, string value)
    { _headers[key] = value; return this; }

    public EmailBuilder WithPriority(Priority priority)
    { _priority = priority; return this; }

    // ── Build with validation ───────────────────────────
    public Email Build()
    {
        // Fail fast on required fields
        // .NET 8+ helper — for .NET 6/7 use: if (string.IsNullOrWhiteSpace(...)) throw ...
        ArgumentException.ThrowIfNullOrWhiteSpace(_from, "From");
        ArgumentException.ThrowIfNullOrWhiteSpace(_to, "To");
        ArgumentException.ThrowIfNullOrWhiteSpace(_subject, "Subject");
        ArgumentException.ThrowIfNullOrWhiteSpace(_body, "Body");

        return new Email
        {
            From = _from!,
            To = _to!,
            Cc = _cc,
            Bcc = _bcc,
            Subject = _subject!,
            Body = _body!,
            IsHtml = _isHtml,
            Attachments = [.. _attachments],
            CustomHeaders = new ReadOnlyDictionary<string, string>(
                new Dictionary<string, string>(_headers)),
            Priority = _priority
        };
    }
}

// ── Usage ───────────────────────────────────────────────
var email = new EmailBuilder()
    .From("alice@example.com")
    .To("bob@example.com")
    .Subject("Sprint 23 Retro Notes")
    .Body("<h1>Retro Summary</h1><p>What went well...</p>", isHtml: true)
    .Cc("charlie@example.com")
    .WithPriority(Priority.High)
    .Attach("/reports/sprint23.pdf")
    .Build();

// Runtime validation: Build() catches missing required fields
// var bad = new EmailBuilder().Build(); // āœ— throws ArgumentException
// For compile-time enforcement, use the Step Builder (next tab)
StepBuilder.cs
// ── Step Builder: compile-time enforcement of required steps ─
// Each step returns a DIFFERENT interface, so you can't skip steps
// or call them out of order.

// Step interfaces — each returns the NEXT step
public interface IConnectionStep
{
    ITableStep ConnectTo(string connectionString);
}

public interface ITableStep
{
    IColumnsStep FromTable(string tableName);
}

public interface IColumnsStep
{
    IColumnsStep Column(string name);
    IBuildStep AllColumns();
    IBuildStep Done();  // transition after selecting specific columns
}

public interface IBuildStep
{
    IBuildStep Where(string condition);
    IBuildStep OrderBy(string column, bool desc = false);
    IBuildStep Limit(int count);
    (string Connection, string Sql) Build();
}

// ── Implementation: single class implements all step interfaces ─
public sealed class QueryBuilder :
    IConnectionStep, ITableStep, IColumnsStep, IBuildStep
{
    private string _connection = "";
    private string _table = "";
    private readonly List<string> _columns = [];
    private readonly List<string> _conditions = [];
    private string? _orderBy;
    private int? _limit;

    private QueryBuilder() { }

    // Entry point — returns the FIRST step only
    public static IConnectionStep Create() => new QueryBuilder();

    // ── Step 1: Connection ──────────────────────────────
    public ITableStep ConnectTo(string connectionString)
    { _connection = connectionString; return this; }

    // ── Step 2: Table ───────────────────────────────────
    public IColumnsStep FromTable(string tableName)
    { _table = tableName; return this; }

    // ── Step 3: Columns ─────────────────────────────────
    public IColumnsStep Column(string name)
    { _columns.Add(name); return this; }

    public IBuildStep AllColumns()
    { _columns.Clear(); _columns.Add("*"); return this; }

    public IBuildStep Done()  // finalize column selection
    { if (_columns.Count == 0) _columns.Add("*"); return this; }

    // ── Step 4: Optional refinements + Build ────────────
    public IBuildStep Where(string condition)
    { _conditions.Add(condition); return this; }

    public IBuildStep OrderBy(string column, bool desc = false)
    { _orderBy = desc ? $"{column} DESC" : column; return this; }

    public IBuildStep Limit(int count)
    { _limit = count; return this; }

    public (string Connection, string Sql) Build()
    {
        var cols = string.Join(", ", _columns);
        var sql = $"SELECT {cols} FROM {_table}";
        if (_conditions.Count > 0)
            sql += " WHERE " + string.Join(" AND ", _conditions);
        if (_orderBy is not null) sql += $" ORDER BY {_orderBy}";
        if (_limit.HasValue) sql += $" OFFSET 0 ROWS FETCH NEXT {_limit} ROWS ONLY";
        return (_connection, sql);
    }
}

// ── Usage ───────────────────────────────────────────────
var query = QueryBuilder.Create()            // IConnectionStep
    .ConnectTo("Server=localhost;...")        // ITableStep
    .FromTable("Users")                      // IColumnsStep
    .Column("Id").Column("Name").Column("Email")  // IColumnsStep
    .Done()                                  // IBuildStep
    .Where("IsActive = 1")                   // IBuildStep
    .OrderBy("Name")                         // IBuildStep
    .Limit(50)                               // IBuildStep
    .Build();                                // (Connection, Sql)

// Or select all columns:
var allQuery = QueryBuilder.Create()
    .ConnectTo("Server=localhost;...")
    .FromTable("Users")
    .AllColumns()                             // IBuildStep
    .Build();

// āœ— Can't skip steps — won't compile:
// QueryBuilder.Create().FromTable("Users")  // Error: IConnectionStep has no FromTable
// QueryBuilder.Create().ConnectTo("...").Build() // Error: ITableStep has no Build
Security Warning

This example concatenates raw strings into SQL for pattern clarity. In production, never interpolate user input into SQL. Use parameterized queries (see Q21: SQL injection prevention) or a library like Dapper / EF Core. The Step Builder pattern itself is great for enforcing build order — just pair it with parameterized values.

Section 6

JR vs SR Implementation

The Telescoping Constructor Trap

Junior sees a class with many optional fields and creates a cascade of constructor overloads. Adding a new field means touching every overload. Callers have no idea what each null or true means positionally — this is called the boolean trapAnti-pattern where a method takes boolean parameters whose meaning isn't obvious at the call site: new Order(..., true, false, true) — which bool means what?.

JuniorOrder.cs
// āœ— Junior: Telescoping constructor nightmare
public class Order
{
    public Order(string customerId, string productId)
        : this(customerId, productId, 1) { }

    public Order(string customerId, string productId, int quantity)
        : this(customerId, productId, quantity, null) { }

    public Order(string customerId, string productId, int quantity,
        string? couponCode)
        : this(customerId, productId, quantity, couponCode, false) { }

    public Order(string customerId, string productId, int quantity,
        string? couponCode, bool giftWrap)
        : this(customerId, productId, quantity, couponCode, giftWrap,
               null, null, Priority.Normal, false) { }

    // The "real" constructor — 9 parameters, good luck remembering
    public Order(string customerId, string productId, int quantity,
        string? couponCode, bool giftWrap, string? shippingAddress,
        string? giftMessage, Priority priority, bool expressShipping)
    {
        CustomerId = customerId;
        ProductId = productId;
        Quantity = quantity;
        CouponCode = couponCode;
        GiftWrap = giftWrap;
        ShippingAddress = shippingAddress;
        GiftMessage = giftMessage;
        Priority = priority;
        ExpressShipping = expressShipping;
    }

    // ... 9 properties
}

// āœ— Caller: which null is what? Which bool is which?
var order = new Order("C123", "P456", 2, null, true, null, null,
    Priority.Normal, false);
// Quick: what does the first 'null' mean? The second 'null'?
// What does 'true' mean? What about 'false'?
Problems

1. Adding a 10th parameter means adding yet another constructor overload — combinatorial explosionThe number of constructor overloads grows exponentially with optional parameters — 5 optional params = 32 possible overloads..
2. Positional nulls are error-prone — swap two parameters and the compiler won't catch it if they're the same type.
3. No validation until the object is used — invalid combinations aren't caught at construction time.
4. Object is mutable — anyone can change fields after construction.

How a Senior Thinks

"9 constructor parameters? That's a code smell. I'll use a Builder that names every field, validates before creating, and returns an immutable product. The Builder is the only way to create an Order — no backdoor constructors."

Order.cs
// āœ“ Senior: Immutable product — only the builder can create it
public sealed class Order
{
    public string CustomerId { get; }
    public string ProductId { get; }
    public int Quantity { get; }
    public string? CouponCode { get; }
    public bool GiftWrap { get; }
    public string? ShippingAddress { get; }
    public string? GiftMessage { get; }
    public Priority Priority { get; }
    public bool ExpressShipping { get; }

    // Internal constructor — only OrderBuilder can call this
    internal Order(string customerId, string productId, int quantity,
        string? couponCode, bool giftWrap, string? shippingAddress,
        string? giftMessage, Priority priority, bool expressShipping)
    {
        CustomerId = customerId;
        ProductId = productId;
        Quantity = quantity;
        CouponCode = couponCode;
        GiftWrap = giftWrap;
        ShippingAddress = shippingAddress;
        GiftMessage = giftMessage;
        Priority = priority;
        ExpressShipping = expressShipping;
    }
}
OrderBuilder.cs
// āœ“ Fluent builder with validation
public sealed class OrderBuilder
{
    private string? _customerId;
    private string? _productId;
    private int _quantity = 1;
    private string? _couponCode;
    private bool _giftWrap;
    private string? _shippingAddress;
    private string? _giftMessage;
    private Priority _priority = Priority.Normal;
    private bool _expressShipping;

    public OrderBuilder ForCustomer(string customerId)
    { _customerId = customerId; return this; }

    public OrderBuilder ForProduct(string productId)
    { _productId = productId; return this; }

    public OrderBuilder WithQuantity(int quantity)
    { _quantity = quantity; return this; }

    public OrderBuilder WithCoupon(string code)
    { _couponCode = code; return this; }

    public OrderBuilder AsGift(string? message = null)
    { _giftWrap = true; _giftMessage = message; return this; }

    public OrderBuilder ShipTo(string address)
    { _shippingAddress = address; return this; }

    public OrderBuilder WithPriority(Priority priority)
    { _priority = priority; return this; }

    public OrderBuilder Express()
    { _expressShipping = true; return this; }

    public Order Build()
    {
        // āœ“ Validation: catch invalid states before creating
        if (string.IsNullOrWhiteSpace(_customerId))
            throw new InvalidOperationException("Customer ID is required");
        if (string.IsNullOrWhiteSpace(_productId))
            throw new InvalidOperationException("Product ID is required");
        if (_quantity < 1)
            throw new InvalidOperationException("Quantity must be ≄ 1");
        if (_giftWrap && string.IsNullOrWhiteSpace(_shippingAddress))
            throw new InvalidOperationException("Gift orders require a shipping address");
        if (_expressShipping && string.IsNullOrWhiteSpace(_shippingAddress))
            throw new InvalidOperationException("Express shipping requires an address");

        return new Order(_customerId, _productId, _quantity,
            _couponCode, _giftWrap, _shippingAddress,
            _giftMessage, _priority, _expressShipping);
    }
}
Program.cs
// āœ“ Every field is named — crystal clear intent
var order = new OrderBuilder()
    .ForCustomer("C123")
    .ForProduct("P456")
    .WithQuantity(2)
    .AsGift("Happy Birthday!")
    .ShipTo("123 Main St, Springfield")
    .WithPriority(Priority.High)
    .Express()
    .Build();

// āœ“ Simple order — skip optional fields, no nulls needed
var simpleOrder = new OrderBuilder()
    .ForCustomer("C789")
    .ForProduct("P012")
    .Build();

// āœ“ Invalid state caught at Build() time:
// new OrderBuilder().ForCustomer("C1").Build();
// → "Product ID is required"

// āœ“ Cross-field validation:
// new OrderBuilder().ForCustomer("C1").ForProduct("P2")
//     .Express().Build();
// → "Express shipping requires an address"

Design Decisions

Why This is Better

AspectJunior (Telescoping)Senior (Builder)
ReadabilityPositional nulls/bools — unreadableNamed methods — self-documenting
Adding fieldsNew overload for every combinationAdd one method — zero breaking changes
ValidationNone at construction timeCross-field validation in Build()
ImmutabilityMutable — anyone can change fieldsImmutable Product, internal constructor
IDE supportNo guidance on optional paramsIntelliSense shows available methods
Section 7

Evolution Timeline

1994 — GoF: Formal Director + Builder

The original Gang of FourErich Gamma, Richard Helm, Ralph Johnson, and John Vlissides — authors of "Design Patterns: Elements of Reusable Object-Oriented Software" (1994). design featured a strict separation: the Director orchestrates the build sequence, the Builder interface declares steps, and ConcreteBuilder assembles the Product. The Director held the construction algorithm, ensuring different builders could produce different representations from the same process. Common in C++ and early Java — rarely seen in this pure form today.

Josh Bloch's Effective JavaInfluential book by Joshua Bloch — the Builder pattern was Item 2 in the 2nd edition (2008) and 3rd edition (2018): "Consider a builder when faced with many constructor parameters." 2nd edition (2008, Item 2: "Consider a builder when faced with many constructor parameters") popularized the fluent builder variant. Bloch's version dropped the Director, made the builder a static nested class, and returned this for method chaining. Java 5 (2004) added StringBuilder — the most widely used builder in the Java ecosystem. Together, these heavily influenced .NET.

C# 3.0 introduced object initializersSyntax that lets you set properties at construction: new Foo { Name = "x", Age = 25 }. Convenient but no validation and objects remain mutable.: new Order { Name = "x", Qty = 5 }. Many devs thought this replaced Builder, but initializers offer no validation, no enforcement of required fields, and the object is mutable. Builder remained essential for complex, validated, immutable objects. LINQLanguage Integrated Query — C# 3.0 feature that uses fluent method chaining (Where, Select, OrderBy) for data queries. Fluent but not a Builder — it transforms data rather than constructing objects. also arrived in C# 3.0, popularizing fluent method chains.

.NET Core 1.0 introduced WebHostBuilder and ConfigurationBuilder — making Builder the official pattern for app startup. IHostBuilder followed in .NET Core 2.1 (2018) for non-web scenarios. WebHostBuilder configured KestrelThe cross-platform HTTP server built into ASP.NET Core. It's the default web server that handles HTTP requests before middleware processes them., DIDependency Injection — a technique where an object receives its dependencies from an external source rather than creating them itself. Built into .NET Core., logging, and middlewareComponents in the ASP.NET Core request pipeline that process HTTP requests and responses — authentication, routing, CORS, compression, etc. in a fluent chain. The pattern was no longer just for domain objects — it became the backbone of application bootstrappingThe process of initializing and configuring an application before it starts handling requests — setting up DI, middleware, logging, etc..

Minimal hosting.NET 6+ feature that simplifies app startup — no Startup.cs, no Configure/ConfigureServices, just a single Program.cs with WebApplication.CreateBuilder(). replaced the Startup.cs ceremony with WebApplicationBuilder — a simplified, unified builder that exposes Services, Configuration, Logging, and Environment as properties. One builder object configures everything. This is .NET's most visible Builder pattern usage.

Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddDbContext<AppDbContext>();
builder.Logging.AddConsole();
var app = builder.Build(); // ← Builder.Build() in action!

C# 11's required keywordC# 11 feature that forces callers to set a property during object initialization — compile-time enforcement without a Builder. Works with init-only properties. and recordsC# 9+ feature: immutable reference types with value equality, built-in ToString, and with-expression support. Ideal as Builder products. further blur the lines. required enforces property assignment at compile time. Source generatorsRoslyn compiler feature that generates C# code at compile time. Can auto-create Builder classes from a Product's property definitions. like Lombok-style libraries can auto-generate builders from record definitions. The pattern isn't going away — it's being automated.

[SetsRequiredMembers] — The Bridge Between Builder and required

If your Product uses required properties, the compiler will complain when your Builder's Build() calls the constructor — because required demands the caller sets those properties. The fix: mark the Product's internal constructor with [SetsRequiredMembers]System.Diagnostics.CodeAnalysis attribute that tells the compiler "this constructor sets all required members — don't enforce them on callers." Essential for Builders creating products with required properties. to tell the compiler "this constructor handles all required fields."

public sealed class Order
{
    public required string CustomerId { get; init; }
    public required string Product { get; init; }

    [SetsRequiredMembers] // ← Builder can call this without compiler errors
    internal Order(string customerId, string product)
    { CustomerId = customerId; Product = product; }
}
Section 8

Pattern in .NET Core

The Builder pattern shows up throughout .NET — from bootstrapping web applications to constructing connection strings. The diagram below shows the most common builders by category:

Builder Pattern in the .NET Ecosystem APP BOOTSTRAP — configure entire application startup WebApplicationBuilder IHostBuilder ConfigurationBuilder DATA & STRING — construct complex values step-by-step StringBuilder DbConnectionStringBuilder UriBuilder ORM / FRAMEWORK — fluent configuration APIs EntityTypeBuilder IEndpointConventionBuilder ⚔ Build() CATEGORY App setup Value construction Model configuration

WebApplicationBuilder (.NET 6+)

The most prominent Builder in all of .NET. WebApplication.CreateBuilder(args) returns a builder that lets you configure services, middleware, logging, and configuration before calling Build() to produce the immutable WebApplication.

Program.cs
var builder = WebApplication.CreateBuilder(args);

// Configure services (DI container)
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// Configure logging
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.AddDebug();

// Configure app settings
builder.Configuration.AddJsonFile("appsettings.json");
builder.Configuration.AddEnvironmentVariables();

// Build() → immutable WebApplication
var app = builder.Build();

// Now configure the HTTP pipeline
app.UseSwagger();
app.MapControllers();
app.Run();

The classic .NET builder — avoids string allocationIn .NET, strings are immutable. Each concatenation creates a new string object on the heap, causing O(n²) allocations for n concatenations. overhead by mutating an internal char bufferStringBuilder uses a linked list of char[] arrays internally. It grows by allocating new chunks rather than copying the entire string each time., then producing the final string via ToString().

StringBuilderExample.cs
var sb = new StringBuilder(256); // Pre-allocate for performance
sb.Append("SELECT ");
sb.AppendJoin(", ", columns);
sb.Append(" FROM ").Append(tableName);
if (hasWhere)
    sb.Append(" WHERE ").Append(condition);
sb.Append(';');

string sql = sb.ToString(); // Build() equivalent

Builds validated connection stringsA semicolon-delimited string of key-value pairs that specifies how to connect to a database: "Server=localhost;Database=MyDb;Trusted_Connection=true". without manual string formatting. Each provider has its own builder — SqlConnectionStringBuilder, NpgsqlConnectionStringBuilder — ensuring only valid keys are accepted.

ConnectionString.cs
var csb = new SqlConnectionStringBuilder
{
    DataSource = "localhost",
    InitialCatalog = "MyDb",
    IntegratedSecurity = true,
    MultipleActiveResultSets = true,
    ConnectTimeout = 30
};

string connStr = csb.ConnectionString;
// "Data Source=localhost;Initial Catalog=MyDb;..."

For non-web apps (background servicesLong-running services that implement IHostedService or BackgroundService — processing queues, running scheduled tasks, watching file systems., console apps), Host.CreateDefaultBuilder() returns an IHostBuilder. It's the same Builder pattern — configure services, logging, and configuration, then Build() and Run().

WorkerService.cs
var host = Host.CreateDefaultBuilder(args)
    .ConfigureServices(services =>
    {
        services.AddHostedService<MyWorker>();
        services.AddSingleton<IMessageBroker, RabbitMqBroker>();
    })
    .ConfigureLogging(logging =>
    {
        logging.AddConsole();
        logging.SetMinimumLevel(LogLevel.Information);
    })
    .Build();

await host.RunAsync();
BuilderProductUsage
ConfigurationBuilderIConfigurationCompose config from JSON, env varsEnvironment variables — OS-level key-value pairs used for configuration that shouldn't be in source code (connection strings, API keys)., user secretsDevelopment-only secret storage in .NET that keeps sensitive config values out of source code — stored in a local JSON file outside the project., Azure Key Vault
LoggingBuilderLogging pipelineConfigure providers (Console, Seq, Application Insights)
UriBuilderUriBuild URIs without manual string concatenation
ImmutableArray.CreateBuilder<T>ImmutableArray<T>Mutable builder for immutable array — MoveToImmutable() (zero-copy when Count == Capacity; otherwise copies)
IEndpointConventionBuilderEndpointConfigure minimal API endpoints: app.MapGet(...).RequireAuthorization()
EntityTypeBuilderEF Core's builder for configuring entity-to-table mappings — relationships, indexes, constraints, column types, all via fluent method chains.EF CoreEntity Framework Core — .NET's primary ORM (Object-Relational Mapper) for database access with LINQ queries and change tracking. modelFluent API for entity configuration: .HasKey(), .Property()
Section 9

When to Use / When Not To

Object has 4+ optional parameters
When a constructor would need many optional arguments, a Builder gives each one a clear, named method.
Construction requires validation across fields
Builder can check that related fields are consistent before creating the product.
Product must be immutableOnce created, the object's state cannot change. Immutability prevents bugs from shared mutable state and makes objects thread-safe.
Builder accumulates state mutably, then produces an immutable product at the end.
Same construction process, different representations
One Director, multiple builders — same steps produce HTML, PDF, or Markdown output.
Construction has an order or multi-step process
Steps must happen in sequence, and Builder enforces that order naturally.
You want a fluent API for developer experience
Method chaining makes configuration readable and discoverable via IntelliSense.
Object creation is complex enough to be error-prone
Builder guides the developer through the process, reducing the chance of mistakes.
Object has 1-3 simple constructor params
A plain constructor with well-named parameters is simpler and sufficient.
No cross-field validation needed
If fields are independent, there's nothing for Builder to validate.
Object is inherently mutable (DTOs, ViewModels)
If the object will be mutated after creation, immutability guarantees don't apply.
Only one representation exists
No need for the abstraction if there's only one way to build the product.
All properties are independent
No ordering or cross-field logic means a constructor or initializer works fine.
Object initializersC# syntax: new Foo { Prop1 = x, Prop2 = y }. Simple and readable for mutable DTOs but offers no validation or required-field enforcement. suffice (simple DTOs)
For straightforward data objects, C# initializer syntax is simpler and has zero overhead.
Simple new with named params works fine
Named arguments already provide clarity without the extra Builder class.
Object has many optional params? NO Constructor with defaults is fine YES Multi-step construction needed? NO record with 'with' expression YES Same steps, different outputs? YES Builder + Director NO Fluent API / method chaining ✓
Section 10

Comparisons

Builder vs Abstract Factory vs Factory Method

Builder
  • Constructs one complex object step by step
  • Validation and optional immutability
  • .NET example: WebApplicationBuilder
  • Use when: many optional params, multi-step construction, or cross-field validation
vs
Abstract Factory
  • Creates families of related products that must be used together
  • One factory per product family
  • .NET example: DbProviderFactory
  • Use when: product families must be consistent (e.g., SQL Server connection + command + adapter)
Builder
  • Multi-step construction with intermediate state
  • Full cross-field validation in Build()
  • Adding params = adding a method (no breaking changes)
vs
Factory Method
  • Creates a single product via subclass override
  • One virtual method, one product — single step
  • .NET example: ILoggerFactory.CreateLogger()

Builder vs Object Initializer vs Telescoping Constructor

Builder
  • Excellent readability (named fluent methods)
  • Full cross-field validation in Build()
  • Product is immutable
  • Adding params = adding a method (no breaking changes)
  • Overhead: extra class + allocation
vs
Object Initializer
  • Good readability (named properties)
  • No validation, object is mutable
  • Required fields not enforced (C# 11 required helps)
  • Zero overhead
Builder
  • Excellent readability, immutable products
  • Full cross-field validation
  • Adding params = no breaking changes
vs
Telescoping Constructor
  • Poor readability (positional params)
  • Limited validation, can be immutable
  • Adding params = new overloads affecting all callers
  • Zero overhead

Builder vs with-Expression

Builder
  • Creates new instances from scratch via Build()
  • Full cross-field validation and multi-step intermediate state
  • IntelliSense shows all builder methods
  • Use when: constructing from scratch with rules, defaults, and multi-step logic
vs
with-Expression
  • Creates a modified copy of an existing record via with { }
  • No validation — any property can be overridden
  • Single-shot property overrides only
  • Use when: modifying an existing record instance (e.g., changing one field on a copy)
When to Choose Which

Builder + Abstract Factory: They often work together. Abstract Factory selects which family (SQL Server vs PostgreSQL), then Builder constructs how (connection string, command, etc.). Example: DbProviderFactory creates a DbConnectionStringBuilder — factory picks the provider, builder configures the connection.

Builder vs Factory Method: Factory Method creates in one shot — you get the product immediately. Builder creates in multiple steps with intermediate configuration. If your object needs zero or minimal configuration, use Factory Method. If it needs 5+ configurable options, reach for Builder.

Practical Guideline

Use with-expressions when you have an existing object and want a slightly modified copy (like updating one field). Use Builder when constructing from scratch with validation, defaults, and multi-step logic. They complement, not compete — a Builder can produce a record that later uses with for modifications.

Section 11

SOLID Mapping

PrincipleStrengthHow Builder Supports It
SRPSingle Responsibility Principle — a class should have only one reason to change. Builder separates construction logic from the product's domain logic. Strong Construction logic lives in the Builder, not in the Product. The Product only holds data; the Builder only knows how to assemble it. Each has exactly one responsibility.
OCPOpen/Closed Principle — software entities should be open for extension but closed for modification. Strong Add a new ConcreteBuilder for a new representation without modifying the Director or existing builders. Need a Markdown report? Add MarkdownReportBuilder — zero changes to existing code.
LSPLiskov Substitution Principle — subtypes must be substitutable for their base types without altering program correctness. Moderate Any IBuilder implementation can be passed to the Director. Works well when all builders produce same-type products. Breaks down if builders have different step requirements.
ISPInterface Segregation Principle — clients should not be forced to depend on methods they don't use. Strong The Step Builder variant is a textbook ISP example — each step returns a different interface, so each phase only sees methods relevant to that step. No "fat interface" problem.
DIPDependency Inversion Principle — depend on abstractions, not concretions. High-level modules shouldn't depend on low-level modules. Moderate The Director depends on the IBuilder abstraction, not on concrete builders. However, modern fluent builders often skip the interface entirely — clients depend directly on the concrete builder class.
Key Insight

Builder's strongest SOLID contribution is SRP — it extracts construction complexity from the Product class. In classic GoF (with Director + interface), it also scores high on OCP, ISP, and DIP. In modern fluent builders (no interface, no Director), SRP is the primary benefit while OCP/DIP are weaker since there's no abstraction layer.

Section 12

Bug Case Studies

Bug 1: Forgot to Call Build()

The Incident

2023, .NET 7 e-commerce app. It started with a simple support ticket: "My order shows no product." The team thought it was a frontend display bug. But when they checked the database, the product ID column was literally NULL for dozens of recent orders.

Here is what happened step by step. The checkout service used a builder to create order objects. But instead of storing the finished order, the developer stored the builder itself in a variable typed as object. This was part of a serialization DTO that passed data between services.

Later, a different method received that object and cast it to dynamic to read properties. Because dynamic skips all compile-time checks, the code ran without errors. It happily read .ProductId from the builder object, which returned null because the builder stores configuration internally, not in product-shaped properties.

The sneaky part? The builder's ToString() output in logs looked almost identical to a real order. The team spent two hours staring at log entries that seemed fine before someone noticed the type was OrderBuilder, not Order.

OrderBuilder .ForCustomer().ForProduct() No .Build() called! stored as object orderData Actually: OrderBuilder! Not an Order cast to dynamic order .ProductId = null No compile-time check Database saves NULL product IDs for all orders

Time to Diagnose

2 hours — the dynamic late binding hid the type mismatch, and the builder's ToString() output in logs looked like a normal order object.

CheckoutService.cs
// āœ— Bug: storing builder in object, then using dynamic
public void CreateOrder(Cart cart)
{
    object orderData = new OrderBuilder()
        .ForCustomer(cart.CustomerId)
        .ForProduct(cart.ProductId)
        .WithQuantity(cart.Quantity);
    // āœ— Forgot .Build() — orderData is an OrderBuilder, not Order!

    // Later, in a different method...
    dynamic order = orderData; // No compile-time check!
    SaveToDb(order.ProductId); // āœ— Returns null — builder field, not product
}

Walking through the buggy code: Look at the first line inside the method. The developer chains .ForCustomer(), .ForProduct(), and .WithQuantity() but never calls .Build() at the end. Each of those fluent methods returns the builder itself (that is how method chaining works), so orderData is storing an OrderBuilder object, not a finished Order. When the code later does order.ProductId, it is reading a property that exists on the builder but was never populated the way the product would be. The result: null gets saved to the database.

CheckoutService.cs
// āœ“ Fix 1: Always call .Build()
public Order CreateOrder(Cart cart)
{
    return new OrderBuilder()
        .ForCustomer(cart.CustomerId)
        .ForProduct(cart.ProductId)
        .WithQuantity(cart.Quantity)
        .Build(); // ← Never forget this!
}

// āœ“ Fix 2: Return type enforcement — Builder and Product
//   are different types. Use strong typing everywhere.
//   The compiler catches the mistake if OrderBuilder ≠ Order.

Why the fix works: Two things changed. First, .Build() is now at the end of the chain, which transforms the builder's accumulated state into a real Order object. Second, the return type is Order instead of object. If someone forgets .Build(), the compiler will complain because an OrderBuilder cannot be returned where an Order is expected. Strong typing catches the bug before the code even runs.

Lesson Learned

Never let a builder implement or inherit from the product type. Keep them as distinct types so the compiler catches the "forgot Build()" mistake. Consider making Build() the only way to get a Product — no public constructor on the Product itself.

How to Spot This in Your Code

Search for any place where a builder's return value is assigned to object, dynamic, or var without .Build() at the end. If a variable holds a builder but is used as if it were the product, you have this bug. A quick regex search for new \w+Builder\( that does NOT end with .Build() on the same statement can flag suspects.

The Incident

2022, .NET 6 report service. The finance team noticed something strange in their April report: it contained all of March's data plus April's data. Revenue figures were doubled, user counts were inflated, and charts looked absurd.

The root cause was deceptively simple. The ReportBuilder was registered as "Scoped" in the dependency injection container. In .NET, "Scoped" means one instance per HTTP request. So when a single API call generated reports for multiple months (a common bulk-export feature), the same builder was used for March, then April, then May.

Here is the problem: the builder kept a List<Section> internally. Each call to AddSection() pushed new items onto that list. But nobody cleared the list between reports. So March's report had 3 sections. April's report had 6 sections (March's 3 + April's 3). May had 9. Each successive report inherited all the data from every previous one.

The bug was invisible in testing because tests only ever generated one report per request. It took 4 hours to diagnose because the builder's internal state was not logged, and the report titles were correct (only the sections leaked).

Same builder instance reused without reset Build #1: March Sections: [Mar-Rev, Mar-Users] Result: 2 sections (correct) Build #2: April Sections: [Mar + Apr data] Result: 4 sections (WRONG!) Build #3: May Sections: [Mar + Apr + May] Result: 6 sections (WRONG!) Internal List<Section> keeps growing across calls Each Build() inherits ALL previous data because nobody clears the builder

Time to Diagnose

4 hours — the bug only appeared when generating multiple reports in the same request scope (the builder was registered as Scoped in DI).

ReportService.cs
// āœ— Builder registered as Scoped — reused across calls
public class ReportService(IReportBuilder builder)
{
    public Report GenerateMonthly(MonthData data)
    {
        // āœ— Previous call's sections are still in the builder!
        builder.SetTitle($"Report: {data.Month}");
        builder.AddSection(data.Revenue);
        builder.AddSection(data.Users);
        return builder.Build(); // Contains OLD + NEW sections
    }
}

Walking through the buggy code: The builder parameter comes from dependency injection. Because it is registered as Scoped, the same instance is shared for the entire HTTP request. The first time GenerateMonthly runs, the builder has an empty section list, so the report is correct. But the second time, those March sections are still sitting in the list. AddSection just keeps appending, and Build() dutifully packages everything it finds into the report.

ReportService.cs
// āœ“ Fix 1: Create a new builder each time (preferred)
public class ReportService
{
    public Report GenerateMonthly(MonthData data)
    {
        var builder = new ReportBuilder(); // Fresh builder
        builder.SetTitle($"Report: {data.Month}");
        builder.AddSection(data.Revenue);
        return builder.Build();
    }
}

// āœ“ Fix 2: If injecting, register as Transient
services.AddTransient<IReportBuilder, ReportBuilder>();

// āœ“ Fix 3: If you must reuse, call Reset() before each Build
builder.Reset(); // Clear state from previous usage

Why the fix works: Fix 1 is the simplest and safest. By creating a brand new builder with new ReportBuilder() inside the method, you guarantee a completely clean slate every time. There is zero chance of leftover state because the builder did not exist before that line. Fix 2 achieves the same thing through the DI container: Transient means a fresh instance every time it is injected. Fix 3 is the weakest option because it relies on the developer remembering to call Reset().

Lesson Learned

Builders should be Transient — new instance per use. If a builder must be reused (rare), it needs a Reset() method and the Director should call it before every new build. Better yet, make builders disposable or create a factory that produces fresh builders.

How to Spot This in Your Code

Check your DI registrations. Search for AddScoped<.*Builder> or AddSingleton<.*Builder>. Any builder registered with a lifetime longer than Transient is a potential ticking time bomb. Also look for builder fields on long-lived service classes — if a builder outlives a single method call, its state might leak.

The Incident

2023, .NET 8 API. The security team got an alert: an unknown origin was appearing in the CORS whitelist. Nobody had deployed a config change. The rogue origin, http://evil.com, was being added at runtime by a downstream service that had a reference to the cached configuration object.

The AppConfig object was built once at startup by a ConfigBuilder and stored in a memory cache. The builder did its job perfectly. But the product it created exposed a plain List<string> for allowed origins. Any code that retrieved the config from the cache could call .Add() on that list, and the change would instantly affect every other consumer sharing the same cached reference.

Think of it like building a house (the builder) but leaving the front door unlocked (mutable collections). The builder did a great job constructing the house, but anyone can walk in and rearrange the furniture afterwards.

The team spent 3 hours tracking this down because the mutation happened in a middleware component that only ran for certain request paths. The config looked correct at startup and in health checks, but was silently corrupted during normal traffic.

ConfigBuilder .Build() creates config Cached AppConfig AllowedOrigins: List<string> ["app.com", "admin.com"] Service A: reads list Sees correct origins Service B: .Add("evil.com") Mutates the shared list! Now ALL services see: ["app.com", "admin.com", "evil.com"] Shared cache is permanently corrupted
Config.cs
// āœ— Product exposes mutable collections
public class AppConfig
{
    public List<string> AllowedOrigins { get; set; } = new();
    public Dictionary<string, string> FeatureFlags { get; set; } = new();
}

// Caller modifies the "immutable" cached config:
var config = cache.Get<AppConfig>("config");
config.AllowedOrigins.Add("http://evil.com"); // āœ— Mutates shared cache!

Walking through the buggy code: The AppConfig class uses plain List<string> and Dictionary with public setters. These collections are the same objects in memory that the builder used during construction. When any caller retrieves this config from cache, they get a direct reference to those mutable collections. Calling .Add() modifies the same list that every other consumer is reading. There is no copy, no protection, no barrier.

Config.cs
// āœ“ Product uses immutable collections
public sealed class AppConfig
{
    public IReadOnlyList<string> AllowedOrigins { get; }
    public IReadOnlyDictionary<string, string> FeatureFlags { get; }

    internal AppConfig(List<string> origins,
        Dictionary<string, string> flags)
    {
        // Defensive copy — builder's list can't mutate the product
        AllowedOrigins = origins.AsReadOnly();
        FeatureFlags = new ReadOnlyDictionary<string, string>(
            new Dictionary<string, string>(flags));
    }
}

// āœ“ Now callers can't mutate:
// config.AllowedOrigins.Add(...) // Compile error!
// ((List<string>)config.AllowedOrigins).Add(...) // Runtime error!

Why the fix works: Two layers of protection. First, the properties are typed as IReadOnlyList and IReadOnlyDictionary, so the compiler will not let anyone call .Add(). Second, the constructor creates defensive copies using .AsReadOnly() and new Dictionary<>(flags). Even if someone holds a reference to the original builder's list, changes to that list will not affect the product's copy. The constructor is internal, so only the builder (in the same assembly) can create instances.

Lesson Learned

Always expose IReadOnlyList<T> / IReadOnlyDictionary<K,V> on Builder products. Make defensive copies in the constructor so the builder's internal collections can't be used to mutate the product after construction.

How to Spot This in Your Code

Look for any builder product that has public List<T>, public Dictionary<K,V>, or public string[] properties with setters. If the product is ever cached or shared between components, those mutable collections are a security and correctness risk. Replace them with IReadOnlyList<T> and make defensive copies in the constructor.

The Incident

2024, .NET 8 microservice. The notification team got a panicked message from ops: thousands of emails were stuck in the outbound queue, and the SMTP server was rejecting them with "missing recipient" errors. The email service had been happily building and queuing messages for hours before anyone noticed.

The EmailBuilder had no validation whatsoever. Its Build() method took whatever fields were set (or not set) and created an Email object. Missing subject? Fine. No recipient? No problem. The builder produced the object, the queuing service accepted it, and the problem only surfaced hours later when the SMTP server actually tried to send.

This is the "delayed explosion" problem. Without validation in Build(), invalid objects travel through your entire system before something finally rejects them. By that point, the stack trace points to the SMTP library, not to the code that forgot to set the recipient. The real bug could be three services away.

The team had to write a SQL query to identify all the broken emails, figure out which ones were recoverable, and manually re-queue them with correct data. A five-minute validation check in Build() would have prevented six hours of cleanup.

Without Build() validation, invalid objects travel the whole pipeline Build() No validation! Queue Service Accepts anything Retry Worker Processes blindly SMTP Server REJECTS "Missing recipient address" Hours later! Wrong stack trace! Build() + validation Checks all fields Fails IMMEDIATELY with clear message "To address is required; Subject is required"
EmailBuilder.cs
// āœ— Build() creates product without any validation
public Email Build()
{
    return new Email(_from, _to, _subject, _body); // All could be null!
}

Walking through the buggy code: This Build() method is just one line: create a new Email and pass in whatever the builder has. If the caller never called .To(), then _to is null. The method does not care. It constructs the email with a null recipient and hands it off to the queue. The bomb is now traveling through your pipeline, and it will explode at the worst possible time.

EmailBuilder.cs
// āœ“ Build() validates ALL required fields + cross-field rules
public Email Build()
{
    var errors = new List<string>();

    if (string.IsNullOrWhiteSpace(_from))
        errors.Add("From address is required");
    if (string.IsNullOrWhiteSpace(_to))
        errors.Add("To address is required");
    if (string.IsNullOrWhiteSpace(_subject))
        errors.Add("Subject is required");
    if (_isHtml && string.IsNullOrWhiteSpace(_body))
        errors.Add("HTML emails require a body");

    if (errors.Count > 0)
        throw new InvalidOperationException(
            $"Cannot build Email: {string.Join("; ", errors)}");

    return new Email(_from!, _to!, _subject!, _body!);
}

Why the fix works: Now Build() checks every required field before creating the email. Notice two important details. First, it collects all errors into a list instead of throwing on the first one. This way the caller can fix everything at once instead of playing whack-a-mole. Second, there is a cross-field rule: HTML emails require a body. This kind of "if A then B is required" logic is impossible with simple constructors but natural in a builder's Build() method.

Lesson Learned

Build() is your last line of defense. Validate required fields, cross-field constraints, and business rules. Collect all errors and throw them together — don't make the caller fix one error at a time. Fail fastDesign principle: detect and report errors as early as possible, before they propagate through the system and become harder to diagnose. with a clear message.

How to Spot This in Your Code

Open any builder's Build() method. If it goes straight to return new Product(...) without any if checks above it, that is a red flag. Every builder that creates domain objects (not simple DTOs) should validate required fields and cross-field rules in Build(). If there is no validation, you are trusting every caller to always configure the builder correctly, and that trust will eventually be broken.

The Incident — Data Leak Under Load

2023, .NET 7 API. Production. 2 AM. Support tickets start flooding in: "I can see someone else's orders." This is one of the scariest bugs you can have: a data leak between users.

The root cause was a QueryBuilder registered as Singleton in the DI container. Singleton means one single instance shared by every request, every thread, every user. The builder stores a _customerId field internally when you call .Where("CustomerId", "C-123"). With a singleton, two requests hitting the server at the same time are writing to the exact same field on the exact same object.

Here is the timeline of the race condition: Thread A sets CustomerId = "C-123". A fraction of a millisecond later, Thread B sets CustomerId = "C-456" on the same builder. Thread A then calls Build(), which reads the customer ID. But it now reads "C-456" because Thread B overwrote the value. User A receives User B's order data.

This bug passed all unit tests because unit tests run single-threaded. It only manifested under concurrent production load when two requests overlapped by milliseconds. The team needed to reproduce the timing with a load test before they could confirm the diagnosis.

Race Condition: Two threads, one builder Singleton QueryBuilder _customerId = ??? Thread A (User C-123) .Where("CustomerId", "C-123") Thread B (User C-456) .Where("CustomerId", "C-456") Thread B overwrites Thread A's value! Thread A calls Build() Gets C-456's data! DATA LEAK Thread B calls Build() Gets C-456's data (correct by luck)
Program.cs
// āœ— Singleton builder — shared mutable state across ALL requests
builder.Services.AddSingleton<QueryBuilder>();
// Thread A: builder.Where("CustomerId", "C-123")
// Thread B: builder.Where("CustomerId", "C-456")  ← overwrites A!
// Thread A: builder.Build()  → returns C-456's data to C-123's user

Walking through the buggy code: The single line AddSingleton<QueryBuilder>() is the entire problem. It tells .NET's DI container: "Create one QueryBuilder and share it with everyone." Since every HTTP request runs on its own thread, two users making requests simultaneously both call .Where() on the same object. There are no locks, no thread safety. The last writer wins, and the other user gets wrong data.

Program.cs
// āœ“ Fix: Transient — new builder per injection
builder.Services.AddTransient<QueryBuilder>();

// āœ“ Better: Don't inject builders at all — just new them up
public string BuildQuery(QueryParams p)
{
    return new QueryBuilder()     // Fresh per call
        .FromTable(p.Table)
        .Where(p.Condition)
        .Build();
}

Why the fix works: Both fixes ensure each thread gets its own private builder instance. With AddTransient, the DI container creates a new builder every time one is requested. With new QueryBuilder(), you bypass DI entirely and create a fresh instance right where you need it. Since no two threads share the same builder, there is no race condition. The "better" approach is often just using new directly, because builders are cheap objects with no dependencies worth injecting.

Lesson Learned

Builders are inherently stateful and mutable — never register them as Singleton or Scoped in DI. Either register as Transient or (better) just new them directly. Builders are cheap to create; shared mutable state is expensive to debug.

How to Spot This in Your Code

Search your Program.cs or Startup.cs for AddSingleton and AddScoped registrations. Any class with "Builder" in the name registered as Singleton is almost certainly a bug waiting to happen. A quick code review rule: if a class has mutable fields that change per use (like a builder does), it should never be Singleton.

The Incident

2024, .NET 8 library. A team was building a shared configuration library used across multiple microservices. They made a subtle but devastating mistake: the Build() method returned this instead of creating a new product object.

For weeks, everything seemed fine. But then a developer on another team did something perfectly reasonable: they called Build(), stored the result as their "config," and later called SetPort(80) on the same variable to test a different scenario. They thought they were modifying a separate test config. In reality, they were mutating the "built" production config because the builder and the product were the same object.

Think of it this way. Imagine you order a custom cake from a bakery (the builder). The baker takes your order, decorates the cake, and says "here is your cake." But instead of handing you a finished cake, they hand you the entire mixing bowl with all the ingredients still in it. If you stir the bowl, you are changing the recipe for every future cake too.

The bug was especially hard to track because Build() looked correct at the call site. The code read var config = builder.Build(); which looks like it produces a separate object. You would have to read the Build() implementation to realize it just returns this.

Bad: Build() returns this builder Host, Port, SetHost()... Build() config Same object! Still mutable! config.SetPort(80) mutates "built" object Good: Build() returns new object builder Mutable (by design) Build() config (record) Immutable! New object! Separate types = compiler catches mistakes
BadBuilder.cs
// āœ— Build() returns the builder itself (anti-pattern)
public class ConfigBuilder
{
    public string Host { get; set; } = "";
    public int Port { get; set; }

    public ConfigBuilder SetHost(string h) { Host = h; return this; }
    public ConfigBuilder SetPort(int p) { Port = p; return this; }

    // āœ— Returns "this" — it's NOT a separate product!
    public ConfigBuilder Build() => this;
}

var config = new ConfigBuilder().SetHost("prod").SetPort(443).Build();
config.SetPort(80); // āœ— Mutates the "built" object!

Walking through the buggy code: Look at the Build() method: it says return this. That means calling Build() does absolutely nothing except return the same builder you already had. The variable config is not a finished product; it is the builder pretending to be one. Since the builder has public setters like SetPort(), anyone can keep changing "config" after it was supposedly finalized. The builder and the product are the same mutable object.

FixedBuilder.cs
// āœ“ Builder and Product are SEPARATE types
public sealed record ServerConfig(string Host, int Port);

public sealed class ServerConfigBuilder
{
    private string _host = "";
    private int _port = 80;

    public ServerConfigBuilder SetHost(string h) { _host = h; return this; }
    public ServerConfigBuilder SetPort(int p) { _port = p; return this; }

    // āœ“ Returns a NEW, IMMUTABLE record — not the builder
    public ServerConfig Build() => new(_host, _port);
}

var config = new ServerConfigBuilder().SetHost("prod").SetPort(443).Build();
// config.Host = "evil"; // āœ— Compile error — record is immutable

Why the fix works: Now there are two completely separate types: ServerConfigBuilder (mutable, has setter methods) and ServerConfig (an immutable record). When Build() runs, it creates a brand new ServerConfig using the builder's current state. The builder and the product are different objects in memory. If someone tries to set config.Host = "evil", the compiler blocks it because records are immutable by default. The type system now prevents the bug at compile time.

Lesson Learned

Build() must always return a new, separate, immutable object — never this. The builder is mutable (that's its job); the product is immutable (that's the whole point). Use C# record types for products to get immutability, value equality, and with-expressions for free.

How to Spot This in Your Code

Open any builder class and look at the Build() method's return type. If Build() returns the same type as the builder class itself (e.g., public ConfigBuilder Build()), that is a strong red flag. The return type of Build() should always be a different type from the builder. Also check if Build() contains return this instead of return new ....

Section 13

Pitfalls & Anti-Patterns

Mistake: A single builder that constructs 15 different types of objects with 50+ configuration methods.

Why This Happens: It usually starts innocently. A developer creates a DocumentBuilder for one type of document. Then the team needs to build reports, so they add report methods. Then invoices. Then receipts. Each time, it is "just one more method." Before long, the builder has 50 methods and nobody can tell which methods go with which product type.

The temptation is understandable: having one builder feels simpler than managing five separate ones. But the complexity does not disappear; it just moves inside the builder, where it becomes tangled and untestable. A builder with 50 methods is like a Swiss army knife with 50 tools. Sure, it can do everything, but it is awkward for any specific task.

DocumentBuilder (50+ methods) SetTitle() AddReportSection() SetInvoiceLineItems() SetReceiptTotal() AddChartData() SetFooter() ... Returns object — builds anything! Focused Builders ReportBuilder InvoiceBuilder ReceiptBuilder Each: 5-10 methods, one job
GodBuilder.cs
// āœ— God Builder — too many responsibilities
public class DocumentBuilder
{
    public DocumentBuilder SetTitle(string t) { ... }
    public DocumentBuilder AddReportSection(Section s) { ... }
    public DocumentBuilder SetInvoiceLineItems(List<Item> items) { ... }
    public DocumentBuilder SetReceiptTotal(decimal total) { ... }
    // ... 46 more methods mixing reports, invoices, receipts
    public object Build() { ... } // Returns "object" because it builds anything!
}
FocusedBuilders.cs
// āœ“ Focused builders — one per product type
public class ReportBuilder { ... }   // Only report methods
public class InvoiceBuilder { ... }  // Only invoice methods
public class ReceiptBuilder { ... }  // Only receipt methods
// Each has 5-10 methods, clear responsibility, easy to test

The connection: The bad version tries to be everything. The good version splits responsibilities so each builder is small, focused, and easy to understand. If a builder has methods that don't all contribute to the same product, it needs to be split.

Mistake: Creating a PointBuilder for new Point(x, y) — using Builder when a simple constructor works fine.

Why This Happens: A developer reads about the Builder pattern and gets excited. They start seeing "construction" everywhere and want to apply the pattern to every class. The thinking goes: "Builder makes code more readable, so let me use it for everything." But readability has a cost. Adding a builder class for a 2-property object means you now have twice as many classes to maintain, and the "readable" version is actually longer and harder to follow than the simple constructor.

This is over-engineeringAdding unnecessary complexity for problems that don't exist — a Builder for a 2-property class adds a class, allocation, and cognitive overhead for zero benefit. at its worst. The cure is worse than the disease. A 2-parameter constructor does not have a "which parameter is which?" problem because you can see both of them at a glance.

PointBuilder (overkill) new PointBuilder() .WithX(10).WithY(20) .Build() 4 lines + extra class Simple Constructor new Point(x: 10, y: 20) 1 line, named params, done
OverEngineered.cs
// āœ— Over-engineered — Builder for a trivial class
var point = new PointBuilder()
    .WithX(10)
    .WithY(20)
    .Build();  // 4 lines to create a Point!
JustRight.cs
// āœ“ Simple constructor — perfectly clear for 2 params
var point = new Point(x: 10, y: 20);  // 1 line, named params

The connection: The builder version adds a whole extra class, an extra allocation, and 3 extra lines of code for zero benefit. The constructor version uses named parameters which are just as readable. Only reach for Builder at 4+ optional parameters or when cross-field validation is needed.

Mistake: A Build() method that blindly assembles the product without checking required fields or invariants.

Why This Happens: Developers often build the happy path first: "Let me get the builder working, I will add validation later." But "later" never comes. The builder ships without validation, and invalid objects start leaking into the system. The developer assumed callers would always configure the builder correctly. They will not.

Without validation in Build(), you just have a fancy constructor wrapper with extra steps. The whole point of the Builder pattern is to be the gatekeeper: it accumulates configuration, then validates everything is correct before creating the product. Remove the validation, and the pattern loses its primary value.

Build() — No Validation null ID qty = -5 Order Garbage in, garbage out Build() — With Validation null ID Gatekeeper blocks invalid objects
NoValidation.cs
// āœ— Build() with no validation — creates anything, even garbage
public Order Build() => new(_customerId, _productId, _quantity);
// What if _customerId is null? What if _quantity is -5?
WithValidation.cs
// āœ“ Build() validates before creating — catches problems early
public Order Build()
{
    var errors = new List<string>();
    if (string.IsNullOrWhiteSpace(_customerId))
        errors.Add("Customer ID is required");
    if (_quantity <= 0)
        errors.Add("Quantity must be positive");
    if (errors.Count > 0)
        throw new InvalidOperationException(
            string.Join("; ", errors));
    return new Order(_customerId!, _productId!, _quantity);
}

The connection: The bad version trusts every caller to get it right. The good version verifies before constructing. This is the difference between a door with no lock and a door with a deadbolt. Both let authorized people through, but only one stops unauthorized entry.

Mistake: Exposing public setters on the Product after Build().

Why This Happens: The developer designs the product class like any other C# class: public get/set properties. It feels natural, especially if they are used to working with DTOs and Entity Framework entities. They do not think about what happens after construction because, in their mind, the builder "handles" the creation. But if the product has public setters, anyone can change any property at any time, completely bypassing the builder's validation.

It is like having a security guard at the front door (the builder validates during construction) but leaving every window wide open (public setters after construction). The guard is pointless if people can just climb through the windows.

Mutable Product Order { get; set; } Anyone: order.Qty = -1 Validation bypassed! Immutable Product record Order(...) Anyone: order.Qty = -1 BLOCKED No setters = no backdoor
MutableProduct.cs
// āœ— Product with public setters — anyone can bypass validation
public class Order
{
    public string CustomerId { get; set; }  // Can be nulled after Build!
    public int Quantity { get; set; }       // Can be set to -1 after Build!
}
ImmutableProduct.cs
// āœ“ Immutable product — locked down after construction
public sealed record Order(
    string CustomerId,
    int Quantity);
// No setters. Record is immutable. Use "with" for safe copies.

The connection: The bad version exposes set, which means any code anywhere can change the product's state without going through validation. The good version uses a record, which is immutable by default. After Build() creates it, nobody can change it. Use init, readonly, or record to lock down products.

Mistake: Registering a Builder as Singleton in DIDependency Injection — the technique of providing dependencies to a class from outside rather than having the class create them itself..

Why This Happens: Developers who are new to DI often default to Singleton because "it sounds efficient." They think: "Why create a new builder every time when I can reuse one?" The answer is that builders are designed to accumulate state from one specific build session. Sharing a builder between sessions (or worse, between threads) means one session's state leaks into another.

Think of a builder like a notepad where you write down an order. If the waiter uses the same notepad for Table 1 and Table 2 without tearing off the page, Table 2's order contains Table 1's food. Each table needs its own fresh page.

Singleton Builder 1 Instance Req A writes Req B writes State leaks between requests! Transient Builder Req A Req B Req C Fresh instance per request
SharedState.cs
// āœ— Singleton — one builder for all requests
services.AddSingleton<OrderBuilder>();
// Request A and B write to the same builder = data leak
FreshState.cs
// āœ“ Transient — fresh builder every time
services.AddTransient<OrderBuilder>();
// āœ“ Even better — just new it up, no DI needed
var builder = new OrderBuilder();

The connection: Singleton shares one instance across all requests, so state leaks between them. Transient creates a fresh instance each time, guaranteeing clean state. For builders, always prefer Transient or just new them directly.

Mistake: The builder's List<T> is directly assigned to the Product without a defensive copy.

Why This Happens: The developer writes AllowedOrigins = _origins in the product's constructor, directly assigning the builder's internal list. It looks correct; the list has the right items. But both the builder and the product now point to the exact same list object in memory. If the builder's list is later modified (for a second build, or by accident), the product's list changes too.

It is like giving someone a spare key to your house instead of a photocopy of a document. They can now enter and rearrange things whenever they want. A defensive copy is like giving them a photocopy: their copy is independent from your original.

Shared Reference Builder Product List<string> Same object in memory! Defensive Copy Builder Product List A List B (copy) Independent lists
ExposedCollection.cs
// āœ— Direct assignment — product shares builder's list
internal Config(List<string> origins)
{
    AllowedOrigins = origins; // Same reference! Builder can mutate product!
}
DefensiveCopy.cs
// āœ“ Defensive copy — product has its own independent list
internal Config(List<string> origins)
{
    AllowedOrigins = origins.AsReadOnly(); // New read-only wrapper
}

The connection: The bad version shares a reference, so changes to the builder's list affect the product. The good version creates an independent read-only copy, so the product is completely isolated from the builder after construction.

Mistake: Builder methods return IBuilder (the interface) instead of the concrete type.

Why This Happens: The developer follows "program to an interface" advice and makes all builder methods return IBuilder. This is good advice in general, but it breaks fluent chains. If a base method returns IBuilder, and your subclass adds WithAge(), you cannot call builder.WithName("X").WithAge(30) because after WithName() the chain sees IBuilder, which does not have WithAge().

Think of it like a phone menu system. After each selection, you are sent back to the main menu instead of staying in the submenu you were navigating. You lose context and have to start over.

Returns IBuilder WithName("A") IBuilder .WithAge() Compile error! IBuilder has no WithAge Returns TSelf (CRTP) WithName("A") UserBuilder .WithAge() Chain preserved, compiles fine
LostFluency.cs
// āœ— Base method returns IBuilder — chain loses derived type
public interface IBuilder { IBuilder WithName(string n); }
public class UserBuilder : IBuilder
{
    public IBuilder WithName(string n) { ... } // Returns IBuilder
    public UserBuilder WithAge(int a) { ... }
}
// builder.WithName("A").WithAge(30) ← Compile error! IBuilder has no WithAge
PreservedFluency.cs
// āœ“ CRTP preserves the concrete type through the chain
public abstract class BaseBuilder<TSelf> where TSelf : BaseBuilder<TSelf>
{
    public TSelf WithName(string n) { _name = n; return (TSelf)this; }
}
public class UserBuilder : BaseBuilder<UserBuilder>
{
    public UserBuilder WithAge(int a) { ... }
}
// builder.WithName("A").WithAge(30) ← Works! Chain stays as UserBuilder

The connection: The bad version returns the base interface type, which "forgets" the concrete type and breaks the chain. The good version uses CRTPCuriously Recurring Template Pattern — a C# technique where a base class takes its subclass as a generic parameter: class Builder<T> where T : Builder<T>. Enables fluent method chains that preserve the subclass type. so every method in the chain returns the correct subclass type. See Q17 in Interview Q&AsSection 17 below — covers the generic base Builder implementation with CRTP in detail with code examples. for the full implementation.

Mistake: Build steps that make HTTP calls, write to databases, or send messages.

Why This Happens: A developer thinks: "The builder already knows what to construct, so why not have it also do some setup work while building?" For example, WithTemplate() fetches a template from a remote API during the build step. This seems convenient, but it turns a simple, predictable builder into something that depends on network availability, response times, and error handling.

Builder steps should be like filling out a form: you write things down. You do not mail the form between each question. All the "doing" should happen after the form is complete, not during the filling-out process. Side effects during construction make the builder unpredictable, hard to test (you need to mock HTTP calls), and order-dependent (what if the API call fails halfway through?)

Side Effects in Steps SetTitle() WithTemplate() HTTP call! Build() Can fail mid-chain! Pure Steps, IO in Build SetTitle() SetUrl() BuildAsync() HTTP here All IO in one place
SideEffects.cs
// āœ— Builder step makes an HTTP call — side effect during construction
public ReportBuilder WithTemplate(string url)
{
    _template = _httpClient.GetStringAsync(url).Result; // Blocks! Can fail!
    return this;
}
PureSteps.cs
// āœ“ Builder step stores config only — I/O happens in BuildAsync()
public ReportBuilder WithTemplateUrl(string url)
{
    _templateUrl = url; // Just stores the URL. No I/O.
    return this;
}

public async Task<Report> BuildAsync()
{
    var template = await _httpClient.GetStringAsync(_templateUrl);
    return new Report(template, _title, _sections);
}

The connection: The bad version performs I/O during a build step, which can fail, block, or throw. The good version stores configuration during build steps and does all the I/O in a single BuildAsync() method. This keeps steps pure, predictable, and testable.

Mistake: A builder that allows Build() to be called repeatedly but produces unpredictable results because internal state mutates between calls.

Why This Happens: The developer does not consider whether the builder should be single-use or multi-use. They leave Build() as a simple method with no guards. A caller innocently calls Build() twice, expecting two identical products. But if the builder has internal lists that accumulate (like adding sections), the second product contains more data than the first. Or if Build() clears some state but not all, the second product is subtly different.

Imagine a factory assembly line that does not reset between products. The first car gets 4 wheels, and the second car gets 8 because the wheel station just kept adding.

Unguarded Build() Build #1: 1 section Build #2: 2 sections! Build #3: 3 sections!! State keeps growing Protected Build() Build #1: 1 section Build #2: throw / fresh copy Predictable every time
MultiBuild.cs
// āœ— Allows multiple Build() calls with no protection
var b = new ReportBuilder().SetTitle("Q1");
b.AddSection("Revenue");
var report1 = b.Build(); // 1 section
b.AddSection("Users");
var report2 = b.Build(); // 2 sections! (Revenue + Users)
SingleUseBuild.cs
// āœ“ Option 1: Single-use — throw on second Build()
private bool _built;
public Report Build()
{
    if (_built) throw new InvalidOperationException("Already built");
    _built = true;
    return new Report(_title, new List<string>(_sections));
}

// āœ“ Option 2: Multi-use safe — defensive copy every time
public Report Build() => new(_title, new List<string>(_sections));

The connection: The bad version silently produces different results on each call. The good versions either block the second call entirely (single-use) or make a fresh defensive copy each time (multi-use safe). Pick one strategy and be explicit about it.

Mistake: Building a full Builder just to change one field on an existing record object.

Why This Happens: The developer is already using a builder to create objects, so they reach for it whenever they need a "modified copy" too. They write a ToBuilder() method on the product, modify one field, and call Build() again. It works, but it is a lot of ceremony for what should be a one-line operation.

In C# 9+, records support with-expressionsC# 9+ syntax for creating a copy of a record with some properties changed: var copy = original with { Name = "new" }. Non-destructive mutation. for exactly this purpose: var updated = original with { Name = "new" }. This creates a copy with one field changed, no builder needed. A builder adds unnecessary complexity when the record already has this feature built in.

Builder Round-Trip original.ToBuilder() .WithName("New").Build() 3 lines + extra method for 1 field with-Expression original with { Name = "New" } 1 line, built into C#
UnnecessaryBuilder.cs
// āœ— Over-engineered — using builder just to change one field
var updated = original.ToBuilder()
    .WithName("New Name")
    .Build();  // 3 lines + ToBuilder() method needed
WithExpression.cs
// āœ“ with-expression — built into C# records, one clean line
var updated = original with { Name = "New Name" };

The connection: The bad version goes through a builder round-trip to change one field. The good version uses the language's built-in feature for exactly this purpose. Reserve builders for constructing objects from scratch with validation. Use with for creating modified copies of existing records.

Section 14

Testing Strategies

Strategy 1: Test Builder Output (Happy Path)

Verify that the Build() method produces a Product with the exact values configured. This is the most basic builder test — call every setter, build, assert every property.

OrderBuilderTests.cs
[Fact]
public void Build_WithAllFields_CreatesCorrectOrder()
{
    // Arrange & Act
    var order = new OrderBuilder()
        .ForCustomer("C123")
        .ForProduct("P456")
        .WithQuantity(3)
        .WithCoupon("SAVE20")
        .AsGift("Happy Birthday!")
        .ShipTo("123 Main St")
        .WithPriority(Priority.High)
        .Express()
        .Build();

    // Assert — every field matches
    Assert.Equal("C123", order.CustomerId);
    Assert.Equal("P456", order.ProductId);
    Assert.Equal(3, order.Quantity);
    Assert.Equal("SAVE20", order.CouponCode);
    Assert.True(order.GiftWrap);
    Assert.Equal("Happy Birthday!", order.GiftMessage);
    Assert.Equal("123 Main St", order.ShippingAddress);
    Assert.Equal(Priority.High, order.Priority);
    Assert.True(order.ExpressShipping);
}

[Fact]
public void Build_WithMinimalFields_UsesDefaults()
{
    var order = new OrderBuilder()
        .ForCustomer("C1")
        .ForProduct("P1")
        .Build();

    Assert.Equal(1, order.Quantity);        // Default
    Assert.Null(order.CouponCode);           // Optional
    Assert.False(order.GiftWrap);            // Default
    Assert.Equal(Priority.Normal, order.Priority); // Default
}

Verify that Build() throws when required fields are missing or cross-field constraints are violated. These tests ensure the builder actually protects against invalid state.

ValidationTests.cs
[Fact]
public void Build_WithoutCustomerId_Throws()
{
    var builder = new OrderBuilder().ForProduct("P1");

    var ex = Assert.Throws<InvalidOperationException>(
        () => builder.Build());
    Assert.Contains("Customer ID is required", ex.Message);
}

[Fact]
public void Build_ExpressWithoutAddress_Throws()
{
    var builder = new OrderBuilder()
        .ForCustomer("C1")
        .ForProduct("P1")
        .Express(); // Express requires an address!

    var ex = Assert.Throws<InvalidOperationException>(
        () => builder.Build());
    Assert.Contains("Express shipping requires an address", ex.Message);
}

[Fact]
public void Build_NegativeQuantity_Throws()
{
    var builder = new OrderBuilder()
        .ForCustomer("C1")
        .ForProduct("P1")
        .WithQuantity(-1);

    Assert.Throws<InvalidOperationException>(() => builder.Build());
}

Verify the product can't be modified after Build(). Ensure collections are read-only, builder state changes don't affect already-built products, and the product has no public setters.

ImmutabilityTests.cs
[Fact]
public void BuilderChanges_DoNotAffect_AlreadyBuiltProduct()
{
    var builder = new OrderBuilder()
        .ForCustomer("C1")
        .ForProduct("P1");

    var order1 = builder.Build();

    // Modify builder AFTER building
    builder.WithQuantity(99);
    var order2 = builder.Build();

    // First product is unaffected
    Assert.Equal(1, order1.Quantity);   // Still default
    Assert.Equal(99, order2.Quantity);  // New value
}

[Fact]
public void ProductCollections_AreReadOnly()
{
    var config = new ConfigBuilder()
        .AddOrigin("https://example.com")
        .Build();

    // Should not be able to cast back to mutable list
    Assert.IsAssignableFrom<IReadOnlyList<string>>(
        config.AllowedOrigins);
}

If you use a Director, test that it calls builder steps in the correct order and produces the expected configuration. Mock the builder to verify the call sequence.

DirectorTests.cs
[Fact]
public void Director_BuildsMonthlyReport_WithCorrectStructure()
{
    var builder = new PlainTextReportBuilder();
    var director = new ReportDirector();

    var report = director.BuildMonthlyReport(builder);

    Assert.Equal("Monthly Report", report.Title);
    Assert.Contains("Performance Summary", report.Header);
    Assert.Equal(3, report.Sections.Count);
    Assert.Contains("Revenue", report.Sections[0]);
}

[Fact]
public void Director_SameProcess_DifferentBuilders_DifferentOutput()
{
    var director = new ReportDirector();

    var text = director.BuildMonthlyReport(new PlainTextReportBuilder());
    var html = director.BuildMonthlyReport(new HtmlReportBuilder());

    // Same content, different representation
    Assert.Contains("===", text.Header);      // Plain text format
    Assert.Contains("<header>", html.Header); // HTML format
}
Section 15

Performance Considerations

ConcernImpactMitigation
Builder allocationOne extra object per build — typically negligibleUse struct builderA value-type builder allocated on the stack instead of the heap. Avoids GC pressure but can't be used with interfaces. Rare optimization for extremely hot paths. for hot paths (rare)
StringBuilder vs string concatString concat is O(n²) for n appends; StringBuilder is O(n)Always use StringBuilder for 3+ concatenations in a loop
Defensive copies in Build()Copying collections adds O(n) per collectionNecessary for safety — don't skip. Use ImmutableArray.CreateBuilder + MoveToImmutable() (zero-copy when Count == Capacity)
Validation in Build()String checks are near-zero costKeep validation simple — no I/O in Build()
Fluent chain GC pressureReturning this has zero allocation — same objectNo concern — fluent chains don't allocate
BenchmarkResults.txt
// BenchmarkDotNet results — string building comparison
// | Method              |       Mean | Allocated |
// |---------------------|-----------:|----------:|
// | StringConcat_10     |   285.3 ns |   1.22 KB |
// | StringBuilder_10    |    89.1 ns |   0.34 KB |
// | StringConcat_100    | 8,420.1 ns |  26.40 KB |
// | StringBuilder_100   |   612.4 ns |   1.05 KB |
// | StringConcat_1000   | 842.3 μs   | 2,700 KB  |
// | StringBuilder_1000  |   5.8 μs   |   8.20 KB |
//
// Verdict: StringBuilder is 3x faster at 10 items,
//          14x at 100, and 145x at 1000.
//          Always use StringBuilder in loops.

Builder vs Direct Constructor — Overhead

BuilderOverhead.txt
// BenchmarkDotNet — Builder vs Constructor for a 6-property object
// | Method              |     Mean |  Allocated |
// |---------------------|---------:|-----------:|
// | DirectConstructor   | 12.4 ns  |     64 B   |
// | BuilderWithBuild    | 38.7 ns  |    128 B   |
// | ObjectInitializer   | 13.1 ns  |     64 B   |
//
// Builder costs ~26 ns extra and 64 B more (the builder object itself).
// That's 0.000026 ms — invisible next to any I/O.
// You'd need 40,000 builds per ms to even notice.

ImmutableArray.CreateBuilder<T>

ImmutableArray.CreateBuilder<T>()Creates a mutable builder for an immutable array. You fill it incrementally (Add, AddRange) then call MoveToImmutable() for a zero-copy transfer to the final ImmutableArray. is .NET's most performance-conscious builder. Instead of copying elements to a new array, MoveToImmutable() transfers ownership of the internal buffer — zero-copy when capacity matches count.

ImmutableArrayBuilder.cs
var builder = ImmutableArray.CreateBuilder<int>(initialCapacity: 100);
for (int i = 0; i < 100; i++)
    builder.Add(i * 2);

// Zero-copy when Count == Capacity:
ImmutableArray<int> result = builder.MoveToImmutable();
// builder is now empty — ownership transferred to result
When Performance Actually Matters

For domain builders (OrderBuilder, ConfigBuilder), the extra allocation is invisible — your database call takes 1000x longer. For hot pathsCode that executes very frequently — tight loops, serialization, request parsing. Even small allocations here can cause GC pressure and latency spikes. (serialization, string formatting in loops), consider StringBuilder, Span<T>A stack-allocated view over contiguous memory — enables zero-allocation string slicing and array operations without copying data., or ArrayPool<T>A shared pool of reusable arrays that reduces GC pressure — rent an array, use it, return it. Built into System.Buffers. instead of custom builders.

Section 16

How to Explain in Interview

Your Script (90 seconds)

"Builder separates the construction of a complex object from its representation. Instead of a constructor with 10 parameters where you forget which null means what, you configure the object step by step with named methods, then call Build() which validates everything and returns an immutable product.

In .NET, this is everywhere — WebApplicationBuilder, StringBuilder, IHostBuilder, DbConnectionStringBuilder. The GoF version has a Director orchestrating the steps, but modern .NET almost exclusively uses fluent builders with method chaining.

The key benefits: readability (named methods vs positional params), validation (catch invalid state at Build time, not at usage time), and immutability (the product can't be modified after creation). The Step Builder variant goes further — it uses different interface types for each step so the compiler won't let you skip required steps or call them out of order.

I use Builder when an object has 4+ optional parameters, needs cross-field validation, or must be immutable. For simple DTOs with 2-3 fields, a constructor or object initializer is simpler."

If They Follow Up...

"Builders should be Transient or just new'd directly — never Singleton or Scoped. Each build operation needs fresh state. If the builder itself needs dependencies (like IValidator<T>), inject a builder factory instead: Func<OrderBuilder> or an IOrderBuilderFactory that creates pre-configured builders."

"Builders are inherently not thread-safe — they accumulate mutable state. This is fine because builders are meant to be short-lived, local objects. If you need to build from multiple threads, each thread gets its own builder instance. The product should be immutable and safely shared across threads."

"required enforces that properties are set, but it can't enforce that they're valid. It can't do cross-field validation ('if express shipping, address is required'). And it doesn't help with computed defaults or step-by-step construction. For simple DTOs, required is better. For domain objects with invariants, Builder wins."

Section 17

Interview Q&As

26 Questions: 5 Easy + 11 Medium + 10 Hard

Start with Easy to nail fundamentals. Medium covers design decisions you'll face in real projects. Hard is principal-engineer-level — CRTP, source generators, async builders, and production architecture.

Easy

Think First What problem does a 10-parameter constructor create?

Think of ordering a custom sandwich at a deli. You do not shout all 10 ingredients at once and hope the person behind the counter remembers them all. Instead, you go step by step: "I want wheat bread, then turkey, then Swiss cheese, then lettuce..." and at the end you say "that's it, make it." That "make it" moment is .Build().

Builder is a creational pattern that constructs complex objects step by step. Instead of passing 10 parameters to a constructor (where you forget which null means what), you call named methods like .SetName(), .WithEmail(), then finalize with .Build(). Each method is self-documenting, so you always know what you are configuring.

It solves three problems: (1) Telescoping constructors — too many overloads. (2) No validation — invalid object state isn't caught until runtime. (3) Mutability — objects can be changed after creation. The builder addresses all three: named methods replace positional params, Build() validates before creating, and the product is immutable.

Great Answer Bonus "In .NET, WebApplicationBuilder is the most prominent example — it configures DI, logging, and middleware step by step before producing an immutable WebApplication."
Think First Can you name all four participants and their roles?

Think of building a house. You have four key roles: the blueprint (Builder interface) describes what steps exist (lay foundation, build walls, add roof). The construction crew (ConcreteBuilder) actually performs those steps and keeps track of progress. The architect (Director) decides the order: "foundation first, then walls, then roof." And the house (Product) is the finished result you move into.

Builder — the interface declaring the construction steps. ConcreteBuilder — implements the steps, accumulates state, and provides Build(). Director — orchestrates which steps are called and in what order. Product — the complex object being constructed, typically immutable.

In modern .NET, the Director is often omitted — the client chains fluent methods directly on the builder. But the Director role is still useful when you have predefined "recipes" like BuildGamingPC() vs BuildBudgetPC() that call builder methods in specific sequences.

Great Answer Bonus "The Director role exists to encapsulate common build sequences — like a BuildMonthlyReport() that always adds the same sections. It's still useful even with fluent builders."
Think First Think beyond StringBuilder — what do you use in every .NET app?

(1) WebApplicationBuilder — configures services, logging, config, then .Build() → WebApplication. (2) StringBuilder — appends string fragments, then .ToString(). (3) IHostBuilderThe generic host builder for non-web .NET applications — configures DI, logging, and configuration for background services and console apps. — configures generic host for background services. Others: ConfigurationBuilder, UriBuilderSystem.UriBuilder — safely constructs URIs by setting Scheme, Host, Port, Path, Query as typed properties rather than concatenating strings., SqlConnectionStringBuilder, IEndpointConventionBuilderThe fluent builder returned by MapGet/MapPost in minimal APIs — lets you chain .RequireAuthorization(), .WithTags(), .CacheOutput() etc..

Great Answer Bonus "EntityTypeBuilder in EF Core's Fluent API is another great example — it configures entity mappings step by step with .HasKey(), .Property(), .HasIndex()."
Think First What makes method chaining possible?

A fluent interface is an API design where each method returns the object itself (return this), enabling method chaining: builder.A().B().C().Build(). It's the dominant implementation style for modern builders because it's readable and discoverable via IntelliSense.

Fluent interface is a technique; Builder is a pattern. Not all fluent interfaces are Builders (LINQ is fluent but not a Builder), and not all Builders are fluent (the GoF version isn't).

Great Answer Bonus "The return type matters — if each method returns the base type, you lose access to subclass-specific methods in the chain. The CRTP pattern (Builder<TSelf>) solves this."
Think First What's the simplest construction scenario where Builder is overkill?

Imagine buying a hammer to hang a single picture frame. You don't need a full carpentry workshop for that. Builder is the same way: it is a powerful tool, but not every construction job needs it.

Don't use Builder when: (1) the object has 1-3 parameters — a simple constructor is fine, named arguments make it readable. (2) No validation is needed — object initializers like new Foo { A = 1 } work great. (3) The object is a simple DTO with no invariants or business rules. (4) C# record with required members handles your needs. (5) You only need to change one field on an existing object — use with-expression instead of round-tripping through a builder.

A good rule of thumb: if you can describe the constructor call to a teammate without them getting confused, you do not need a builder. Builders shine when the construction is complex enough that a plain constructor becomes unreadable or error-prone.

Great Answer Bonus "The threshold is roughly 4+ optional parameters or any cross-field validation. Below that, Builder adds overhead without enough benefit."

Easy complete! You can explain Builder's purpose, participants, and when to use it. Now let's go deeper into design decisions.

Medium

Think First What does each pattern focus on — process or product families?

Think of it with a restaurant analogy. Builder is like a custom burger counter: you build one meal step by step (pick the bun, add patty, choose toppings, finalize). Abstract Factory is like choosing a cuisine: if you pick "Italian," every dish on the menu is Italian (pasta, wine, tiramisu). The factory ensures everything belongs together; the builder ensures one complex thing is built correctly.

Builder focuses on constructing one complex object step by step. Abstract Factory focuses on creating families of related objects that must be consistent. Builder is about the construction process; Abstract Factory is about product compatibility.

Builder often produces one big Product. Abstract Factory produces multiple small Products that belong to the same family. Builder validates at Build() time; Abstract Factory guarantees consistency by restricting which factory creates which products.

Builder Pattern Step 1 Step 2 Build() One Complex Product Abstract Factory Pattern ItalianFactory Pasta Wine Tiramisu Family of Related Products
Great Answer Bonus "They can work together — an Abstract Factory might use a Builder internally to construct complex products: factory.CreateReport() internally uses a ReportBuilder."
Think First How can the compiler enforce that certain build steps must happen before others?

Imagine a recipe where you absolutely must preheat the oven before putting in the cake batter. If you skip preheating, the cake is ruined. A regular builder lets you call methods in any order, so it cannot prevent someone from adding batter before preheating. A Step Builder fixes this by using the type system as a guard rail.

A Step BuilderA Builder variant where each build phase returns a different interface type — the compiler enforces that required steps happen in order and optional steps only appear after required ones. uses different interface types for each build phase. Step 1 returns IStep2, which returns IStep3, etc. The compiler will not let you skip steps or call them out of order because each step only exposes the methods for the next step. After you call the method on IStep1, your variable's type changes to IStep2, and only IStep2's methods are visible in IntelliSense.

Use it when: build order matters (cannot set WHERE before FROM in SQL), required steps must happen before optional ones, or you want compile-time rather than runtime validation of the build sequence.

IBaseUrlStep From(table) IMethodStep Where(col, val) IOptionalStep OrderBy() / Limit() IBuildable Build() Each step returns the NEXT interface. Compiler blocks skipping or reordering. Calling .Where() before .From() is a COMPILE ERROR, not a runtime crash
Great Answer Bonus "The trade-off is more interfaces — one per step. For complex builders with 10+ steps, this gets verbose. Use it for critical builders where wrong step order causes hard-to-debug issues."
Think First What are 3 different approaches to enforce required fields?

Three approaches: (1) Validate in Build() — throw if required fields are null. Simple but runtime-onlyErrors are only caught when the code runs, not when it compiles. Build() validation is runtime — you only discover missing fields when Build() is called.. (2) Required params in constructor — new OrderBuilder(customerId, productId) forces required fields upfront, optional via fluent methods. (3) Step Builder — return type changes per step, compile-time enforcementErrors are caught by the compiler before the code runs. Step Builder's typed interfaces make wrong step order a compiler error, not a runtime exception..

Great Answer Bonus "Approach 2 is the sweet spot for most cases — required fields are constructor params (compile-time), optional fields are fluent methods, and Build() does cross-field validation."
Think First What C# features help make a class immutable?

(1) Make the Product's constructor internal — only the Builder can create it. (2) Use readonly fieldsC# fields marked readonly can only be assigned in the constructor or field initializer. They prevent accidental mutation after construction. or init-only propertiesC# 9+ feature: properties that can only be set during object initialization (constructor or object initializer). Read-only afterwards.. (3) Use C# record types which are immutable by default. (4) Expose collections as IReadOnlyList<T>. (5) Make defensive copiesCreating a new copy of a mutable collection in the constructor so the caller's reference can't be used to mutate the object's internal state after construction. in the constructor — don't store the builder's mutable list directly.

Great Answer Bonus "Records + Builder is a powerful combo in C# — the record gives you immutability, value equality, ToString, and with-expressions. The Builder gives you validation and a clean construction API."
Think First Where does the build "recipe" live if there's no Director?

Think of a head chef in a restaurant. The chef does not cook the food directly (that is the cook's job, a.k.a. the builder). But the chef knows the recipe: "For the special, start with the sauce, then sear the steak, then plate with garnish." The recipe is reusable across different cooks (builders). The Director is that head chef: it holds the recipe and tells the builder which steps to perform and in what order.

The Director encapsulatesEncapsulation — hiding implementation details behind an interface. The Director hides the construction algorithm so clients don't need to know the step sequence. a specific construction sequence — it knows which steps to call and in what order. Without a Director, the client must know the sequence. The Director is still relevant when you have predefined configurations (e.g., BuildGamingPC, BuildBudgetPC) that should be reusable.

In practice, modern .NET either uses the client as the director (fluent chains) or uses factory methods that encapsulate the build sequence: Order.CreateGiftOrder(...) internally uses a builder. The Director is basically a factory method that takes a builder. It is still useful; it just often takes the form of a static method rather than a separate class.

Director BuildMonthlyReport() 1. SetTitle("Monthly") 2. AddHeader(summary) 3. AddSections(data) Build() Report (immutable product) Director knows the recipe. Builder does the work. Product is the result.
Great Answer Bonus "The Director is basically a factory method that takes a Builder. It's still useful — it just often takes the form of a static method rather than a separate class."
Think First What DI lifetime makes sense for a mutable, stateful object?

This question trips up many candidates because the natural instinct is "I want to reuse objects, so Scoped or Singleton." But builders are the exception. They accumulate mutable state that is specific to one build operation. Sharing them means sharing that state.

TransientDI lifetime where a new instance is created every time the service is requested. AddTransient<T>() — ideal for stateful, short-lived objects like builders. — always. Builders are mutable and stateful. SingletonDI lifetime where one instance is shared across the entire application. AddSingleton<T>() — dangerous for builders because concurrent requests share mutable state. = race conditions (two threads writing to the same builder). ScopedDI lifetime where one instance is created per HTTP request (or scope). AddScoped<T>() — still risky for builders if multiple usages occur within one request. = state leaks between usages in the same scope (generating two reports in one request means the second inherits the first's data). Better yet, don't register builders in DI at all — just new them. Builders are simple, have no dependencies, and are meant to be short-lived.

If a builder needs injected dependencies (rare), register a builder factory as Singleton that creates fresh builder instances: IBuilderFactory.Create(). The factory lives forever; the builders it creates are disposable and short-lived.

Great Answer Bonus "This is a common interview trap — candidates register builders as Scoped and then wonder why their second report contains the first report's data."
Think First What can a Builder do that an object initializer can't?

Object initializer: new Foo { A = 1, B = 2 }. Good for simple DTOs, no validation, object is mutable. Builder: validation in Build(), immutable product, cross-field rules, can transform inputs. Use initializers for simple data carriers; use Builder for domain objects with invariants.

C# 11's required keyword narrows the gap — it enforces property assignment at compile time — but still can't do cross-field validation or immutability.

Great Answer Bonus "I use the 'invariant test' — if the object has business rules that must always be true (e.g., express shipping requires an address), use Builder. If it's just data with no rules, use an initializer."
Think First How do you create test objects with sensible defaults without cluttering every test?

A Test Data BuilderA Builder specifically designed for unit tests that provides sensible defaults for all properties. Tests only override the fields relevant to the scenario, keeping tests focused and readable. provides sensible defaults so each test only overrides what's relevant to that scenario. This avoids the "arrange monster" where every test manually sets 15 properties.

OrderTestBuilder.cs
public class OrderTestBuilder
{
    private string _customerId = "C-DEFAULT";
    private string _product = "Widget";
    private decimal _price = 9.99m;
    private int _qty = 1;
    private string? _coupon;

    public OrderTestBuilder WithCustomer(string id) { _customerId = id; return this; }
    public OrderTestBuilder WithProduct(string p, decimal price) { _product = p; _price = price; return this; }
    public OrderTestBuilder WithQuantity(int q) { _qty = q; return this; }
    public OrderTestBuilder WithCoupon(string code) { _coupon = code; return this; }

    public Order Build() => new()
    {
        CustomerId = _customerId, Product = _product,
        Price = _price, Quantity = _qty, CouponCode = _coupon
    };
}

// Tests are now one-liners:
var order = new OrderTestBuilder().WithCoupon("SAVE10").Build();
var bulkOrder = new OrderTestBuilder().WithQuantity(500).Build();
Great Answer Bonus "It follows the 'relevant detail only' principle — if a test is about discount logic, the builder handles all the irrelevant defaults. This makes tests more readable and resilient to constructor changes."
Think First What access does a nested class have that a separate class doesn't?

Nested Builder (Josh Bloch style): The Builder is a static nested class inside the Product. Advantage: it can access the Product's private constructor, ensuring only the Builder can create instances. Usage: Order.Builder().WithCustomer("C1").Build().

Separate Builder: Lives in its own file. Advantage: better separation of concerns, easier to test, doesn't bloat the Product class. The Product needs an internal constructor for the builder to call.

Rule of thumb: Use nested if the Product and Builder are tightly coupled and the Product should be creatable ONLY via Builder. Use separate when multiple builders might produce the same Product type (like Classic GoF with Director).

Great Answer Bonus "In .NET, separate builders are more common because DI and testability favor standalone classes. Nested builders are more of a Java convention (Effective Java Item 2)."
Think First What can required enforce that a Builder can, and what can't it?

required ensures a property is set during initialization — compile-time enforcement, zero boilerplate. But it has limits:

required can: enforce that a property is assigned (not null/default). required cannot: enforce cross-field rules ("if express shipping, address is required"), validate values ("email must contain @"), or produce immutable objects with complex construction logic.

RequiredVsBuilder.cs
// āœ“ required is enough — simple DTO, no invariants
public class CreateUserRequest
{
    public required string Name { get; init; }
    public required string Email { get; init; }
}

// āœ— required is NOT enough — cross-field rules needed
// → Use Builder: OrderBuilder validates "express → address required"
Great Answer Bonus "I use the 'invariant test': if the object has cross-field business rules, use Builder. If it's just 'all fields must be set', required is simpler and should be preferred."
Think First Both configure objects with multiple properties — what's different?

IOptions<T>.NET's built-in configuration binding pattern. Maps appsettings.json sections to strongly-typed C# classes. Supports reload (IOptionsSnapshot), validation (DataAnnotations), and named options. binds configuration from external sources (appsettings.json, env vars) to a POCO. Builder constructs objects programmatically with step-by-step logic.

Use Options when: values come from config files/env vars, you need hot-reload (IOptionsSnapshot), or you want DataAnnotation validation. Use Builder when: construction involves computation, transformation, or cross-field business rules that go beyond simple property mapping.

They overlap for configuration objects — IConfigurationBuilder is literally a Builder that feeds into the Options pipeline.

Great Answer Bonus "In practice, Options handles 'what' to configure (values from appsettings), and Builder handles 'how' to configure (programmatic setup with logic). WebApplicationBuilder uses both — builder.Configuration feeds IOptions, while the builder itself orchestrates services."

Medium complete! You can handle real Builder design decisions. Now for principal-engineer territory — the questions that separate senior from staff.

Hard

Think First What's the problem with returning a base Builder type from fluent methods?

This is one of the trickier C# patterns, so let's build up to it. Say you have a BaseBuilder with shared methods like WithName(), and a UserBuilder that inherits from it and adds WithAge(). You want fluent chaining: new UserBuilder().WithName("Alice").WithAge(30).

The problem: If WithName() returns BaseBuilder, the chain breaks at .WithAge() because the compiler sees a BaseBuilder at that point, and BaseBuilder does not have a WithAge() method. The solution is called CRTP (Curiously Recurring Template Pattern), where the base class takes its own subclass as a generic parameter: BaseBuilder<TSelf>. Now WithName() returns TSelf (the actual subclass type), and the chain preserves the concrete type.

Without CRTP (broken) WithName() : BaseBuilder .WithAge(30) COMPILE ERROR With CRTP (works) WithName() : UserBuilder WithAge(30) : UserBuilder Build() : User CRTP Magic BaseBuilder<TSelf> returns (TSelf)this
GenericBuilder.cs
public abstract class BaseBuilder<TSelf> where TSelf : BaseBuilder<TSelf>
{
    protected string Name = "";
    public TSelf WithName(string n) { Name = n; return (TSelf)this; }
}

public class UserBuilder : BaseBuilder<UserBuilder>
{
    private int _age;
    public UserBuilder WithAge(int a) { _age = a; return this; }
    public User Build() => new(Name, _age);
}

// āœ“ Chain preserves UserBuilder type:
var user = new UserBuilder().WithName("Alice").WithAge(30).Build();
Great Answer Bonus "CRTP is a trade-off — it enables fluent inheritance but adds generics complexity. For most applications, prefer composition (shared config object) over inheritance for builder reuse."
Think First What does Build() actually do — just create an object, or something more?

If you have ever written a .NET web app, you have already used the Builder pattern without realizing it. WebApplication.CreateBuilder(args) returns a WebApplicationBuilder, and everything you do between that line and .Build() is configuration. You are using a builder.

WebApplicationBuilder aggregates several sub-builders internally: ConfigurationManager handles app settings, IServiceCollection handles dependency injection registrations, and ILoggingBuilder handles logging providers. Properties like builder.Services give you direct access to configure DI; builder.Configuration lets you add config sources from JSON files, environment variables, or command-line args.

When you call Build(), a lot happens behind the scenes: it compiles all service registrations into an IServiceProvider (the DI container), merges all configuration sources into a single IConfiguration root, sets up the HTTP pipeline, and returns an immutable WebApplication ready to run. This is a "big bang" operation: everything comes together at Build time. After Build(), you cannot add more services or change configuration.

WebApplicationBuilder .Services IServiceCollection .Configuration ConfigurationManager .Logging ILoggingBuilder .Build() WebApplication (immutable after Build) IServiceProvider built IConfiguration merged HTTP pipeline wired Ready to .Run()
Great Answer Bonus "The interesting architectural choice is that WebApplicationBuilder exposes mutable properties (Services, Configuration) directly rather than fluent methods. This makes it easier to pass to extension methods: builder.Services.AddMyStuff()."
Think First Should you even try, or is there a simpler solution?

This is a favorite trick question in interviews. The interviewer wants to see if you reach for locks and synchronization, or if you step back and question the premise. The correct senior answer is usually: "don't share builders between threads." Each thread gets its own builder instance. Problem solved without any complexity.

Builders are inherently mutable and sequential. That is their whole job: accumulate state, then produce a product. Making them thread-safeCode that works correctly when accessed by multiple threads simultaneously. Achieved through locks, immutability, or thread-local storage. with locks defeats their purpose and adds unnecessary complexity. The locks also hurt performance because threads wait for each other, and the error-prone synchronization code is harder to test than the builder itself.

If you absolutely must share build state across threads (rare), use an immutable builder pattern where each method returns a new builder instance (via record + with) instead of mutating this. Each method returns a fresh copy, so no thread can observe partial state. Think of it like a Google Docs version history: each edit creates a new version, and nobody can corrupt another user's view.

Bad: shared mutable builder Thread A Thread B One Builder (shared) Good: one builder per thread Thread A Builder A (own) Thread B Builder B (own) No shared state = no race conditions

Note: ImmutableArray.CreateBuilder() is a mutable builder for an immutable product. The builder itself is not thread-safe; only the resulting ImmutableArray is.

Great Answer Bonus "This is a trick question in interviews. The senior answer is 'don't share builders' — not 'add locks.' Architecture beats synchronization."
Think First How would you create variations of a complex object without rebuilding from scratch?

Sometimes you need to create many similar objects that differ in only one or two fields. Building each one from scratch through the full builder process would be wasteful. Instead, you build a "template" once with a Builder, then create variations by cloning it and tweaking a few fields. This is the PrototypeGoF creational pattern that creates new objects by copying an existing object (the prototype). In C#, implemented via ICloneable or record with-expressions. pattern combined with Builder.

In C#, the record type + with-expression is essentially Builder + Prototype baked into the language. You build the template once (Builder), then use with to create cheap variations (Prototype):

BuilderPlusPrototype.cs
// Product must be a record for with-expression support
public sealed record Order(
    string CustomerId, string ProductId,
    string Address, bool ExpressShipping = false,
    bool GiftWrap = false, string? GiftMessage = null);

// Build the base template
var template = new OrderBuilder()
    .ForCustomer("C1").ForProduct("P1")
    .ShipTo("123 Main St").Build();

// Clone with variations (Prototype via record with-expression)
var express = template with { ExpressShipping = true };
var gift = template with { GiftWrap = true, GiftMessage = "Hi!" };
Great Answer Bonus "This is exactly what C# records were designed for — Builder creates the initial complex object, then with-expressions create cheap variations without going through the full build process again."
Think First How would Build() ensure the query is parameterized, not concatenated?

SQL injection is one of the most dangerous web vulnerabilities, and the Builder pattern is naturally suited to prevent it. The key idea: the builder collects the structure of the query (which table, which columns) separately from the user-provided values. When Build() runs, it produces a parameterized query where user input is never directly concatenated into the SQL string. Instead, values become @p0, @p1 parameters that the database engine handles safely.

The builder should also validate inputs. Table names are whitelisted (not user-provided). Column names are checked against a regex to prevent injection through column names. The Build() return type is a tuple: the SQL string plus a dictionary of parameters. This forces the caller to use parameterized queries because the values are separate from the SQL.

SafeQueryBuilder.cs
public sealed class SafeQueryBuilder
{
    private string _table = "";
    private readonly List<string> _conditions = [];
    private readonly Dictionary<string, object> _params = new();
    private int _paramCount = 0;

    private static readonly HashSet<string> _allowedTables =
        ["Users", "Orders", "Products"]; // whitelist

    public SafeQueryBuilder From(string table)
    {
        if (!_allowedTables.Contains(table))
            throw new ArgumentException($"Table '{table}' is not allowed.");
        _table = table; return this;
    }

    private static readonly Regex _validColumn =
        new(@"^[A-Za-z_][A-Za-z0-9_]{0,127}$", RegexOptions.Compiled);

    public SafeQueryBuilder Where(string column, object value)
    {
        if (!_validColumn.IsMatch(column))
            throw new ArgumentException($"Invalid column name: '{column}'");
        var paramName = $"@p{_paramCount++}";
        _conditions.Add($"[{column}] = {paramName}");
        _params[paramName] = value;
        return this;
    }

    public (string Sql, Dictionary<string, object> Params) Build()
    {
        var sql = $"SELECT * FROM [{_table}]";
        if (_conditions.Count > 0)
            sql += " WHERE " + string.Join(" AND ", _conditions);
        return (sql, new Dictionary<string, object>(_params));
    }
}

var (sql, parms) = new SafeQueryBuilder()
    .From("Users")
    .Where("Name", "O'Brien")    // āœ“ Parameterized, not interpolated
    .Where("Active", true)
    .Build();
// sql: "SELECT * FROM Users WHERE Name = @p0 AND Active = @p1"
Great Answer Bonus "Real-world: libraries like Dapper and EF Core do exactly this internally. The Builder pattern is perfect for query construction because it naturally separates structure from data."
Think First What happens when a builder step needs to call a database or API?

This comes up a lot in real projects. What happens when a builder step needs to call a database, read from a remote API, or load a file? You cannot just slap async on every fluent method because that breaks method chaining. With async methods, you would need to await each call individually: var b1 = await builder.StepA(); var b2 = await b1.StepB();. The clean one-liner chain is gone.

Approach 1 — Async Build() only (recommended): Keep all fluent methods synchronous. They just store configuration, URLs, paths, and parameters. No I/O. Then make only BuildAsync() async, and do all the I/O there. This preserves the clean chaining syntax: builder.WithTitle("X").WithTemplateUrl("https://...").BuildAsync().

Approach 2 — Async steps with callback registration: For cases where you genuinely need interleaved async steps (e.g., step 2 depends on step 1's async result), register each step as a Func<T, Task<T>> callback. The builder stores these callbacks and executes them sequentially in BuildAsync():

AsyncBuilder.cs
public class ReportBuilder(HttpClient httpClient)
{
    private readonly HttpClient _httpClient = httpClient;
    private readonly List<Func<Report, Task<Report>>> _steps = [];

    public ReportBuilder WithTitle(string t)
    { _steps.Add(r => Task.FromResult(r with { Title = t })); return this; }

    public ReportBuilder WithRemoteTemplate(string url)
    {
        _steps.Add(async r => {
            var template = await _httpClient.GetStringAsync(url);
            return r with { Template = template };
        });
        return this;
    }

    public async Task<Report> BuildAsync()
    {
        var report = new Report();
        foreach (var step in _steps)
            report = await step(report);
        return report;
    }
}
Great Answer Bonus "Approach 1 is almost always better — it keeps the builder API simple and predictable. Only use Approach 2 if construction genuinely requires interleaved async steps, like IHostBuilder's build pipeline."
Think First What if you need to add a required step to an existing builder?

Builder APIs in libraries evolve — new steps are added, old ones deprecated. Key strategies:

(1) New optional methods with sensible defaults: Adding .WithTimeout() that defaults to 30s doesn't break anyone. This is the easiest path.

(2) Extension methods for new capabilities: Put new builder methods in a separate namespace. Callers who import the namespace get new features; others are unaffected.

(3) Interface versioning with Step Builder: Create IBuilderV2 that extends IBuilderV1 with new steps. Old code targets V1, new code targets V2.

(4) [Obsolete] attribute: Mark old methods with [Obsolete("Use WithConnectionString() instead")] — compiler warnings guide migration.

What NOT to do: Change the return type of an existing method, remove a method without [Obsolete] first, or change behavior silently.

Great Answer Bonus "ASP.NET Core does this well — WebHostBuilder → GenericHostBuilder → WebApplicationBuilder. Each generation is a new class, not a modification. Old builders still work, new code uses the latest."
Think First How do you build a tree where parent and children are all immutable?

Building immutable trees is a classic challenge because of a chicken-and-egg problem: a parent needs its children before it can be constructed (they are part of its IReadOnlyList), but children might also need their parent. The solution is to build bottom-up: construct leaf nodes first, then pass them to their parent's constructor. Recursive builders handle this elegantly.

Builder Tree (mutable) Root Builder A Builder B Builder A1 A2 Build() recursive, bottom-up Immutable Tree (product) Root A B A1 A2
TreeBuilder.cs
public sealed record TreeNode(
    string Name,
    IReadOnlyList<TreeNode> Children);

public class TreeNodeBuilder
{
    private string _name = "";
    private readonly List<TreeNodeBuilder> _childBuilders = [];

    public TreeNodeBuilder WithName(string n) { _name = n; return this; }

    public TreeNodeBuilder AddChild(Action<TreeNodeBuilder> configure)
    {
        var child = new TreeNodeBuilder();
        configure(child);
        _childBuilders.Add(child);
        return this;
    }

    public TreeNode Build() => new(
        _name,
        _childBuilders.Select(c => c.Build()).ToList());
}

// Usage — nested lambda pattern:
var tree = new TreeNodeBuilder()
    .WithName("Root")
    .AddChild(c => c.WithName("A")
        .AddChild(gc => gc.WithName("A1"))
        .AddChild(gc => gc.WithName("A2")))
    .AddChild(c => c.WithName("B"))
    .Build();
// Root → [A → [A1, A2], B]

Walking through the code: The key method is AddChild(Action<TreeNodeBuilder> configure). Instead of returning a child builder that the caller manages, it creates a child builder internally and lets the caller configure it through a lambda. This keeps the parent in control. When you call Build() on the root, it calls Build() on each child builder, which calls Build() on their children, and so on. The whole tree is constructed recursively from the leaves up. By the time the root's Build() finishes, every node is a fully immutable TreeNode record with a read-only list of children.

Great Answer Bonus "This is how .NET's IHostBuilder.ConfigureServices() works — you pass an Action<IServiceCollection> callback, not a return value. The framework controls when to 'Build' the entire tree."
Think First What happens when you have 50 domain objects and each needs a test builder?

Test Data Builders provide sensible defaults so tests only specify what's relevant. But in large projects, you end up maintaining 50+ builder classes — builder explosion.

Strategies to tame it:

(1) Generic base builder: Use CRTP to share common patterns (WithId, CreatedAt, etc.) across all test builders.

(2) AutoFixtureA .NET library that auto-generates test data. It creates objects with random but valid data, reducing the need for manual Test Data Builders. + customization: Instead of writing builders, use AutoFixture with ICustomization to define rules. It auto-fills properties you don't care about.

AutoFixtureVsBuilder.cs
// Manual Test Data Builder (50+ classes to maintain):
var order = new OrderTestBuilder().WithCoupon("SAVE10").Build();

// AutoFixture approach (zero builder classes):
var fixture = new Fixture();
fixture.Customize<Order>(c => c
    .With(o => o.CouponCode, "SAVE10")
    .Without(o => o.CancelledAt));
var order = fixture.Create<Order>();

// Hybrid: AutoFixture for simple objects, Builder for complex ones
// with cross-field invariants that AutoFixture can't guarantee.

(3) Object Mother + Builder: A static factory class (Object MotherA pattern where a static factory class provides pre-configured test objects: OrderMother.CreateStandard(), OrderMother.CreateExpressWithCoupon(). Simpler than builders for common scenarios.) provides common scenarios (OrderMother.CreateExpressOrder()), while builders handle the customizable edge cases.

Great Answer Bonus "In practice, I use a 3-tier approach: Object Mother for common scenarios, AutoFixture for 'I don't care about the values' tests, and Test Data Builders only for complex objects with business invariants."
Think First What information does a source generator need to emit a builder class?

Writing builders by hand is repetitive: for every property, you write a private field, a fluent setter, and include it in Build(). This is exactly the kind of mechanical boilerplate that machines should handle. Roslyn source generatorsA C# compiler feature that runs during compilation and generates additional source files. They inspect your code via syntax/semantic models and emit new classes without reflection or runtime overhead. run during compilation, inspect your Product class, and automatically emit a complete Builder class. No runtime reflection, no performance cost, no maintenance burden.

[GenerateBuilder] record Order(...) Your code (you write this) Roslyn Source Generator Reads properties at compile time Emits With*() + Build() IIncrementalGenerator Generated OrderBuilder WithCustomerId(), WithProduct() Auto-generated, zero maintenance
SourceGeneratedBuilder.cs
// You write this:
[GenerateBuilder]
public sealed record Order(
    string CustomerId,
    string Product,
    decimal Price,
    int Quantity = 1,
    string? CouponCode = null);

// Source generator emits (at compile time):
public partial class OrderBuilder
{
    private string _customerId = default!;
    private string _product = default!;
    private decimal _price;
    private int _quantity = 1;
    private string? _couponCode;

    public OrderBuilder WithCustomerId(string v) { _customerId = v; return this; }
    public OrderBuilder WithProduct(string v) { _product = v; return this; }
    public OrderBuilder WithPrice(decimal v) { _price = v; return this; }
    public OrderBuilder WithQuantity(int v) { _quantity = v; return this; }
    public OrderBuilder WithCouponCode(string? v) { _couponCode = v; return this; }

    public Order Build() => new(_customerId, _product, _price, _quantity, _couponCode);
}

How it works: The generator's IIncrementalGenerator receives the syntax tree, finds types marked with [GenerateBuilder], reads their properties/constructor params, and emits With*() methods + Build(). Default parameter values become field initializers.

Limitations: Source generators can't add validation logic automatically (they don't know your business rules). You'd use a partial method hook: partial void Validate(Order order); that developers implement manually.

Great Answer Bonus "Libraries like AutoCtor, Mapperly, and the Dunet source generator show this pattern in production. For Builders specifically, the key insight is that the boilerplate (With methods + Build) is perfectly mechanical — ideal for generation — while the validation is domain-specific and must stay manual."
Section 18

Practice Exercises

Exercise 1: Meal Builder Easy

Create a MealBuilder that constructs a Meal with: main course (required), side dish (optional), drink (optional), dessert (optional), and a size (Small/Medium/Large, default Medium). The Meal must be immutable. Build() should throw if no main course is set. Add a TotalPrice computed property on Meal that sums item prices.

1. Create a Meal record with internal constructor.
2. Builder methods: WithMain(), WithSide(), WithDrink(), WithDessert(), WithSize().
3. Use nullable fields for optional items.
4. Build() validates main course is not null.

MealBuilder.cs
public enum MealSize { Small, Medium, Large }

public sealed record MenuItem(string Name, decimal Price);

public sealed record Meal
{
    public required MenuItem Main { get; init; }
    public MenuItem? Side { get; init; }
    public MenuItem? Drink { get; init; }
    public MenuItem? Dessert { get; init; }
    public MealSize Size { get; init; } = MealSize.Medium;

    public decimal TotalPrice =>
        Main.Price
        + (Side?.Price ?? 0)
        + (Drink?.Price ?? 0)
        + (Dessert?.Price ?? 0);
}

public sealed class MealBuilder
{
    private MenuItem? _main;
    private MenuItem? _side;
    private MenuItem? _drink;
    private MenuItem? _dessert;
    private MealSize _size = MealSize.Medium;

    public MealBuilder WithMain(string name, decimal price)
    { _main = new(name, price); return this; }

    public MealBuilder WithSide(string name, decimal price)
    { _side = new(name, price); return this; }

    public MealBuilder WithDrink(string name, decimal price)
    { _drink = new(name, price); return this; }

    public MealBuilder WithDessert(string name, decimal price)
    { _dessert = new(name, price); return this; }

    public MealBuilder WithSize(MealSize size)
    { _size = size; return this; }

    public Meal Build()
    {
        if (_main is null)
            throw new InvalidOperationException("Main course is required");

        return new Meal
        {
            Main = _main,
            Side = _side,
            Drink = _drink,
            Dessert = _dessert,
            Size = _size
        };
    }
}

// Usage
var meal = new MealBuilder()
    .WithMain("Burger", 9.99m)
    .WithSide("Fries", 3.99m)
    .WithDrink("Cola", 1.99m)
    .WithSize(MealSize.Large)
    .Build();

Console.WriteLine($"Total: ${meal.TotalPrice}"); // $15.97
Exercise 2: HTML Document Builder Medium

Build an HtmlDocumentBuilder that constructs a valid HTML page. Required: title. Optional: meta tags, CSS links, inline styles, body content (paragraphs, headings, lists), scripts. Build() returns a complete HTML string with proper nesting. Validate that title is set and body has at least one element. Bonus: add a Minify() option.

1. Use StringBuilder internally for efficient string assembly.
2. Separate methods for head elements vs body elements.
3. AddHeading(level, text), AddParagraph(text), AddList(items).
4. Build() assembles the full HTML skeleton and validates.

HtmlDocumentBuilder.cs
using System.Net;

public sealed class HtmlDocumentBuilder
{
    private string? _title;
    private readonly List<string> _metaTags = [];
    private readonly List<string> _cssLinks = [];
    private readonly List<string> _bodyElements = [];
    private readonly List<string> _scripts = [];
    private bool _minify;

    // Helper: always HTML-encode user content to prevent XSS
    private static string Enc(string s) => WebUtility.HtmlEncode(s);

    public HtmlDocumentBuilder Title(string title)
    { _title = title; return this; }

    public HtmlDocumentBuilder AddMeta(string name, string content)
    { _metaTags.Add($"<meta name=\"{Enc(name)}\" content=\"{Enc(content)}\">"); return this; }

    public HtmlDocumentBuilder AddCss(string href)
    { _cssLinks.Add($"<link rel=\"stylesheet\" href=\"{Enc(href)}\">"); return this; }

    public HtmlDocumentBuilder AddHeading(int level, string text)
    { _bodyElements.Add($"<h{level}>{Enc(text)}</h{level}>"); return this; }

    public HtmlDocumentBuilder AddParagraph(string text)
    { _bodyElements.Add($"<p>{Enc(text)}</p>"); return this; }

    public HtmlDocumentBuilder AddList(params string[] items)
    {
        var li = string.Join("", items.Select(i => $"<li>{Enc(i)}</li>"));
        _bodyElements.Add($"<ul>{li}</ul>");
        return this;
    }

    public HtmlDocumentBuilder AddScript(string src)
    { _scripts.Add($"<script src=\"{Enc(src)}\"></script>"); return this; }

    public HtmlDocumentBuilder Minify(bool minify = true)
    { _minify = minify; return this; }

    public string Build()
    {
        if (string.IsNullOrWhiteSpace(_title))
            throw new InvalidOperationException("Title is required");
        if (_bodyElements.Count == 0)
            throw new InvalidOperationException("Body needs at least one element");

        var nl = _minify ? "" : "\n";
        var indent = _minify ? "" : "  ";

        var sb = new StringBuilder();
        sb.Append($"<!DOCTYPE html>{nl}");
        sb.Append($"<html lang=\"en\">{nl}");
        sb.Append($"<head>{nl}");
        sb.Append($"{indent}<title>{Enc(_title)}</title>{nl}");
        foreach (var meta in _metaTags)
            sb.Append($"{indent}{meta}{nl}");
        foreach (var css in _cssLinks)
            sb.Append($"{indent}{css}{nl}");
        sb.Append($"</head>{nl}");
        sb.Append($"<body>{nl}");
        foreach (var el in _bodyElements)
            sb.Append($"{indent}{el}{nl}");
        foreach (var script in _scripts)
            sb.Append($"{indent}{script}{nl}");
        sb.Append($"</body>{nl}");
        sb.Append("</html>");

        return sb.ToString();
    }
}

// Usage
var html = new HtmlDocumentBuilder()
    .Title("My Page")
    .AddMeta("description", "A test page")
    .AddCss("styles.css")
    .AddHeading(1, "Hello World")
    .AddParagraph("Welcome to my page.")
    .AddList("Item 1", "Item 2", "Item 3")
    .AddScript("app.js")
    .Build();
Exercise 3: Step Builder for HTTP Pipeline Hard

Create a Step Builder for an HTTP request pipeline. Required steps (in order): 1. Set base URL → 2. Set HTTP method → 3. Set path. Optional steps (any order after step 3): headers, query params, body, timeout. Each step must return a different interface so the compiler enforces the order. Build() returns an immutable HttpPipelineRequest. Test that skipping step 2 won't compile.

1. Interfaces: IBaseUrlStep → IMethodStep → IPathStep → IOptionalStep.
2. IOptionalStep has WithHeader(), WithQuery(), WithBody(), WithTimeout(), and Build().
3. One class implements all 4 interfaces — private constructor, static Create() entry point.
4. Product: sealed record HttpPipelineRequest(...).

HttpPipelineBuilder.cs
// Product
public sealed record HttpPipelineRequest(
    string BaseUrl, string Method, string Path,
    IReadOnlyDictionary<string, string> Headers,
    IReadOnlyDictionary<string, string> QueryParams,
    string? Body, TimeSpan Timeout);

// Step interfaces
public interface IBaseUrlStep { IMethodStep BaseUrl(string url); }
public interface IMethodStep { IPathStep Method(string method); }
public interface IPathStep { IOptionalStep Path(string path); }
public interface IOptionalStep
{
    IOptionalStep WithHeader(string key, string value);
    IOptionalStep WithQuery(string key, string value);
    IOptionalStep WithBody(string body);
    IOptionalStep WithTimeout(TimeSpan timeout);
    HttpPipelineRequest Build();
}

// Implementation
public sealed class HttpPipelineBuilder :
    IBaseUrlStep, IMethodStep, IPathStep, IOptionalStep
{
    private string _baseUrl = "";
    private string _method = "";
    private string _path = "";
    private readonly Dictionary<string, string> _headers = new();
    private readonly Dictionary<string, string> _query = new();
    private string? _body;
    private TimeSpan _timeout = TimeSpan.FromSeconds(30);

    private HttpPipelineBuilder() { }
    public static IBaseUrlStep Create() => new HttpPipelineBuilder();

    public IMethodStep BaseUrl(string url) { _baseUrl = url; return this; }
    public IPathStep Method(string method) { _method = method; return this; }
    public IOptionalStep Path(string path) { _path = path; return this; }

    public IOptionalStep WithHeader(string k, string v)
    { _headers[k] = v; return this; }
    public IOptionalStep WithQuery(string k, string v)
    { _query[k] = v; return this; }
    public IOptionalStep WithBody(string body)
    { _body = body; return this; }
    public IOptionalStep WithTimeout(TimeSpan t)
    { _timeout = t; return this; }

    public HttpPipelineRequest Build() => new(
        _baseUrl, _method, _path,
        new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(_headers)),
        new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(_query)),
        _body, _timeout);
}

// Usage — compiler enforces order
var req = HttpPipelineBuilder.Create()
    .BaseUrl("https://api.example.com")    // Must be first
    .Method("POST")                         // Must be second
    .Path("/users")                         // Must be third
    .WithHeader("Auth", "Bearer xyz")       // Optional, any order
    .WithBody("{\"name\":\"Alice\"}")
    .WithTimeout(TimeSpan.FromSeconds(10))
    .Build();

// āœ— Won't compile — skipping steps:
// HttpPipelineBuilder.Create().Method("GET") // IBaseUrlStep has no Method!
Section 19

Cheat Sheet

Classic GoF Builder
interface IBuilder
{
  IBuilder StepA();
  IBuilder StepB();
  Product Build();
}

class ConcreteBuilder : IBuilder
{
  private state...
  StepA() { ... return this; }
  StepB() { ... return this; }
  Build() { validate → new Product(); }
}

class Director
{
  Construct(IBuilder b)
  {
    b.StepA().StepB().Build();
  }
}
Fluent Builder (Modern .NET)
class FooBuilder
{
  private string? _name;
  private int _count = 1;

  WithName(s) { _name=s; return this; }
  WithCount(n) { _count=n; return this; }

  Foo Build()
  {
    if (_name is null) throw ...;
    return new Foo(_name, _count);
  }
}

// Usage:
new FooBuilder()
  .WithName("bar")
  .WithCount(5)
  .Build();
Decision Rules
Parameters?
ā”œā”€ 1-3 → Constructor āœ“
ā”œā”€ 4+ all required → Constructor
ā”œā”€ 4+ with optionals → Builder āœ“
└─ Cross-field rules? → Builder āœ“

Need immutability? → Builder āœ“
Need validation? → Builder āœ“
Simple DTO? → Object Initializer āœ“
One field change? → record with āœ“
Step order matters? → Step Builder āœ“

.NET equivalents:
  WebApplicationBuilder → app startup
  StringBuilder → string assembly
  IHostBuilder → generic host
  EntityTypeBuilder → EF Core config
Section 20

Deep Dive: Fluent API Design

Expression Builders in EF Core

EF Core's Fluent APIEntity Framework Core's builder-based configuration API that lets you define entity mappings, relationships, and constraints using method chaining instead of data annotations. is one of the most sophisticated Builder implementations in .NET. Each EntityTypeBuilder returns progressively more specific builders as you drill into navigation propertiesEF Core properties that represent relationships between entities — e.g., Order.Customer is a navigation property pointing to the Customer entity. and property configurations.

EfCoreFluentApi.cs
// EF Core Fluent API — Builder pattern in action
modelBuilder.Entity<Order>(entity =>
{
    // EntityTypeBuilder — configures the entity
    entity.ToTable("Orders");
    entity.HasKey(o => o.Id);

    // PropertyBuilder — drills into a specific property
    entity.Property(o => o.Total)
        .HasColumnType("decimal(18,2)")
        .IsRequired();

    // ReferenceNavigationBuilder → ReferenceCollectionBuilder
    entity.HasOne(o => o.Customer)
        .WithMany(c => c.Orders)
        .HasForeignKey(o => o.CustomerId)
        .OnDelete(DeleteBehavior.Cascade);

    // IndexBuilder — configures an index
    entity.HasIndex(o => o.OrderDate)
        .HasDatabaseName("IX_Order_Date")
        .IsDescending();
});

// Each method returns a MORE SPECIFIC builder type:
// Entity<T>() → EntityTypeBuilder<T>
// .Property() → PropertyBuilder<T>
// .HasOne() → ReferenceNavigationBuilder
// .WithMany() → ReferenceCollectionBuilder
// This is Step Builder applied to ORM configuration!

.NET 6+ minimal APIs use an endpoint builder that chains configuration methods. Each method returns an IEndpointConventionBuilder, allowing progressive refinement of the endpoint's behavior.

MinimalApi.cs
// Minimal API — endpoint builder chain
app.MapGet("/api/orders/{id}", async (int id, AppDbContext db) =>
    await db.Orders.FindAsync(id) is Order order
        ? Results.Ok(order)
        : Results.NotFound())
    .WithName("GetOrder")              // Name for link generation
    .WithTags("Orders")                // OpenAPI grouping
    .RequireAuthorization("AdminOnly") // Auth policy
    .Produces<Order>(200)              // OpenAPI response type
    .ProducesProblem(404)              // OpenAPI error response
    .CacheOutput(p => p.Expire(TimeSpan.FromMinutes(5)))
    .WithOpenApi();                    // Include in Swagger

// Each method configures one aspect of the endpoint.
// This is fluent Builder applied to HTTP endpoint definition.

An advanced variant where each build step returns a new builder instance instead of mutating this. This makes the builder itself immutableAn object whose state never changes after creation. Immutable objects are inherently thread-safe and can be shared freely without defensive copies. and shareable — inspired by functional programmingA programming paradigm that treats computation as evaluation of pure functions, avoiding mutable state and side effects. Immutable builders are a functional-style adaptation. principles. Useful for creating builder "templates" that can branch into different configurations.

ImmutableBuilder.cs
// Each step returns a NEW builder — original is unchanged
public sealed record RequestBuilder(
    string Url = "",
    string Method = "GET",
    ImmutableDictionary<string, string>? Headers = null)
{
    // Coalesce null → Empty in the parameterless ctor
    public RequestBuilder() : this("", "GET",
        ImmutableDictionary<string, string>.Empty) { }

    // Ensure Headers is never null after construction
    public ImmutableDictionary<string, string> Headers
    { get; init; } = Headers
        ?? ImmutableDictionary<string, string>.Empty;

    public RequestBuilder WithUrl(string url)
        => this with { Url = url };

    public RequestBuilder WithMethod(string method)
        => this with { Method = method };

    public RequestBuilder WithHeader(string key, string value)
        => this with { Headers = Headers.Add(key, value) };

    public HttpRequest Build()
    {
        if (string.IsNullOrEmpty(Url))
            throw new InvalidOperationException("URL required");
        return new HttpRequest(Url, Method, Headers); // simplified HttpRequest for demo
    }
}

// Template + branching:
var template = new RequestBuilder()
    .WithUrl("https://api.example.com")
    .WithHeader("Accept", "application/json");

// Branch from the same template — template is NOT modified
var getReq = template.WithMethod("GET").Build();
var postReq = template.WithMethod("POST")
    .WithHeader("Content-Type", "application/json")
    .Build();
Section 21

Mini-Project

Build a Configuration File Generator that produces config files in multiple formats (JSON, YAML, XML) from the same build process. The generator uses the Builder pattern with a Director that defines common configuration templates.

Attempt 1: Naive (String Concatenation)

NaiveConfigGenerator.cs
// āœ— Attempt 1: Hard-coded format, no reuse, no validation
public class ConfigGenerator
{
    public string GenerateJson(string appName, int port,
        string dbHost, string dbName, bool ssl, string logLevel)
    {
        return $@"{{
  ""app"": ""{appName}"",
  ""port"": {port},
  ""database"": {{
    ""host"": ""{dbHost}"",
    ""name"": ""{dbName}"",
    ""ssl"": {ssl.ToString().ToLower()}
  }},
  ""logging"": {{
    ""level"": ""{logLevel}""
  }}
}}";
    }

    // āœ— Need a whole new method for YAML, XML...
    // āœ— 6 parameters — will grow to 20+ for real configs
    // āœ— No validation — empty appName? Port -1? No error.
    // āœ— String interpolation with JSON is error-prone (escaping)
}
What's Wrong

Telescoping parameters, no validation, format locked to JSON, string interpolation is fragile. Adding YAML means copy-pasting the entire method.

BasicBuilder.cs
// ~ Better: Fluent builder, but format-specific
public sealed class JsonConfigBuilder
{
    private string? _appName;
    private int _port = 8080;
    private string? _dbHost;
    private string? _dbName;
    private bool _ssl;
    private string _logLevel = "Information";

    public JsonConfigBuilder AppName(string name)
    { _appName = name; return this; }
    public JsonConfigBuilder Port(int port)
    { _port = port; return this; }
    public JsonConfigBuilder Database(string host, string name)
    { _dbHost = host; _dbName = name; return this; }
    public JsonConfigBuilder Ssl(bool ssl = true)
    { _ssl = ssl; return this; }
    public JsonConfigBuilder LogLevel(string level)
    { _logLevel = level; return this; }

    public string Build()
    {
        if (string.IsNullOrWhiteSpace(_appName))
            throw new InvalidOperationException("App name required");
        // āœ“ Has validation
        // āœ— But locked to JSON format
        // āœ— Need YamlConfigBuilder, XmlConfigBuilder separately
        return JsonSerializer.Serialize(new { ... }, options);
    }
}
Better But...

Fluent API is nice, but we need a separate builder class for each format. The configuration steps (AppName, Port, Database) are identical — only the output format differs. This is exactly what Builder + Director solves.

ConfigModel.cs
// Product — immutable configuration model
public sealed record AppConfig
{
    public required string AppName { get; init; }
    public int Port { get; init; } = 8080;
    public DatabaseConfig? Database { get; init; }
    public LoggingConfig Logging { get; init; } = new();
    public IReadOnlyList<string> CorsOrigins { get; init; } = [];
    public IReadOnlyDictionary<string, string> FeatureFlags { get; init; }
        = new Dictionary<string, string>();
}

public sealed record DatabaseConfig(
    string Host, string Name, int Port = 5432, bool Ssl = false);

public sealed record LoggingConfig(
    string Level = "Information", string? FilePath = null);
IConfigBuilder.cs
// Builder interface — format-agnostic configuration steps
public interface IConfigFileBuilder
{
    IConfigFileBuilder SetAppName(string name);
    IConfigFileBuilder SetPort(int port);
    IConfigFileBuilder SetDatabase(string host, string name,
        int port = 5432, bool ssl = false);
    IConfigFileBuilder SetLogging(string level, string? filePath = null);
    IConfigFileBuilder AddCorsOrigin(string origin);
    IConfigFileBuilder AddFeatureFlag(string flag, string value);
    string Build();  // Returns formatted config string
    void Reset();
}
JsonConfigBuilder.cs
public sealed class JsonConfigBuilder : IConfigFileBuilder
{
    private string? _appName;
    private int _port = 8080;
    private DatabaseConfig? _db;
    private LoggingConfig _logging = new();
    private readonly List<string> _cors = [];
    private readonly Dictionary<string, string> _flags = new();

    public IConfigFileBuilder SetAppName(string name)
    { _appName = name; return this; }

    public IConfigFileBuilder SetPort(int port)
    { _port = port; return this; }

    public IConfigFileBuilder SetDatabase(string host, string name,
        int port = 5432, bool ssl = false)
    { _db = new(host, name, port, ssl); return this; }

    public IConfigFileBuilder SetLogging(string level,
        string? filePath = null)
    { _logging = new(level, filePath); return this; }

    public IConfigFileBuilder AddCorsOrigin(string origin)
    { _cors.Add(origin); return this; }

    public IConfigFileBuilder AddFeatureFlag(string flag, string value)
    { _flags[flag] = value; return this; }

    public string Build()
    {
        if (string.IsNullOrWhiteSpace(_appName))
            throw new InvalidOperationException("App name required");

        var config = new AppConfig
        {
            AppName = _appName,
            Port = _port,
            Database = _db,
            Logging = _logging,
            CorsOrigins = [.. _cors],
            FeatureFlags = new Dictionary<string, string>(_flags)
        };

        return JsonSerializer.Serialize(config, new JsonSerializerOptions
        {
            WriteIndented = true,
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase
        });
    }

    public void Reset()
    {
        _appName = null; _port = 8080; _db = null;
        _logging = new(); _cors.Clear(); _flags.Clear();
    }
}

// YamlConfigBuilder, XmlConfigBuilder follow same pattern
// with different Build() serialization logic
ConfigDirector.cs
// Director — predefined configuration templates
public sealed class ConfigDirector
{
    public string BuildDevelopmentConfig(IConfigFileBuilder builder)
    {
        builder.Reset();
        return builder
            .SetAppName("MyApp")
            .SetPort(5000)
            .SetDatabase("localhost", "myapp_dev")
            .SetLogging("Debug", "logs/dev.log")
            .AddCorsOrigin("http://localhost:3000")
            .AddFeatureFlag("dark-mode", "true")
            .AddFeatureFlag("beta-api", "true")
            .Build();
    }

    public string BuildProductionConfig(IConfigFileBuilder builder)
    {
        builder.Reset();
        return builder
            .SetAppName("MyApp")
            .SetPort(443)
            .SetDatabase("db.prod.internal", "myapp", ssl: true)
            .SetLogging("Warning")
            .AddCorsOrigin("https://myapp.com")
            .AddCorsOrigin("https://www.myapp.com")
            .AddFeatureFlag("dark-mode", "true")
            .AddFeatureFlag("beta-api", "false")
            .Build();
    }
}

// Usage — same templates, different output formats
var director = new ConfigDirector();

var devJson = director.BuildDevelopmentConfig(new JsonConfigBuilder());
var devYaml = director.BuildDevelopmentConfig(new YamlConfigBuilder());
var prodJson = director.BuildProductionConfig(new JsonConfigBuilder());

Console.WriteLine("=== Dev (JSON) ===");
Console.WriteLine(devJson);
// {
//   "appName": "MyApp",
//   "port": 5000,
//   "database": { "host": "localhost", ... },
//   ...
// }
Production Ready

Builder + Director: Same configuration templates produce JSON, YAML, or XML. Adding a new format = one new builder class. Adding a new template = one new Director method. Validation in Build(). Immutable product. No string concatenationBuilding strings by joining fragments with + operator. Each concatenation allocates a new string — O(n²) for n operations. Builder with StringBuilder is O(n).. Full OCPOpen/Closed Principle — the system is open for extension (new builders, new templates) but closed for modification (existing code doesn't change). compliance.

Section 22

Migration Guide

How to refactor a telescoping constructorAnti-pattern where a class has multiple constructors with increasing numbers of parameters, often with many of them null or default. into a Builder pattern — step by step.

Step 1: Identify the Candidate

Look for constructors with 4+ parameters, especially when many are optional (nullable, have defaults, or callers pass null). Count how many constructor overloads exist — if it's 3+, Builder will help. Also look for new Foo(a, b, null, null, true, null, 5) — positional nulls are a dead giveaway.

Create a new FooBuilder class with:
(a) Private fields matching the product's properties, with sensible defaults.
(b) One fluent method per property: WithName(string n) { _name = n; return this; }
(c) A Build() method that validates and returns a new Product.

Make the product immutable:
(a) Change public setters to get-only or init.
(b) Make the constructor internal (or use a record with an internal constructor).
(c) Wrap collections in IReadOnlyList<T>.
(d) Consider using sealed record for the product — you get immutability, equality, and with-expressions free.

Don't update all call sites at once. Keep the old constructor temporarily (mark it [Obsolete]) and add the builder alongside it. Migrate callers one by one to the builder. Once all callers are migrated, delete the old constructors.

GradualMigration.cs
public class Order
{
    // Phase 1: Mark old constructor as obsolete
    [Obsolete("Use OrderBuilder instead")]
    public Order(string customerId, string productId,
        int quantity, string? coupon, bool gift) { ... }

    // Phase 2: Internal constructor for builder
    internal Order(/* all fields */) { ... }
}

// Phase 3: Migrate callers
// Before: var o = new Order("C1", "P1", 2, null, true);
// After:  var o = new OrderBuilder().ForCustomer("C1")...Build();

// Phase 4: Delete [Obsolete] constructor
Section 23

Code Review Checklist

#CheckWhy
1Build() validates all required fieldsPrevents invalid products from being created
2Product is immutable (no public setters)Builder guarantees are meaningless if product is mutable
3Product constructor is internalForces creation through the builder
4Collections are IReadOnlyList<T> / IReadOnlyDictionaryPrevents mutation after Build()
5Defensive copies of mutable collectionsBuilder's list can't corrupt the product
6Builder is not registered as Singleton/Scoped in DIBuilders are stateful — shared state causes bugs
7Fluent methods return this (not a new builder)Unless using immutable builder pattern
8Build() returns a new Product, not thisBuilder ≠ Product — keep types separate
9No side effects in build stepsSteps should only accumulate configuration
10Builder is worth the complexity (4+ optional params)Don't over-engineer — simple objects don't need builders
11Cross-field validation in Build()E.g., "express shipping requires an address"
12Error messages are clear and actionableCollect all errors, don't fail on the first one