Creational Intermediate 10 min read

Builder Pattern in C#: Records vs Fluent Builders

Builder pattern in C# / .NET 10: when records and with-expressions are enough, when you still need a fluent builder class, and how to design one that scales.

Table of contents
  1. What problem does the Builder pattern solve in C#?
  2. How does a fluent Builder class look in .NET 10?
  3. Can records with with expressions replace the Builder pattern?
  4. When should you keep the explicit Builder class?
  5. How does Builder compare to Abstract Factory and Prototype?
  6. What does a real C# / .NET 10 example look like?
  7. Where should you read next in this series?

You open OrderConfirmationEmail.cs to fix one bug and find the constructor:

public OrderConfirmationEmail(string to, string subject, string body,
    string? from, string? replyTo, IEnumerable<string>? cc,
    IEnumerable<string>? bcc, IEnumerable<Attachment>? attachments,
    string? trackingLink, string? promoBanner, bool isHtml,
    DateTime scheduledFor, CancellationToken ct)

Twelve parameters, half optional, half nullable. The call site looks like an unsigned legal document: new(buyer, "Your order #1234", template, null, null, null, null, attachments, null, null, true, DateTime.UtcNow, default). Six months from now, someone will swap two arguments and ship a Stripe invoice attached to the wrong customer.

The Builder pattern is the answer the Gang of Four wrote for this in 1994. Construct complex objects step by step through a series of named calls, hiding the messy parameter list behind a fluent surface. Like Factory Method and Abstract Factory, the modern C# version of this pattern is much shorter than the textbook one — but unlike those two, the modern version did not come from DI. It came from the language itself: records, init-only setters, and with expressions.

This article shows when each variant is the right one, with the checkout email as our running example.

What problem does the Builder pattern solve in C#?

The pattern earns its place when a single object has many independent fields, most of them optional, and the construction expression has become unreadable. Three concrete shapes:

  1. The 12-parameter constructor. Every new optional field grows the constructor and breaks every existing call site. The Builder absorbs the optionality.
  2. Multi-step construction. You read a base configuration, then conditionally override fields, then loop through a collection to add attachments, then maybe wrap with a fallback. Doing all of that inside one expression is a multiline ternary.
  3. Construction with validation. Some combinations of fields are illegal (HTML body without a <body> tag; bcc set without subject). The Builder's Build() is the one place those rules live.

What is not a Builder problem: "I have one object and want to share it" (that is Singleton); "I have one object and want a near-copy of it" (that is Prototype). Builder is specifically about constructing a fresh object whose final shape depends on a sequence of inputs.

How does a fluent Builder class look in .NET 10?

The textbook fluent Builder. Each With* method returns this, so the calls chain. The target type is immutable; the Builder is the only gateway:

public sealed record OrderConfirmationEmail(
    string To,
    string Subject,
    string Body,
    bool IsHtml,
    IReadOnlyList<string> Cc,
    IReadOnlyList<string> Bcc,
    IReadOnlyList<Attachment> Attachments,
    string? TrackingLink,
    string? PromoBanner)
{
    public sealed class Builder
    {
        private readonly string _to;
        private string  _subject = "";
        private string  _body = "";
        private bool    _isHtml = true;
        private readonly List<string> _cc          = new();
        private readonly List<string> _bcc         = new();
        private readonly List<Attachment> _attach  = new();
        private string? _tracking;
        private string? _promo;

        public Builder(string to) => _to = to;

        public Builder WithSubject(string s) { _subject = s; return this; }
        public Builder WithBody(string b, bool isHtml = true)
            { _body = b; _isHtml = isHtml; return this; }
        public Builder Cc(string addr)  { _cc.Add(addr); return this; }
        public Builder Bcc(string addr) { _bcc.Add(addr); return this; }
        public Builder Attach(Attachment a) { _attach.Add(a); return this; }
        public Builder TrackingLink(string url) { _tracking = url; return this; }
        public Builder Promo(string banner)     { _promo = banner; return this; }

        public OrderConfirmationEmail Build()
        {
            if (string.IsNullOrEmpty(_subject))
                throw new InvalidOperationException("Subject is required");
            if (_isHtml && !_body.Contains("<body", StringComparison.OrdinalIgnoreCase))
                throw new InvalidOperationException("HTML body must contain <body>");
            return new OrderConfirmationEmail(
                _to, _subject, _body, _isHtml,
                _cc.ToArray(), _bcc.ToArray(), _attach.ToArray(),
                _tracking, _promo);
        }
    }
}

The call site is now self-documenting:

var email = new OrderConfirmationEmail.Builder(buyer.Email)
    .WithSubject($"Your order #{order.Id}")
    .WithBody(html, isHtml: true)
    .Cc(order.SalesRep.Email)
    .Attach(invoicePdf)
    .TrackingLink($"https://track/{order.Id}")
    .Build();

The flow that makes this shape look like a sequenceDiagram, which is exactly how reviewers read it:

sequenceDiagram
    participant Caller
    participant B as Builder
    participant E as OrderConfirmationEmail
    Caller->>B: new Builder(to)
    Caller->>B: WithSubject(...)
    Caller->>B: WithBody(...)
    Caller->>B: Cc(...)
    Caller->>B: Attach(...)
    Caller->>B: Build()
    B->>B: validate
    B->>E: new OrderConfirmationEmail(...)
    B-->>Caller: email

This is the version most C# textbooks stop at. It is correct. It is also the version you write when records do not fit your case — see next.

Can records with with expressions replace the Builder pattern?

For most of the cases listed in What problem does the Builder pattern solve?, yes. Records introduced in C# 9, combined with init-only setters and the with expression, give you most of what the Builder provided without the second class:

public sealed record OrderConfirmationEmail
{
    public required string To       { get; init; }
    public required string Subject  { get; init; }
    public required string Body     { get; init; }
    public bool   IsHtml            { get; init; } = true;
    public IReadOnlyList<string>     Cc          { get; init; } = Array.Empty<string>();
    public IReadOnlyList<string>     Bcc         { get; init; } = Array.Empty<string>();
    public IReadOnlyList<Attachment> Attachments { get; init; } = Array.Empty<Attachment>();
    public string? TrackingLink     { get; init; }
    public string? PromoBanner      { get; init; }
}

The call site:

var email = new OrderConfirmationEmail
{
    To       = buyer.Email,
    Subject  = $"Your order #{order.Id}",
    Body     = html,
    Cc       = new[] { order.SalesRep.Email },
    Attachments = new[] { invoicePdf },
    TrackingLink = $"https://track/{order.Id}",
};

Three things this gives you for free:

For 80–90% of "I want a Builder" use cases in real .NET 10 code, this is the right shape. The Microsoft team uses it themselves throughout the framework — JsonSerializerOptions is one place a hand-rolled Builder used to live and is now an init-only record-shaped class.

When should you keep the explicit Builder class?

Records are not always enough. The Builder class earns its place when one of three conditions holds:

A common shape that combines both: keep the data model as a record, but expose a Builder only when callers need the staged or validated construction path. Both coexist; callers pick based on need.

public sealed record OrderConfirmationEmail { /* init-only props */ }

public sealed class OrderConfirmationEmailBuilder
{
    // ... fluent methods that ultimately do `new OrderConfirmationEmail { ... }`
    public OrderConfirmationEmail Build() => /* assemble + validate */;
}

This way the data type is testable, comparable, and serialisable like any other record, and the Builder is just one (optional) entry point.

How does Builder compare to Abstract Factory and Prototype?

The three patterns blur into each other in interviews, but the split is clean once you have the email running example:

Aspect Builder Abstract Factory Prototype
Starts from Nothing Nothing An existing object
Number of products per call One A family of related products One (the clone)
Caller flow new B(...).With...().Build() kit.AddressForm; kit.ConsentDialog prototype with { … }
Modern .NET shape record + with, occasionally fluent class DI registration per family record with with expression
Best for Many optional fields, validation rules Multiple products that vary together Many variants of a base configuration

The hardest call is between Builder and Prototype. If you start from nothing and assemble inputs, Builder; if you start from a template and tweak two fields, Prototype. In a single codebase you frequently have both: a Builder constructs the template once at startup, then each request clones it via Prototype.

What does a real C# / .NET 10 example look like?

A complete shape that ships in production. Notice how the primary type is a record — the Builder is opt-in for callers that need the staged validation:

public sealed record Attachment(string Name, byte[] Content, string MimeType);

public sealed record OrderConfirmationEmail
{
    public required string To       { get; init; }
    public required string Subject  { get; init; }
    public required string Body     { get; init; }
    public bool IsHtml              { get; init; } = true;
    public IReadOnlyList<string>     Cc          { get; init; } = Array.Empty<string>();
    public IReadOnlyList<string>     Bcc         { get; init; } = Array.Empty<string>();
    public IReadOnlyList<Attachment> Attachments { get; init; } = Array.Empty<Attachment>();
    public string? TrackingLink     { get; init; }
    public string? PromoBanner      { get; init; }
}

