Khởi tạo Trung bình 10 phút đọc

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
  1. Builder pattern giải quyết bài toán gì trong C#?
  2. Một fluent Builder class trong .NET 10 trông thế nào?
  3. Record với with expression có thay được Builder pattern không?
  4. Khi nào nên giữ Builder class tường minh?
  5. So sánh Builder với Abstract Factory và Prototype thế nào?
  6. Một ví dụ thật trong C# / .NET 10 trông thế nào?
  7. Đọ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 MethodAbstract 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ể:

  1. 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 đó.
  2. 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.
  3. Khởi tạo có validation. Một số tổ hợp field trái luật (HTML body không có thẻ <body>; bcc set 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í:

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:

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ínhrecord — 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?

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?
90% trường hợp là không. Một 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ì?
Named arguments hợp khi construction là một biểu thức và mọi tham số độc lập. Fluent Builder phát huy khi các bước phải áp dần (đọc config, override có điều kiện, thêm header trong vòng lặp), khi cùng một builder dùng lại với khác biệt nhỏ, hoặc khi muốn giấu object lắp ráp sau interface đến lúc 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?
Dùng nested class 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#?
Builder dựng từ con số không, từng bước. Prototype xuất phát từ một object có sẵn rồi clone với một vài thay đổi. Nếu có object base và cần nhiều biến thể, Prototype (thường là 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.