Mert Özen Further With Every Line
Software Design Design Patterns — #05

Builder Pattern: Fluent Builders, WebApplicationBuilder and Where Object Initializers and required Fall Short

Mert Özen Nov 7, 2026 16 min 5 views
Builder Pattern: Fluent Builders, WebApplicationBuilder and Where Object Initializers and required Fall Short

required, init and object initializers make a many-parameter constructor readable, but they do not solve cross-field rules, step order or an immutable result that accumulates piece by piece; Builder enforces those rules at a single Build() moment. How with skips a record's rule, the GoF Builder with a director versus the fluent builder, UriBuilder, connection string builders, the ImmutableArray builder, HostApplicationBuilder and the step builder, with real output.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First

The Bookstore represents order confirmation emails with a record. The rule is simple: an email must have at least one body, plain text or HTML (HyperText Markup Language). We wrote the rule in a property initializer; anyone trying to create an email without a body gets an exception. Later someone builds a draft feature: they copy an existing email with with and clear its body. Which of the three lines throws?

using Bookstore.Notifications;

OrderConfirmation mail = new("ada@example.com", "Order 1042 confirmed", TextBody: "Thank you, Ada!", HtmlBody: null);
Console.WriteLine($"1: {Describe(mail)}");

try
{
    OrderConfirmation empty = new("alan@example.com", "Order 1043 confirmed", TextBody: null, HtmlBody: null);
    Console.WriteLine($"2: {Describe(empty)}");
}
catch (ArgumentException ex)
{
    Console.WriteLine($"2: {ex.Message}");
}

OrderConfirmation draft = mail with { TextBody = null };
Console.WriteLine($"3: {Describe(draft)}");

static string Describe(OrderConfirmation m) =>
    $"to={m.To}, text={m.TextBody ?? "(none)"}, html={m.HtmlBody ?? "(none)"}";

namespace Bookstore.Notifications
{
    public sealed record OrderConfirmation(string To, string Subject, string? TextBody, string? HtmlBody)
    {
        // Rule: there must be at least one body
        public string? TextBody { get; init; } = TextBody is null && HtmlBody is null
            ? throw new ArgumentException("A message needs a text or an HTML body.")
            : TextBody;
    }
}

with Skips the Rule

Output:

1: to=ada@example.com, text=Thank you, Ada!, html=(none)
2: A message needs a text or an HTML body.
3: to=ada@example.com, text=(none), html=(none)

The second line was rejected as expected, but the with on the third line produced exactly the object the rule forbids: no text and no HTML body. The reason is how with works. It does not run the primary constructor or the property initializers; it copies the fields through the copy constructor the compiler generates for the record, then calls only the init setters you wrote inside the braces. The rule lives in an initializer, so it never ran for the copy. In the record post of the C# OOP (Object-Oriented Programming) series we saw that with works through a hidden <Clone>$ method and the copy constructor; this is the other side of that.

This small example shows why the Builder pattern still exists. Recent C# versions made object creation much more readable: named arguments, object initializers, init, required, records and with. They largely solved "make a constructor with many parameters readable". What they did not solve is the creation process itself: rules that involve several fields at once, steps that must happen in a certain order, and a result that accumulates step by step and becomes immutable at the end. That gap is where Builder belongs.

Telescoping Constructors and C#'s Answer: required and init

The classic problem: a sales report query has two required and four optional parameters. Write a constructor for each combination and you get constructors that call each other and keep getting longer; Joshua Bloch (author of Effective Java) calls this a telescoping constructor. Someone reading the call to the longest constructor cannot tell which int is the page size and which is the minimum quantity:

using Bookstore.Reporting;

// Telescoping constructor: which int is the page size, which the minimum quantity?
SalesQuery old = new(new DateOnly(2026, 10, 1), new DateOnly(2026, 10, 31), "TR", 50, 3, true);

// required + init: readable, and the compiler catches a missing field
SalesQuery query = new()
{
    From = new DateOnly(2026, 10, 31),
    To = new DateOnly(2026, 10, 1),
    PageSize = 0
};
Console.WriteLine($"{query.From:yyyy-MM-dd}..{query.To:yyyy-MM-dd}, page size {query.PageSize}");

