Builder Pattern trong C#: Record vs Fluent Builder
Builder pattern trong C# / .NET 10: khi record và with-expression là đủ, khi vẫn cần fluent builder class, và cách thiết kế builder co giãn được.
Mục lục
- Builder pattern giải quyết bài toán gì trong C#?
- Một fluent Builder class trong .NET 10 trông thế nào?
- Record với with expression có thay được Builder pattern không?
- Khi nào nên giữ Builder class tường minh?
- So sánh Builder với Abstract Factory và Prototype thế nào?
- Một ví dụ thật trong C# / .NET 10 trông thế nào?
- Đọc tiếp gì trong series?
Bạn mở OrderConfirmationEmail.cs để fix một bug và gặp 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)
Mười hai tham số, một nửa optional, một nửa nullable. Call site nhìn
như tài liệu pháp lý chưa ký: new(buyer, "Your order #1234", template, null, null, null, null, attachments, null, null, true, DateTime.UtcNow, default). Sáu tháng sau, ai đó sẽ đảo hai tham số và ship hóa đơn
Stripe đính kèm sai khách hàng.
Builder pattern là câu trả lời Gang of Four viết cho cảnh đó từ
1994. Dựng object phức tạp từng bước qua loạt lời gọi có tên, giấu
danh sách tham số bừa bộn sau bề mặt fluent. Như Factory
Method và Abstract
Factory, bản C# hiện đại của
pattern này ngắn hơn nhiều bản giáo khoa — nhưng khác hai cái kia,
phiên bản hiện đại không đến từ DI. Nó đến từ chính ngôn ngữ: record,
init-only setter, và biểu thức with.
Bài này chỉ ra biến thể nào đúng cho tình huống nào, với email checkout làm ví dụ xuyên suốt.
Builder pattern giải quyết bài toán gì trong C#?
Pattern xứng chỗ khi một object có nhiều field độc lập, đa số optional, và biểu thức khởi tạo trở nên không đọc được. Ba hình dạng cụ thể:
- Constructor 12 tham số. Mỗi field optional mới làm constructor phình ra và phá mọi call site cũ. Builder hấp thụ tính optional đó.
- Khởi tạo nhiều bước. Bạn đọc config base, override có điều kiện, lặp qua collection để add attachment, rồi có thể bọc thêm fallback. Làm tất cả trong một biểu thức là ternary nhiều dòng.
- Khởi tạo có validation. Một số tổ hợp field trái luật (HTML
body không có thẻ
<body>;bccset mà không cósubject).Build()của Builder là chỗ duy nhất các luật đó cư trú.
Cái không phải bài toán Builder: "Tôi có một object và muốn share nó" (đó là Singleton); "Tôi có một object và muốn bản gần copy của nó" (đó là Prototype). Builder đặc biệt nói về dựng object mới mà hình hài cuối phụ thuộc vào chuỗi input.
Một fluent Builder class trong .NET 10 trông thế nào?
Fluent Builder giáo khoa. Mỗi method With* trả this, các lời gọi
nối tiếp nhau. Target type immutable; Builder là cổng duy nhất:
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);
}
}
}
Call site giờ tự nói chuyện:
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();
Dòng chảy làm hình dạng này nhìn như sequenceDiagram, đúng cách reviewer đọc nó:
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
Đây là phiên bản đa số sách C# dừng lại. Nó đúng. Cũng là phiên bản bạn viết khi record không vừa case của bạn — xem phần kế.
Record với with expression có thay được Builder pattern không?
Hầu hết trường hợp liệt kê ở mục Builder giải quyết bài toán gì?, có.
Record giới thiệu trong C# 9, kết hợp với init-only setter và biểu
thức with, cho bạn phần lớn cái Builder cung cấp mà không cần class
thứ hai:
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; }
}
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}",
};
Ba thứ cách này cho bạn miễn phí:
- Required field tại compile-time. Từ khoá
required(C# 11+) ép caller setTo,Subject,Body. Compiler là validator; bạn không viếtif (string.IsNullOrEmpty(...))trongBuild(). - Biến thể qua
with. Cần bản gần copy email với subject khác?var draft = email with { Subject = newSubject };. Không cần lời gọi Builder thứ hai. - Equality theo giá trị và
ToString()miễn phí. Hai email cùng field là.Equals(). Log một email hiện đủ field. Builder class tự cuộn không có cả hai.
Cho 80–90% use case "tôi muốn Builder" trong code .NET 10 thật, đây
là hình dạng đúng. Đội Microsoft tự dùng cách này khắp framework —
JsonSerializerOptions từng là chỗ Builder tự cuộn nay đã chuyển
sang init-only record-style.
Khi nào nên giữ Builder class tường minh?
Record không phải lúc nào cũng đủ. Builder class xứng đáng khi một trong ba điều kiện đúng:
- Khởi tạo thật sự theo giai đoạn. Bạn phải gọi
Authenticate()trướcWithRequest(), vàWithRequest()trướcBuild(). Một Builder type-state — mỗi bước trả về một type builder khác — mã hoá luật đó vào compiler. Record không làm được. - Validation cross-field. "Nếu
Bcckhông rỗng, thìSubjectphải chứa[CONFIDENTIAL]."requiredđơn-field không ép quan hệ được.Build()của Builder là chỗ đặt rule đó, fail tại construction time, không bao giờ cho object trái luật tồn tại. - Type được dựng là polymorphic.
Build()quyết định trảHtmlEmailhayPlainTextEmaildựa trên cái đã set. Record cam kết một hình hài tại declaration time; Builder phân nhánh được ở cuối.
Một hình dạng kết hợp phổ biến: giữ data model là record, nhưng
expose Builder chỉ khi caller cần đường dựng theo giai đoạn hoặc
có validation. Cả hai cùng tồn tại; caller chọn theo nhu cầu.
public sealed record OrderConfirmationEmail { /* init-only props */ }
public sealed class OrderConfirmationEmailBuilder
{
// ... method fluent cuối cùng làm `new OrderConfirmationEmail { ... }`
public OrderConfirmationEmail Build() => /* lắp ráp + validate */;
}
Cách này data type test được, so sánh được, serialize được như mọi record khác, và Builder chỉ là một (optional) entry point.
So sánh Builder với Abstract Factory và Prototype thế nào?
Ba pattern này lẫn vào nhau trong phỏng vấn, nhưng tách ra rõ một khi đã có ví dụ email chạy xuyên suốt:
| Khía cạnh | Builder | Abstract Factory | Prototype |
|---|---|---|---|
| Xuất phát từ | Số không | Số không | Một object có sẵn |
| Số sản phẩm/lần | Một | Một họ sản phẩm liên quan | Một (bản clone) |
| Dòng caller | new B(...).With...().Build() |
kit.AddressForm; kit.ConsentDialog |
prototype with { … } |
| Hình hài .NET hiện đại | record + with, đôi khi class fluent |
DI registration cho mỗi family | record với with expression |
| Hợp với | Nhiều field optional, validation rule | Nhiều sản phẩm biến đổi cùng nhau | Nhiều biến thể của một config base |
Quyết định khó nhất là giữa Builder và Prototype. Nếu bạn xuất phát từ số không và lắp ráp input, Builder; nếu xuất phát từ template và chỉnh hai field, Prototype. Trong cùng codebase bạn hay có cả hai: một Builder dựng template một lần lúc startup, rồi mỗi request clone nó qua Prototype.
Một ví dụ thật trong C# / .NET 10 trông thế nào?
Hình dạng đầy đủ ship được production. Để ý type chính là record
— Builder là opt-in cho caller cần 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 dùng record trực tiếp (case đơn giản)
var simple = new OrderConfirmationEmail
{
To = buyer.Email,
Subject = $"Your order #{order.Id}",
Body = html,
};
// Caller dùng Builder (case staged + validated)
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` cho biến thể
var follow_up = simple with { Subject = $"Reminder: {simple.Subject}" };
Pattern sống vì staged validation trong Build() sẽ khó chịu nếu
diễn đạt thuần record. Pattern teo lại vì bản thân data type không
cần class wrapper chỉ để giữ field.
Đọc tiếp gì trong series?
- Bài trước: Abstract Factory — khi câu hỏi là "họ object nào?", không phải "lắp ráp object này thế nào?".
- Bài kế: Prototype — khi có template và cần nhiều biến thể nhỏ.
- Tham chiếu chéo: Singleton — Builder
tạo ra kết quả immutable thường là input hoàn hảo cho đăng ký
AddSingleton. - Cây quyết định: Cách chọn design pattern phù hợp.
- Bản đồ series: Giới thiệu.
Một góc nhìn thực dụng: trong C# hiện đại Builder pattern không còn là mặc định cho "object nhiều field". Record là. Với tới Builder khi record không diễn đạt được khởi tạo staged, validated, hay polymorphic. Bộ lọc đó co dân số Builder class trong codebase của bạn xuống còn những cái thật sự xứng đáng tồn tại.
Câu hỏi thường gặp
C# có with expression rồi thì còn cần Builder class không?
record với init-only setter cộng with cho bạn construction có tên, optional, immutable mà không cần class riêng. Bạn giữ Builder class khi construction theo giai đoạn (field này phải set trước field kia), cần validation cross-field, hoặc trả về subtype tùy input. Nếu mục tiêu chỉ là 'quá nhiều tham số constructor', record là đủ.Fluent Builder khác named arguments ở điểm gì?
Build() chạy. Với named arguments trước; thăng cấp Builder khi call site mọc thêm vòng lặp hoặc nhánh.Builder nên là class riêng hay method trên target?
Builder trên target type (ví dụ OrderEmail.Builder) khi caller không bao giờ được thấy object dở dang — Builder là cách public duy nhất để khởi tạo. Dùng method top-level hoặc extension khi target đã public-immutable và Builder chỉ là đường ngắn quanh with. Form nested-class là form đa số codebase C# hiện có dùng; cả hai biên dịch ra cùng kết quả.Builder pattern khác Prototype thế nào trong C#?
record with) rẻ hơn. Nếu mỗi instance được hợp thành từ input độc lập, Builder mới là lựa chọn. Hai pattern kết hợp: Builder có thể tạo Prototype, rồi clone nó cho mỗi lần dùng.