Mert Özen Her Satırda Daha İleri
Yazılım Tasarımı Design Patterns — #05

Builder Pattern: Fluent Builder, WebApplicationBuilder ve Object Initializer ile required'ın Yetmediği Yer

Mert Özen 7 Kas 2026 16 dk 4 görüntülenme
Builder Pattern: Fluent Builder, WebApplicationBuilder ve Object Initializer ile required'ın Yetmediği Yer

required, init ve object initializer çok parametreli constructor'ı okunur kılar ama alanlar arası kuralları, adım sırasını ve parça parça biriken değişmez sonucu çözmez; Builder bu kuralları tek bir Build() anında uygular. Record'da with'in kuralı atlaması, GoF'un director'lı Builder'ı ile fluent builder farkı, UriBuilder, connection string builder, ImmutableArray builder, HostApplicationBuilder ve step builder gerçek çıktılarla.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore sipariş onayı e-postalarını bir record ile temsil ediyor. Kural basit: bir e-postanın en az bir gövdesi olmalı, düz metin ya da HTML (HyperText Markup Language). Kuralı property initializer'a yazdık; gövdesiz bir e-posta oluşturmaya çalışan exception alıyor. Sonra biri bir taslak özelliği için mevcut bir e-postanın kopyasını with ile alıp gövdesini temizliyor. Üç satırdan hangisi exception fırlatır?

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)
    {
        // Kural: en az bir gövde olmalı
        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, Kuralı Atlıyor

Çıktı:

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)

İkinci satır beklendiği gibi reddedildi ama üçüncü satırdaki with, kuralın tam olarak yasakladığı object'i üretti: ne metin ne HTML gövdesi var. Sebep, with'in nasıl çalıştığı. Primary constructor'ı ve property initializer'ları çalıştırmıyor; derleyicinin record için ürettiği copy constructor ile field'ları kopyalıyor, ardından yalnızca süslü parantezde yazdığın init setter'larını çağırıyor. Kural initializer'da yaşadığı için kopyada hiç çalışmadı. C# OOP (Object-Oriented Programming) serisindeki record yazısında with'in gizli <Clone>$ method'u ve copy constructor ile çalıştığını görmüştük; işin bir de bu yüzü var.

Bu küçük örnek, Builder pattern'inin hâlâ neden var olduğunu özetliyor. C#'ın son sürümleri object oluşturmayı çok daha okunaklı hâle getirdi: named argument, object initializer, init, required, record ve with. Bunlar "çok parametreli constructor'ı okunur kılma" problemini büyük ölçüde çözdü. Çözmedikleri şey, oluşturma sürecinin kendisi: birden fazla property'yi birlikte ilgilendiren kurallar, belli bir sırayla yapılması gereken adımlar ve adım adım biriken, sonunda değişmez hâle gelen bir sonuç. Builder'ın yeri bu boşluk.

Telescoping Constructor ve C#'ın Cevabı: required ve init

Klasik problem şu: bir satış raporu sorgusunun iki zorunlu ve dört isteğe bağlı parametresi var. Her kombinasyon için ayrı constructor yazınca birbirini çağıran, giderek uzayan constructor'lar oluşur; Joshua Bloch (Effective Java kitabının yazarı) bu yapıya telescoping constructor der. En uzun constructor'ı çağıran kodu okuyan biri hangi int'in sayfa boyutu, hangisinin en az adet olduğunu bilemez:

using Bookstore.Reporting;

// Telescoping constructor: hangi int sayfa boyutu, hangisi en az adet?
SalesQuery old = new(new DateOnly(2026, 10, 1), new DateOnly(2026, 10, 31), "TR", 50, 3, true);

// required + init: okunaklı, eksik property'yi derleyici yakalar
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 initializer ve required okunurluk problemini çözdü: her değerin yanında adı var. Zorunlu bir property'yi unutmak da artık derleme hatası. Aynı koddan To = ... satırını silince build şunu söylüyor:

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

Ama çıktıya bak: 31 Ekim'den 1 Ekim'e uzanan, sayfa boyutu sıfır olan bir sorgu sorunsuz oluştu. required bir property'nin atandığını garanti eder, değerinin anlamlı olduğunu değil. "Bitiş başlangıçtan önce olamaz" gibi birden fazla property'yi ilgilendiren bir kuralı hiçbir property setter'ı tek başına uygulayamaz, çünkü setter'lar sırasız çalışır ve From atanırken To henüz belli değildir. Kuralı uygulamanın tek doğru anı, bütün değerlerin bilindiği an: oluşturmanın sonu. Builder'ın Build() method'u tam olarak o an.