namespace Bookstore.Reporting
{
    public sealed class SalesQuery
    {
        public required DateOnly From { get; init; }
        public required DateOnly To { get; init; }
        public string? Country { get; init; }
        public int PageSize { get; init; } = 50;
        public int MinQuantity { get; init; }
        public bool IncludeDigital { get; init; } = true;

        public SalesQuery() { }

        [System.Diagnostics.CodeAnalysis.SetsRequiredMembers]
        public SalesQuery(DateOnly from, DateOnly to, string? country, int pageSize, int minQuantity, bool includeDigital) =>
            (From, To, Country, PageSize, MinQuantity, IncludeDigital) = (from, to, country, pageSize, minQuantity, includeDigital);
    }
}
2026-10-31..2026-10-01, page size 0

Object initializers and required solved the readability problem: every value has its name next to it. Forgetting a required field is now a compile error too. Delete the To = ... line from the same code and the build says:

Program.cs(7,20): error CS9035: Required member 'SalesQuery.To' must be set in the object initializer or attribute constructor.

But look at the output: a query running from 31 October to 1 October with a page size of zero was created without complaint. required guarantees that a field is assigned, not that its value makes sense. No single property setter can enforce a rule involving several fields, such as "the end cannot be before the start", because setters run in no particular order and when From is assigned, To is not yet known. The only correct moment to enforce the rule is when all values are known: the end of creation. A builder's Build() method is exactly that moment.

The GoF Builder: Same Steps, Different Representations

Say "builder" and most of us think of a .With...().Build() chain, but the Builder of the GoF (Gang of Four: Gamma, Helm, Johnson and Vlissides, authors of the 1994 Design Patterns book) solves a different problem. Its intent: separate the construction of a complex object from its representation, so that the same construction process can create different representations. The book's example is an RTF (Rich Text Format) reader: the code that reads the document (the director) stays the same, while different builders turn each piece it reads into plain text, TeX or an on-screen text widget. In the Bookstore the same idea is an order receipt:

using System.Globalization;
using System.Net;
using System.Text;
using Bookstore.Orders;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Order order = new(1042, "Ada", [new("Dune", 2, 12.99m), new("Emma & Co.", 1, 7.50m)]);

PlainTextReceiptBuilder text = new();
ReceiptDirector.Construct(order, text);
Console.WriteLine(text.GetResult());

HtmlReceiptBuilder html = new();
ReceiptDirector.Construct(order, html);
Console.WriteLine(html.GetResult());

namespace Bookstore.Orders
{
    public record OrderLine(string Title, int Quantity, decimal UnitPrice);
    public record Order(int Id, string Customer, IReadOnlyList<OrderLine> Lines);

    // Builder: the steps; each concrete builder produces its own representation
    public interface IReceiptBuilder
    {
        void AddHeader(int orderId, string customer);
        void AddLine(string title, int quantity, decimal amount);
        void AddTotal(decimal total);
    }

    // Director: knows the order of the steps, not the representation
    public static class ReceiptDirector
    {
        public static void Construct(Order order, IReceiptBuilder builder)
        {
            builder.AddHeader(order.Id, order.Customer);
            foreach (OrderLine line in order.Lines)
                builder.AddLine(line.Title, line.Quantity, line.Quantity * line.UnitPrice);
            builder.AddTotal(order.Lines.Sum(l => l.Quantity * l.UnitPrice));
        }
    }

    public sealed class PlainTextReceiptBuilder : IReceiptBuilder
    {
        private readonly StringBuilder _text = new();

        public void AddHeader(int orderId, string customer) => _text.AppendLine($"Receipt #{orderId} for {customer}");
        public void AddLine(string title, int quantity, decimal amount) => _text.AppendLine($"  {quantity} x {title,-12} {amount,8:0.00}");
        public void AddTotal(decimal total) => _text.Append($"  {"Total",-16} {total,8:0.00}");

