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
- What problem does the Builder pattern solve in C#?
- How does a fluent Builder class look in .NET 10?
- Can records with with expressions replace the Builder pattern?
- When should you keep the explicit Builder class?
- How does Builder compare to Abstract Factory and Prototype?
- What does a real C# / .NET 10 example look like?
- 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:
- The 12-parameter constructor. Every new optional field grows the constructor and breaks every existing call site. The Builder absorbs the optionality.
- 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.
- Construction with validation. Some combinations of fields are
illegal (HTML body without a
<body>tag;bccset withoutsubject). The Builder'sBuild()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:
- Compile-time required fields. The
requiredkeyword (C# 11+) forces callers to setTo,Subject,Body. The compiler is the validator; you do not writeif (string.IsNullOrEmpty(...))in aBuild()method. - Variants via
with. Need a near-copy of the email but with a different subject?var draft = email with { Subject = newSubject };No second Builder call. - Value equality and
ToString()for free. Two emails with the same fields are.Equals(). Logging an email shows every field. Hand-written Builder classes give you neither.
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:
- Construction is genuinely staged. You must call
Authenticate()beforeWithRequest(), andWithRequest()beforeBuild(). A type-state fluent Builder — where each step returns a different builder type — encodes those rules in the compiler. Records cannot do that. - Validation spans multiple fields. "If
Bccis non-empty, thenSubjectmust include[CONFIDENTIAL]." Single-fieldrequiredcannot enforce a relationship. The Builder'sBuild()is the place to put that rule, fail at construction time, and never let an invalid object exist. - The constructed type is polymorphic.
Build()decides between returningHtmlEmailorPlainTextEmailbased on what was set. A record commits to one shape at declaration time; the Builder can fan out at the end.
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.
Where should you read next in this series?
- Previous: Abstract Factory — when the question is "which family of objects?", not "how do I assemble this one object?".
- Next: Prototype — when you have a template and need many small variants of it.
- Cross-reference: Singleton — a Builder
that produces an immutable result is often the perfect input for an
AddSingletonregistration. - Decision tree: How to choose the right design pattern.
- Series map: Introduction.
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?
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?
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?
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#?
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.