GoF Builder: Aynı Adımlar, Farklı Temsiller

"Builder" denince çoğumuzun aklına .With...().Build() zinciri gelir, ama GoF'un (Gang of Four: Gamma, Helm, Johnson ve Vlissides'in 1994 tarihli Design Patterns kitabı) Builder'ı başka bir problemi çözer. Amacı: karmaşık bir object'in oluşturulma sürecini temsilinden ayırmak, böylece aynı süreç farklı temsiller üretebilsin. Kitabın örneği bir RTF (Rich Text Format) okuyucu: belgeyi okuyan kod (director) aynı, ama okuduğu her parçayı düz metne, TeX'e ya da ekrandaki bir metin widget'ına çeviren farklı builder'lar var. Bookstore'da aynı fikir bir sipariş fişi:

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: adımlar; her concrete builder kendi temsilini üretir
    public interface IReceiptBuilder
    {
        void AddHeader(int orderId, string customer);
        void AddLine(string title, int quantity, decimal amount);
        void AddTotal(decimal total);
    }

    // Director: adımların sırasını bilir, temsili bilmez
    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 fişin hangi parçalardan, hangi sırayla oluştuğunu biliyor: başlık, satırlar, toplam. Biçimden haberi yok. PlainTextReceiptBuilder sütunları hizalıyor, HtmlReceiptBuilder etiket üretiyor ve "Emma & Co." içindeki &'i kaçırıyor. Yarın bir PDF fişi gerekirse director'a dokunmadan yeni bir builder yazılır.

Dikkat edilecek bir GoF ayrıntısı: GetResult() interface'te değil, her concrete builder'da ve farklı tip döndürebilir. GoF bunu bilinçli bir karar olarak anlatır; ürünler o kadar farklı olabilir ki ortak bir üst tip anlamsızdır. Bu da Builder'ı Abstract Factory'den ayıran nokta: Abstract Factory bir ürün ailesini tek adımda döndürür, Builder ise tek bir ürünü adım adım kurar ve sonucu en sonda verir.

Bu biçimi günlük .NET kodunda sık görmezsin ama tanıdık bir akrabası var: System.Text.Json'ın Utf8JsonWriter'ı ya da XmlWriter. Serileştiren kod (director rolü) WriteStartObject, WriteString, WriteEndObject gibi adımları çağırır; adımların hangi byte'lara dönüşeceğine writer karar verir.

Fluent Builder: Build() İçinde Invariant