        public string GetResult() => _text.ToString();
    }

    public sealed class HtmlReceiptBuilder : IReceiptBuilder
    {
        private readonly StringBuilder _html = new();

        public void AddHeader(int orderId, string customer) =>
            _html.Append($"<h1>Receipt #{orderId}</h1><p>{WebUtility.HtmlEncode(customer)}</p><ul>");
        public void AddLine(string title, int quantity, decimal amount) =>
            _html.Append($"<li>{quantity} x {WebUtility.HtmlEncode(title)}: {amount:0.00}</li>");
        public void AddTotal(decimal total) => _html.Append($"</ul><p>Total: {total:0.00}</p>");

        public string GetResult() => _html.ToString();
    }
}
Receipt #1042 for Ada
  2 x Dune            25.98
  1 x Emma & Co.       7.50
  Total               33.48
<h1>Receipt #1042</h1><p>Ada</p><ul><li>2 x Dune: 25.98</li><li>1 x Emma &amp; Co.: 7.50</li></ul><p>Total: 33.48</p>

ReceiptDirector knows which parts make up a receipt and in which order: header, lines, total. It knows nothing about format. PlainTextReceiptBuilder aligns columns, HtmlReceiptBuilder produces tags and escapes the & in "Emma & Co.". If a PDF receipt is needed tomorrow, a new builder gets written without touching the director.

A GoF detail worth noticing: GetResult() is not on the interface but on each concrete builder, and it can return different types. The GoF describes this as a deliberate choice; products can differ so much that a common supertype is meaningless. That is also what separates Builder from Abstract Factory: Abstract Factory returns a product family in one step, while Builder assembles a single product step by step and hands over the result at the end.

You do not see this form often in everyday .NET code, but it has a familiar relative: System.Text.Json's Utf8JsonWriter, or XmlWriter. The serializing code (in the director's role) calls steps such as WriteStartObject, WriteString and WriteEndObject; the writer decides which bytes the steps become.

The Fluent Builder: Invariants in Build()

What we call a "builder" in everyday code is the subject of Bloch's second item in Effective Java: assembling an immutable object with many optional parameters in a readable way. There is no director; the code calling the chain is the director. Let's build the Bookstore's email message with a builder this time:

using Bookstore.Notifications;

EmailMessage confirmation = EmailMessage.Builder()
    .From("orders@bookstore.example")
    .To("ada@example.com")
    .Subject("Order 1042 confirmed")
    .TextBody("Thank you, Ada!")
    .Attach("invoice-1042.pdf", sizeBytes: 182_000)
    .Build();
Console.WriteLine($"{confirmation.Subject} -> {string.Join(", ", confirmation.To)}, {confirmation.Attachments.Count} attachment(s)");

try
{
    EmailMessage.Builder()
        .From("orders@bookstore.example")
        .Subject("   ")
        .Attach("catalog.pdf", sizeBytes: 12_000_000)
        .Build();
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message);
}

namespace Bookstore.Notifications
{
    public sealed record Attachment(string FileName, long SizeBytes);

    public sealed class EmailMessage
    {
        public const long MaxTotalAttachmentBytes = 10 * 1024 * 1024;

        public string From { get; }
        public IReadOnlyList<string> To { get; }
        public string Subject { get; }
        public string? TextBody { get; }
        public string? HtmlBody { get; }
        public IReadOnlyList<Attachment> Attachments { get; }

        // The only way in goes through the builder; the constructor never sees an invalid object
        private EmailMessage(MessageBuilder b)
        {
            From = b.FromAddress!;
            To = [.. b.Recipients];
            Subject = b.SubjectText!;
            TextBody = b.Text;
            HtmlBody = b.Html;
            Attachments = [.. b.Files];
        }

        public static MessageBuilder Builder() => new();

        public sealed class MessageBuilder
        {
            internal string? FromAddress;
            internal readonly List<string> Recipients = [];
            internal string? SubjectText;
            internal string? Text;
            internal string? Html;
            internal readonly List<Attachment> Files = [];