public sealed class OrderConfirmationEmailBuilder
{
    private readonly string _to;
    private string? _subject;
    private string? _body;
    private bool    _isHtml = true;
    private readonly List<string>     _cc     = new();
    private readonly List<string>     _bcc    = new();
    private readonly List<Attachment> _attach = new();
    private string? _tracking;
    private string? _promo;

    public OrderConfirmationEmailBuilder(string to) => _to = to;

    public OrderConfirmationEmailBuilder WithSubject(string s)         { _subject = s; return this; }
    public OrderConfirmationEmailBuilder WithBody(string b, bool html) { _body = b; _isHtml = html; return this; }
    public OrderConfirmationEmailBuilder Cc(string a)                  { _cc.Add(a); return this; }
    public OrderConfirmationEmailBuilder Bcc(string a)                 { _bcc.Add(a); return this; }
    public OrderConfirmationEmailBuilder Attach(Attachment a)          { _attach.Add(a); return this; }
    public OrderConfirmationEmailBuilder TrackingLink(string url)      { _tracking = url; return this; }
    public OrderConfirmationEmailBuilder Promo(string banner)          { _promo = banner; return this; }

    public OrderConfirmationEmail Build()
    {
        if (string.IsNullOrEmpty(_subject)) throw new InvalidOperationException("Subject is required");
        if (string.IsNullOrEmpty(_body))    throw new InvalidOperationException("Body is required");
        if (_bcc.Count > 0 && !_subject!.Contains("[CONFIDENTIAL]"))
            throw new InvalidOperationException("BCC requires [CONFIDENTIAL] in subject");
        return new OrderConfirmationEmail
        {
            To = _to, Subject = _subject!, Body = _body!, IsHtml = _isHtml,
            Cc = _cc.ToArray(), Bcc = _bcc.ToArray(), Attachments = _attach.ToArray(),
            TrackingLink = _tracking, PromoBanner = _promo,
        };
    }
}

// Caller using the record directly (simple case)
var simple = new OrderConfirmationEmail
{
    To       = buyer.Email,
    Subject  = $"Your order #{order.Id}",
    Body     = html,
};

// Caller using the Builder (staged + validated case)
var richer = new OrderConfirmationEmailBuilder(buyer.Email)
    .WithSubject($"Your order #{order.Id} [CONFIDENTIAL]")
    .WithBody(html, html: true)
    .Bcc("compliance@shop.example")
    .Attach(invoicePdf)
    .TrackingLink($"https://track/{order.Id}")
    .Build();

// `with` for variants
var follow_up = simple with { Subject = $"Reminder: {simple.Subject}" };

The pattern survives because the staged validation in Build() would be uncomfortable to express as a record-only design. The pattern shrinks because the data type itself does not need a class wrapper just to hold fields.

A practical lens: in modern C# the Builder pattern is no longer the default for "object with many fields". Records are. Reach for a Builder when the records cannot express staged, validated, or polymorphic construction. That filter shrinks the population of Builder classes in your codebase to the ones that genuinely earn their existence.

Frequently asked questions

Do I still need a Builder class when C# records have with expressions?
For 90% of cases, no. A record with init-only setters plus the with expression gives you named, optional, immutable construction without writing a separate class. You keep the Builder class when construction is staged (some fields must be set before others), needs validation across multiple fields, or returns a non-trivial subtype depending on inputs. If your goal is just 'too many constructor parameters', records are enough.
What is the difference between a fluent Builder and a constructor with named arguments?
Named arguments work when the construction is one expression and every parameter is independent. A fluent Builder shines when steps must be applied incrementally (read config, then maybe override, then add headers in a loop), when the same builder is reused with small differences, or when you want to hide the assembled object behind an interface until Build() runs. Reach for named arguments first; promote to a Builder when the call site grows loops or branches.
When should the Builder be a separate class versus a method on the target?
Use a nested Builder class on the target type (e.g. OrderEmail.Builder) when callers should never see a half-built object — the Builder is the only public way to construct it. Use a top-level method or extension when the target is already public-immutable and the Builder is just sugar around with. The nested-class form is the one most existing C# codebases use; both compile to the same thing.
How does the Builder pattern compare to Prototype in C#?
Builder constructs from scratch, step by step. Prototype starts from an existing object and clones it with small changes. If you have a base object and need many variants of it, Prototype (often as record with) is cheaper. If each instance is composed from independent inputs, Builder is the right fit. The patterns combine: a Builder can produce a Prototype that is then cloned per use.