Günlük kodda "builder" dediğimiz şey ise Bloch'un Effective Java'daki ikinci maddesinin konusu: çok sayıda isteğe bağlı parametresi olan, değişmez bir object'i okunaklı biçimde kurmak. Director yok; zinciri çağıran kod kendisi director. Bookstore'un e-posta mesajını bu kez builder ile kuralım:

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; }

        // Tek yol builder'dan geçiyor; constructor geçersiz object kabul etmiyor
        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()
            {
                // Bütün hataları tek seferde topla: kullanıcı bir bir düzeltmek zorunda kalmasın
                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.

Üç tasarım kararı var. Birincisi, EmailMessage'ın constructor'ı private ve builder'ı parametre olarak alıyor; geçersiz bir mesajın oluşmasının tek yolu kapalı. İkincisi, bütün kurallar Build()'de ve hepsi birlikte kontrol ediliyor: dört hata tek mesajda geldi. Bir API (Application Programming Interface) isteğini doğrularken de aynı şeyi yaparsın: ASP.NET Core'un model validation'ı bütün hataları tek bir ValidationProblemDetails yanıtında döndürür, kullanıcı onları bir bir keşfetmek zorunda kalmaz. Üçüncüsü, sonuç gerçekten değişmez: To ve Attachments, builder'ın listelerinin kopyası ([.. b.Recipients]). Bu kopyanın neden önemli olduğunu tuzaklar bölümünde göreceğiz.

Bu yapı test kodunda da çok işe yarar. Bir sipariş test'i için on property'yi doldurmak yerine anlamlı varsayılanlarla dolu bir builder alıp yalnızca test'in umursadığı property'yi değiştirmek, test data builder denen tekniktir. Clean Code serisinin test kodu yazısı bu tekniği ayrıntılı anlatıyor; production builder'ından farkı, kuralları gevşetmek yerine geçerli varsayılanlar sunması.

.NET'teki Builder'lar: Biriktir, Sonra Dondur

.NET'in kendi kütüphanesindeki builder'ların ortak bir davranışı var: değiştirilebilir bir şeyde biriktirir, sonucu tek seferde değişmez ya da tutarlı bir hâle getirirler. StringBuilder en bilineni; birkaçını daha çalıştıralım:

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

// UriBuilder: parçaları tek tek kur, sonucu tek seferde al
UriBuilder uri = new("https", "api.bookstore.example") { Path = "v1/books", Query = "q=dune&page=2" };
Console.WriteLine(uri.Uri);

// Connection string builder: değerleri kaçırarak birleştirir
SqliteConnectionStringBuilder cs = new() { DataSource = "shop;Mode=Memory.db", Mode = SqliteOpenMode.ReadOnly };
Console.WriteLine(cs.ConnectionString);

// ImmutableArray<T>.Builder: değiştirilebilir biriktirme, değişmez sonuç
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: Build()'den sonra service collection kilitlenir
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 parçaları ayrı ayrı alıp doğru ayraçlarla birleştiriyor. SqliteConnectionStringBuilder daha önemli bir iş yapıyor: dosya adının içindeki noktalı virgülü tırnağa aldı. Connection string'i $"Data Source={fileName}" ile birleştirseydin, kullanıcının verdiği bir dosya adı Mode=Memory gibi yeni bir ayar enjekte edebilirdi. Builder burada yalnızca okunurluk değil, güvenlik de sağlıyor.

ImmutableArray<T>.Builder pattern'in bellek tarafını gösteriyor. MoveToImmutable builder'ın iç array'ini kopyalamadan devralır ama yalnızca kapasite ile eleman sayısı eşitse; değilse çıktıdaki exception'ı fırlatır. .NET 8 ile gelen DrainToImmutable bu koşulu aramıyor, gerekirse kopyalıyor ve builder'ı boşaltıyor. İkisi de aynı sözü veriyor: sonuç bir daha değişmeyecek ve builder'daki sonraki değişiklikler onu etkilemeyecek.

Son örnek ASP.NET Core'dan tanıdığın WebApplicationBuilder'ın kardeşi HostApplicationBuilder. Services, Configuration ve Logging property'leri birikim alanı; Build() container'ı kurar ve service collection'ı salt okunur yapar. Sonrasında bir service eklemeye çalışmak, çıktıdaki gibi açık bir hata verir. Bu kilit bir kısıtlama değil, bir garanti: container kurulduktan sonra registration değişseydi, hangi service'in hangi kayda göre çözüldüğü belirsizleşirdi.

Step Builder: Sırayı Tipe Gömmek

Fluent builder'ın zayıf noktası, eksik adımın ancak Build() çağrıldığında, yani runtime'da yakalanması. Bazı API'lerde adımların sırası ve zorunluluğu o kadar net ki bunu derleyiciye devretmek mümkün: her adım, bir sonraki adımın interface'ini döndürür. Bu varyanta step builder denir:

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);

    // Her adım bir sonrakinin interface'ini döndürür: sıra tipe gömülü
    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)

Konu ve gövdeyi atlayan bir çağrı ise derlenmiyor:

// Konu ve gövde atlanmış
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(...) bir INeedsSubject döndürüyor ve o interface'te Build yok. IntelliSense de yalnızca sıradaki adımı önerdiği için API kendini anlatıyor. Bedeli de açık: dört adım için dört interface, isteğe bağlı adımlar için ek düzenekler, adımların sırasını değiştirmek istediğinde bütün zincirin yeniden tasarlanması. Step builder'ı, birçok ekibin kullanacağı ve yanlış kullanımın pahalı olduğu bir library API'si için sakla; uygulama içindeki bir mesaj için Build()'deki kontrol yeterli.

Sık Düşülen Tuzaklar

Builder'ın kendi listesini sonuca vermek. Fatura builder'ını bir döngü içinde tekrar kullanan bir kod düşün:

using System.Globalization;
using Bookstore.Billing;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

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

// Aynı builder ile ikinci fatura
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; }

        // Hata: builder'ın kendi listesini sonuca veriyor
        public Invoice Build() => new(_orderId, _lines);
    }
}
#1042: 2 lines, total 33.48
#1043: 2 lines, total 33.48

1042 numaralı faturanın satırları, ikinci fatura için eklenen satırla değişti; çünkü Invoice builder'ın _lines listesinin kendisini tutuyor. IReadOnlyList<T> tipi okuyana "değişmez" gibi görünse de yalnızca bir görünüm; altındaki liste değişince o da değişir. Çözüm, Build()'de kopyalamak ([.. _lines]) ve builder'ın ya tek kullanımlık olduğunu belgelemek ya da her Build()'den sonra kendini sıfırlamasını sağlamak. İkinci fatura da Dune satırını taşıyor, çünkü builder sıfırlanmadı; bu da aynı tuzağın öteki yüzü.

Tek satırlık object için builder yazmak. Üç property'si olan bir record için BookBuilder açmak, object initializer'ın zaten verdiği okunurluğu iki kat kodla yeniden üretmektir. Builder'ın gerekçesi kural, sıra ya da birikim olmalı; yalnızca "çok property var" değil.

Kuralı yalnızca builder'a koyup tipi açık bırakmak. EmailMessage'ın public bir constructor'ı ya da public setter'ları varsa, builder'daki kontroller bir öneriden ibaret kalır ve yazının başındaki with gibi bir arka kapı her zaman bulunur. Invariant'ı koruyan, tipin kendisi olmalı; builder yalnızca ona giden okunaklı yol.

Fluent API'de her method'un yeni bir builder döndürdüğünü ya da döndürmediğini varsaymak. Bu yazıdaki builder'lar this döndürüyor, yani mutable. LINQ (Language-Integrated Query) ya da ImmutableList<T> gibi immutable fluent API'lerde ise her çağrı yeni bir object döndürür ve dönüş değerini atamazsan değişiklik kaybolur. Bir fluent API'yi kullanmadan önce hangisi olduğunu öğren.

Ne Zaman Kullanma

Object'in bütün property'leri bağımsızsa ve aralarında kural yoksa builder yazma: required, init ve object initializer daha az kodla aynı okunurluğu verir. Değişmez bir value object'in iki üç property'si varsa primary constructor'lı bir record ve gerekirse önceki yazılarda gördüğümüz static factory method'lar yeter. Builder'ı şu durumlarda düşün: property'ler arasında kural varsa (tarih aralığı, toplam ek boyutu), oluşturma adım adım ve koşullu ilerliyorsa (birden fazla alıcı, isteğe bağlı ekler), sonuç değişmez olmalı ama parça parça birikiyorsa ya da aynı adımlardan farklı temsiller üretmen gerekiyorsa (GoF Builder). Step builder'ı ise yalnızca yaygın kullanılan bir library API'sinde, adımların sırası gerçekten sabitse seç.

Küçük Bir Deneme

★☆☆ InvoiceBuilder.Build()'i düzelt: satırları kopyalasın ve builder'ı sıfırlasın. Aynı programı tekrar çalıştırıp iki faturanın doğru satır sayısını ve toplamını gösterdiğini doğrula.

★★☆ SalesQuery için bir builder yaz: Between(from, to), InCountry, PageSize ve Build(). Bitişin başlangıçtan önce olması ve sayfa boyutunun 1 ile 500 arasında olmaması hatalarını tek bir exception mesajında topla. Sonra SalesQuery'nin public constructor'larını ve init setter'larını kaldır; geçersiz bir sorgu oluşturmanın builder dışında bir yolu kalmadığını derleyici hatalarıyla göster.

★★★ ReceiptDirector için bir JsonReceiptBuilder yaz ve Utf8JsonWriter kullan. Director'a hiç dokunmadan üçüncü bir temsil üretebildiğini göster; GetResult()'ın dönüş tipini ne seçtin ve neden?

Bir sonraki yazıda creational pattern'lerin sonuncusuna, Prototype'a geçiyoruz: record ve with'in aslında bir prototype mekanizması olduğu, shallow copy'nin bir List property'sini iki object arasında nasıl paylaştırdığı ve ICloneable'ın neden önerilmediği.