            public MessageBuilder From(string address) { FromAddress = address; return this; }
            public MessageBuilder To(string address) { Recipients.Add(address); return this; }
            public MessageBuilder Subject(string subject) { SubjectText = subject; return this; }
            public MessageBuilder TextBody(string text) { Text = text; return this; }
            public MessageBuilder HtmlBody(string html) { Html = html; return this; }
            public MessageBuilder Attach(string fileName, long sizeBytes) { Files.Add(new(fileName, sizeBytes)); return this; }

            public EmailMessage Build()
            {
                // Collect every error at once so the caller does not fix them one by one
                List<string> errors = [];
                if (string.IsNullOrWhiteSpace(FromAddress)) errors.Add("sender is missing");
                if (Recipients.Count == 0) errors.Add("at least one recipient is required");
                if (string.IsNullOrWhiteSpace(SubjectText)) errors.Add("subject is empty");
                if (Text is null && Html is null) errors.Add("a text or HTML body is required");
                if (Files.Sum(f => f.SizeBytes) > MaxTotalAttachmentBytes) errors.Add("attachments exceed 10 MB");

                return errors.Count == 0
                    ? new EmailMessage(this)
                    : throw new InvalidOperationException("Invalid email: " + string.Join("; ", errors) + ".");
            }
        }
    }
}
Order 1042 confirmed -> ada@example.com, 1 attachment(s)
Invalid email: at least one recipient is required; subject is empty; a text or HTML body is required; attachments exceed 10 MB.

There are three design decisions. First, EmailMessage's constructor is private and takes the builder as its parameter; the only way to create an invalid message is closed. Second, all the rules are in Build() and checked together: four errors arrived in one message. You do the same when validating an API (Application Programming Interface) request: ASP.NET Core's model validation returns all errors in a single ValidationProblemDetails response, so the user does not have to discover them one by one. Third, the result is genuinely immutable: To and Attachments are copies of the builder's lists ([.. b.Recipients]). We will see why that copy matters in the pitfalls section.

This structure is very useful in test code as well. Instead of filling in ten fields for an order test, you take a builder full of sensible defaults and change only the field the test cares about; that technique is called a test data builder. The test code post of the Clean Code series covers it in detail; the difference from a production builder is that it offers valid defaults rather than relaxing the rules.

Builders in .NET: Accumulate, Then Freeze

The builders in .NET's own library share a behaviour: they accumulate in something mutable and turn the result into an immutable or consistent form in one step. StringBuilder is the best known; let's run a few more:

using System.Collections.Immutable;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

// UriBuilder: set the parts one by one, take the result once
UriBuilder uri = new("https", "api.bookstore.example") { Path = "v1/books", Query = "q=dune&page=2" };
Console.WriteLine(uri.Uri);

// Connection string builder: escapes values while composing
SqliteConnectionStringBuilder cs = new() { DataSource = "shop;Mode=Memory.db", Mode = SqliteOpenMode.ReadOnly };
Console.WriteLine(cs.ConnectionString);

// ImmutableArray<T>.Builder: mutable accumulation, immutable result
ImmutableArray<string>.Builder titles = ImmutableArray.CreateBuilder<string>(initialCapacity: 3);
titles.Add("Dune");
titles.Add("Emma");
try
{
    titles.MoveToImmutable();
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"MoveToImmutable: {ex.Message}");
}
ImmutableArray<string> drained = titles.DrainToImmutable();
Console.WriteLine($"DrainToImmutable: [{string.Join(", ", drained)}], builder count now {titles.Count}");

// HostApplicationBuilder: the service collection is locked after Build()
HostApplicationBuilder hostBuilder = Host.CreateApplicationBuilder();
hostBuilder.Services.AddSingleton(TimeProvider.System);
using IHost host = hostBuilder.Build();
try
{
    hostBuilder.Services.AddSingleton<object>(new object());
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"After Build: {ex.Message}");
}
https://api.bookstore.example/v1/books?q=dune&page=2
Data Source="shop;Mode=Memory.db";Mode=ReadOnly
MoveToImmutable: MoveToImmutable can only be performed when Count equals Capacity.
DrainToImmutable: [Dune, Emma], builder count now 0
After Build: The service collection cannot be modified because it is read-only.

UriBuilder takes the parts separately and joins them with the right separators. SqliteConnectionStringBuilder does something more important: it quoted the semicolon inside the file name. Had you composed the connection string with $"Data Source={fileName}", a file name supplied by a user could have injected a new setting such as Mode=Memory. Here the builder provides not just readability but safety.

ImmutableArray<T>.Builder shows the memory side of the pattern. MoveToImmutable takes over the builder's internal array without copying, but only if capacity equals the element count; otherwise it throws the exception in the output. DrainToImmutable, introduced in .NET 8, does not require that; it copies if needed and empties the builder. Both make the same promise: the result will never change again, and later changes to the builder will not affect it.

The last example is HostApplicationBuilder, sibling of the WebApplicationBuilder you know from ASP.NET Core. The Services, Configuration and Logging properties are the accumulation area; Build() builds the container and makes the service collection read-only. Trying to add a service afterwards gives a clear error, as in the output. The lock is not a restriction but a guarantee: if registrations could change after the container was built, it would become unclear which service was resolved from which registration.

The Step Builder: Building the Order into the Types

The fluent builder's weak spot is that a missing step is caught only when Build() is called, that is, at runtime. In some APIs the order and necessity of the steps are so clear that you can hand them to the compiler: each step returns the interface of the next one. This variant is called a step builder:

using Bookstore.Notifications;

EmailDraft draft = Email.From("orders@bookstore.example")
    .To("ada@example.com")
    .Subject("Your order has shipped")
    .Text("Dune is on its way.")
    .Cc("support@bookstore.example")
    .Build();

Console.WriteLine($"{draft.Subject} -> {draft.To} (cc: {string.Join(", ", draft.Cc)})");

namespace Bookstore.Notifications
{
    public sealed record EmailDraft(string From, string To, string Subject, string Body, IReadOnlyList<string> Cc);

    // Each step returns the next step's interface: the order is built into the types
    public interface INeedsRecipient { INeedsSubject To(string address); }
    public interface INeedsSubject { INeedsBody Subject(string subject); }
    public interface INeedsBody { IReadyToBuild Text(string body); }
    public interface IReadyToBuild
    {
        IReadyToBuild Cc(string address);
        EmailDraft Build();
    }

    public static class Email
    {
        public static INeedsRecipient From(string sender) => new Steps(sender);

        private sealed class Steps(string from) : INeedsRecipient, INeedsSubject, INeedsBody, IReadyToBuild
        {
            private string _to = "", _subject = "", _body = "";
            private readonly List<string> _cc = [];

            public INeedsSubject To(string address) { _to = address; return this; }
            public INeedsBody Subject(string subject) { _subject = subject; return this; }
            public IReadyToBuild Text(string body) { _body = body; return this; }
            public IReadyToBuild Cc(string address) { _cc.Add(address); return this; }
            public EmailDraft Build() => new(from, _to, _subject, _body, [.. _cc]);
        }
    }
}
Your order has shipped -> ada@example.com (cc: support@bookstore.example)

A call that skips the subject and body does not compile:

// Subject and body skipped
EmailDraft draft = Email.From("orders@bookstore.example")
    .To("ada@example.com")
    .Build();
Program.cs(7,6): error CS1061: 'INeedsSubject' does not contain a definition for 'Build' and no accessible extension method 'Build' accepting a first argument of type 'INeedsSubject' could be found (are you missing a using directive or an assembly reference?)

To(...) returns an INeedsSubject, and that interface has no Build. Because IntelliSense only suggests the next step, the API explains itself. The price is equally clear: four interfaces for four steps, extra machinery for optional steps, and a redesign of the whole chain when you want to reorder the steps. Save the step builder for a library API many teams will use and where misuse is expensive; for a message inside an application, the check in Build() is enough.

Common Pitfalls

Handing the builder's own list to the result. Picture code that reuses an invoice builder in a loop:

using System.Globalization;
using Bookstore.Billing;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

InvoiceBuilder builder = new InvoiceBuilder().ForOrder(1042).AddLine("Dune", 25.98m);
Invoice first = builder.Build();

// A second invoice from the same builder
builder.ForOrder(1043).AddLine("Emma", 7.50m);
Invoice second = builder.Build();

Console.WriteLine($"#{first.OrderId}: {first.Lines.Count} lines, total {first.Total:0.00}");
Console.WriteLine($"#{second.OrderId}: {second.Lines.Count} lines, total {second.Total:0.00}");

namespace Bookstore.Billing
{
    public sealed record InvoiceLine(string Title, decimal Amount);

    public sealed class Invoice(int orderId, IReadOnlyList<InvoiceLine> lines)
    {
        public int OrderId { get; } = orderId;
        public IReadOnlyList<InvoiceLine> Lines { get; } = lines;
        public decimal Total => Lines.Sum(l => l.Amount);
    }

    public sealed class InvoiceBuilder
    {
        private int _orderId;
        private readonly List<InvoiceLine> _lines = [];

        public InvoiceBuilder ForOrder(int orderId) { _orderId = orderId; return this; }
        public InvoiceBuilder AddLine(string title, decimal amount) { _lines.Add(new(title, amount)); return this; }

        // Bug: hands its own list to the result
        public Invoice Build() => new(_orderId, _lines);
    }
}
#1042: 2 lines, total 33.48
#1043: 2 lines, total 33.48

Invoice 1042's lines changed when a line was added for the second invoice, because Invoice holds the builder's _lines list itself. The IReadOnlyList<T> type looks "immutable" to the reader, but it is only a view; when the list underneath changes, so does the view. The fix is to copy in Build() ([.. _lines]) and either document the builder as single-use or have it reset itself after each Build(). The second invoice also carries the Dune line because the builder was not reset; that is the other face of the same trap.

Writing a builder for a one-line object. Opening a BookBuilder for a record with three properties reproduces, with twice the code, the readability an object initializer already gives you. A builder's justification should be rules, order or accumulation, not merely "there are many properties".

Putting the rule only in the builder and leaving the type open. If EmailMessage has a public constructor or public setters, the checks in the builder are only a suggestion, and a back door like the with at the start of this post can always be found. The type itself must protect the invariant; the builder is just the readable road to it.

Assuming that every method in a fluent API returns, or does not return, a new builder. The builders in this post return this, so they are mutable. In immutable fluent APIs such as LINQ (Language-Integrated Query) or ImmutableList<T>, each call returns a new object, and if you do not assign the return value the change is lost. Find out which kind a fluent API is before using it.

When Not to Use It

If all of an object's fields are independent and there are no rules between them, do not write a builder: required, init and object initializers give the same readability with less code. For an immutable value object with two or three fields, a record with a primary constructor and, if needed, the static factory methods from earlier posts are enough. Consider a builder when there are rules between fields (a date range, total attachment size), when creation proceeds step by step and conditionally (several recipients, optional attachments), when the result must be immutable but accumulates piece by piece, or when you need different representations from the same steps (the GoF Builder). Choose the step builder only for a widely used library API whose step order is genuinely fixed.

A Small Experiment

★☆☆ Fix InvoiceBuilder.Build(): have it copy the lines and reset the builder. Run the same program again and confirm both invoices show the right line count and total.

★★☆ Write a builder for SalesQuery: Between(from, to), InCountry, PageSize and Build(). Collect the errors "end before start" and "page size not between 1 and 500" into a single exception message. Then remove SalesQuery's public constructors and init setters, and show with compiler errors that there is no way left to create an invalid query except through the builder.

★★★ Write a JsonReceiptBuilder for ReceiptDirector using Utf8JsonWriter. Show that you can produce a third representation without touching the director at all; what return type did you choose for GetResult(), and why?

The next post moves to the last creational pattern, Prototype: how record and with are really a prototype mechanism, how a shallow copy shares a List property between two objects, and why ICloneable is discouraged.