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

Pattern Seçim Rehberi ve C# 15: Union Type, Closed Hierarchy ve Pattern'lerin Evrimi

Mert Özen 14 Kas 2026 20 dk 7 görüntülenme
Pattern Seçim Rehberi ve C# 15: Union Type, Closed Hierarchy ve Pattern'lerin Evrimi

Doğru pattern'i seçmenin yolu pattern'den değil problemden başlamaktır: neyin değişebilir kalacağını bul, sonra oluşturma, sarmalama, davranış, durum, ağaç ve sonuç ailelerinden adayı seç. C#'ın lambda, yield, record ve pattern matching ile pattern'leri nasıl incelttiği ve C# 15'in union type'ları ile closed hierarchy'lerinin Visitor, State ve Result'ı exhaustive switch'lerle nasıl yeniden yazdırdığı, gerçek derleyici uyarılarıyla.

Sürüm notu: Bu yazıdaki kodlar C# 15 ve .NET 11 ile derlenip denenmiştir; yalnız C# 15 öncesi özellikleri kullanan örnek, C# 14 ve .NET 10 ile de aynı çıktıyı veriyor.

Önce Tahmin Et

Serinin Visitor yazısında, belge element'lerini HTML'e (HyperText Markup Language) çeviren switch expression'a Quote eklenmemişti. Derleyici CS8509 ile uyarmış ama eksik olanın Quote olduğunu söyleyememiş, "_ kalıbı eksik" demişti; abstract bir record'tan başka assembly'ler de türeyebilirdi. C# 15 ve .NET 11, 10 Kasım 2026'da GA (General Availability) oldu. Aynı kodda tek kelimeyi değiştiriyoruz: abstract yerine closed.

DocumentElement[] invoice =
[
    new Paragraph("Order #1042"),
    new Image("logo.png", 120),
    new Quote("Dune is a masterpiece.", "Grace"),
];

foreach (DocumentElement element in invoice)
{
    Console.WriteLine(Html.Render(element));
}

// Visitor yazısındaki hiyerarşi; tek fark abstract yerine closed
public closed record DocumentElement;
public sealed record Paragraph(string Text) : DocumentElement;
public sealed record Table(string[] Header, string[][] Rows) : DocumentElement;
public sealed record Image(string Path, int WidthPx) : DocumentElement;
public sealed record Quote(string Text, string Author) : DocumentElement;

public static class Html
{
    public static string Render(DocumentElement element) => element switch
    {
        Paragraph p => $"<p>{p.Text}</p>",
        Table t => $"<table>{t.Rows.Length} rows</table>",
        Image { WidthPx: > 600 } i => $"<img src=\"{i.Path}\" class=\"wide\">",
        Image i => $"<img src=\"{i.Path}\" width=\"{i.WidthPx}\">",
    };
}

İki soru. Derleyici yine CS8509 verir mi, verirse bu kez ne der? Program çalışınca üçüncü satırda, yani Quote'ta ne olur?

Derleyici Artık Eksik Tipi Adıyla Söylüyor

Derleme çıktısı:

Program.cs(22,69): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Quote' is not covered.

Çalıştırınca:

<p>Order #1042</p>
<img src="logo.png" width="120">
Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException: Non-exhaustive switch expression failed to match its input.
Unmatched value was Quote { Text = Dune is a masterpiece., Author = Grace }.

Uyarı hâlâ CS8509, ama artık "_ kalıbı" değil "Quote kalıbı kapsanmıyor" diyor. closed, DocumentElement'in doğrudan alt tiplerinin bu assembly'dekilerden ibaret olduğunu söylüyor; liste kapalı olunca derleyici eksik kolu adıyla bulabiliyor.

İkinci cevap ise C# 15'in neyi değiştirmediğini gösteriyor: bu bir uyarı, hata değil. Program derlendi, çalıştı ve Quote'a gelince yine SwitchExpressionException fırlattı. Eksik kolu eklediğimizde uyarı kayboluyor ve _ koluna da gerek kalmıyor:

// Html.Render'a eklenen tek kol
Quote q => $"<blockquote>{q.Text} ({q.Author})</blockquote>",
<p>Order #1042</p>
<img src="logo.png" width="120">
<blockquote>Dune is a masterpiece. (Grace)</blockquote>

Visitor'ın yıllardır sattığı "yeni tip eklenince her işlem derleme zamanında kırılır" garantisini C# 15 switch expression'a da veriyor. Bu son yazıda önce seçim rehberini toparlayacağız, sonra Visitor, State ve Result'ı union ve closed ile yeniden yazacağız.

Problemden Pattern'e: Seçim Rehberi

26-pattern-guide-tr.png

Pattern seçmenin en sık hatası, pattern'den başlamak: "burada bir Strategy kullanalım" diye başlayan tasarım problemi pattern'e uydurmaya başlar. GoF (Gang of Four: Gamma, Helm, Johnson ve Vlissides'in 1994 tarihli Design Patterns kitabı) ilk bölümünde pattern seçimi için birkaç yol önerir; bence en işe yarayanı, tasarımda neyin değişebilir kalması gerektiğini sormak. Harita bu soruya göre dizildi: solda problem, sağda aday pattern.

İlk üç ailede soru sırasıyla şu: hangi class'ı, ne zaman ve kaç adımda yaratıyorum; var olan bir object'in etrafına ne koyuyorum; hangi kod, hangi koşulda çalışacak? Önce dilin verdiğine bak: object initializer ve required çoğu Builder'ı, record ve with çoğu Prototype'ı gereksiz kılıyor, "tek instance" ise DI (Dependency Injection) container'ında bir lifetime. Adapter, Decorator, Proxy ve Facade'ı yapı değil amaç ayırıyor.

Son üç aile bu yazının konusu. Durum ailesinde davranış object'in o anki durumuna bağlı (State) ya da geçmiş bir duruma dönmek gerekiyor (Memento). Ağaç ailesinde bir yapıyı kurmak (Composite), üzerinde birçok işlem yapmak (Visitor) ya da gezmek (Iterator) var. Sonuç ailesi GoF'un dışından: beklenen bir iş hatasını taşımak (Result), "yok" durumuna davranış vermek (Null Object). Yeşil üç kutu, C# 15 ile en çok değişenler.

Dil Pattern'leri Nasıl İnceltti

Serinin ilk yazısında Peter Norvig'in 1996 tarihli tespitini konuşmuştuk: dil güçlendikçe bazı pattern'ler dilin bir özelliğine dönüşüp görünmez oluyor. Aşağıdaki program, seride ayrı yazılarda gördüğümüz beş pattern'in bugünkü hâlini tek dosyada gösteriyor:

Book dune = new("Dune", 12.99m, Stock: 4);
Book[] catalog = [dune, new("Emma", 7.50m, Stock: 0), new("Persuasion", 9.90m, Stock: 1)];

// Strategy (C# 3): interface ve class ailesi yerine bir Func
Func<decimal, decimal> studentPrice = price => Math.Round(price * 0.85m, 2);

// Command (C# 3): kuyruğa atılıp sonra çalışan lambda'lar
Queue<Action> outbox = new();
outbox.Enqueue(() => Console.WriteLine("email: order 1042 shipped"));

// Iterator (C# 2): yield return ile tembel gezme
foreach (Book book in Catalog.InStock(catalog))
{
    Console.WriteLine($"in stock: {book.Title}");
}

// Prototype ve Memento (C# 9): record ve with
Book discounted = dune with { Price = studentPrice(dune.Price) };
Console.WriteLine($"{dune.Price} -> {discounted.Price}");

// Decorator (C# 12): primary constructor ile saran class
IPriceService prices = new LoggingPriceService(new PriceService());
Console.WriteLine(prices.PriceOf(dune));

while (outbox.TryDequeue(out Action? job))
{
    job();
}

public sealed record Book(string Title, decimal Price, int Stock);

public static class Catalog
{
    public static IEnumerable<Book> InStock(IEnumerable<Book> books)
    {
        foreach (Book book in books)
        {
            if (book.Stock > 0)
            {
                yield return book;
            }
        }
    }
}

public interface IPriceService
{
    decimal PriceOf(Book book);
}

public sealed class PriceService : IPriceService
{
    public decimal PriceOf(Book book) => book.Price;
}

public sealed class LoggingPriceService(IPriceService inner) : IPriceService
{
    public decimal PriceOf(Book book)
    {
        decimal price = inner.PriceOf(book);
        Console.WriteLine($"log: price of {book.Title} is {price}");
        return price;
    }
}
in stock: Dune
in stock: Persuasion
12.99 -> 11.04
log: price of Dune is 12.99
12.99
email: order 1042 shipped

Hiçbir satırda IDiscountStrategy, ICommand, IIterator ya da Clone yok. Pattern'ler kaybolmadı; rolleri hâlâ orada (Strategy'nin değiştirilebilir algoritması studentPrice, Command'ın ertelenmiş isteği kuyruktaki lambda), ama onları taşıyan tören dilin içine girdi. Hangi sürümün hangi pattern'i incelttiğinin özeti:

PatternGoF'taki yapıC#'taki hafif hâliSürüm
ObserverSubject ve Observer interface'lerieventC# 1.0
IteratorIterator class'ı ve CreateIteratoryield returnC# 2.0
Strategy, Command, Template MethodInterface ve class ailesiFunc, Action ve lambdaC# 3.0
InterpreterExpression class ağacıExpression treeC# 3.0
VisitorAccept ve Visit çiftiType pattern ve switch expressionC# 7.0 ve 8.0
Prototype, MementoClone ve Memento class'ırecord ve withC# 9.0
DecoratorConstructor'da sarılan componentPrimary constructorC# 12
Visitor, State, ResultKapalı olduğu bilinmeyen hiyerarşiclosed ve unionC# 15

Her satırın arkasında serinin bir yazısı var. Sürümler Microsoft Learn'deki C# sürüm geçmişinden; C# Sürümleri serisinin "C# Sürümleri: 1.0'dan 10'a Dilin Kısa Tarihi" yazısı aynı hikâyeyi dil tarafından anlatıyor.

Tablonun son satırına kadar her inceltme törenin yanında bir şeyi de götürdü. Visitor yerine switch yazınca "yeni tip eklenince derleyici bana söylesin" garantisi de gitti; Önce Tahmin Et'teki _ uyarısı bunun kanıtıydı. C# 15'in iki yeni aracı bu boşluğu kapatıyor.

C# 15'in İki Aracı: union ve closed

union: Ortak Atası Olmayan Tipleri Kapalı Bir Kümede Toplamak

union bildirimi, bir değerin birkaç tipten tam olarak biri olduğunu söylüyor. Bu tiplere case type deniyor ve ortak bir base class'a ya da interface'e ihtiyaçları yok. Derleyicinin ürettiği şeye reflection ile bakalım:

using System.Reflection;
using System.Runtime.CompilerServices;

PlaceOrderResult result = new CartEmpty();
Console.WriteLine(result.Value);

Type type = typeof(PlaceOrderResult);
Console.WriteLine($"struct: {type.IsValueType}, [Union]: {type.IsDefined(typeof(UnionAttribute))}, IUnion: {type.IsAssignableTo(typeof(IUnion))}");
foreach (ConstructorInfo ctor in type.GetConstructors())
{
    Console.WriteLine($"ctor({ctor.GetParameters()[0].ParameterType.Name})");
}
foreach (PropertyInfo property in type.GetProperties())
{
    Console.WriteLine($"{property.PropertyType.Name} {property.Name}");
}

public sealed record Receipt(decimal Charged, int Items);
public sealed record CartEmpty;
public sealed record OutOfStock(string Isbn);
public union PlaceOrderResult(Receipt, CartEmpty, OutOfStock);
CartEmpty { }
struct: True, [Union]: True, IUnion: True
ctor(Receipt)
ctor(CartEmpty)
ctor(OutOfStock)
Object Value

Microsoft Learn'deki union referansının anlattığı yapı çıktıda görünüyor: derleyici union bildirimini bir struct'a çeviriyor, onu System.Runtime.CompilerServices altındaki [Union] attribute'u ve IUnion interface'iyle işaretliyor (ikisi de .NET 11 runtime'ında), her case type için tek parametreli bir constructor ve içeriği object? olarak tutan bir Value property'si üretiyor. Bu constructor'lar sayesinde her case type'tan union'a implicit dönüşüm var; ilk satırdaki atama bu yüzden derleniyor.

Union üzerindeki bir type pattern (CartEmpty, Receipt r) union'ı değil, içindeki değeri test ediyor; result.Value is CartEmpty yazmana gerek yok. Bütün case type'ları kapsayan bir switch exhaustive sayılıyor. Struct olmanın iki bedeli var: int gibi value type case'ler box'lanıyor ve default bir union'ın Value'su null. Gerekirse [Union] attribute'u ile kendi union tipini, istersen class olarak, yazabiliyorsun.

closed: Hiyerarşiyi Assembly'ye Kilitlemek

closed ise var olan bir class hiyerarşisini kapatıyor. Kural kısa: bir closed class'tan doğrudan türeyen class'lar, base class'la aynı assembly'de tanımlanmak zorunda. Bunu iki projeyle denedik:

// Bookstore.Orders projesi
namespace Bookstore.Orders;

public closed record OrderState;
public sealed record PendingPayment : OrderState;
public record Paid(decimal Amount) : OrderState;

// Bookstore.Plugins projesi (Bookstore.Orders'a referans veriyor)
using Bookstore.Orders;

Console.WriteLine(new PaidByGiftCard(10m, "GC-1"));

public sealed record Refunded : OrderState;
public sealed record PaidByGiftCard(decimal Amount, string Code) : Paid(Amount);
Program.cs(5,22): error CS9382: 'Refunded': cannot use a closed type 'OrderState' from another assembly as a base type.

Tek hata Refunded için: başka bir assembly kapalı OrderState'ten türeyemiyor. PaidByGiftCard ise derlendi, çünkü kapalılık geçişli değil; Paid'i de korumak istiyorsan onu sealed ya da closed yapmalısın. Exhaustiveness bozulmuyor: Paid kolu PaidByGiftCard'ı da yakalıyor.

Diğer kurallar: closed bir class örtük olarak abstract. sealed, static ya da açık abstract ile birlikte kullanılamıyor (sealed closed denediğimizde derleyici CS9381 verdi). Yalnızca class'lara, record class'lar dahil, uygulanıyor. closed contextual bir keyword olduğu için onu değişken adı olarak kullanan eski kod kırılmıyor.

İkisi arasındaki seçim tek bir soruya iniyor. Tipler zaten ortak bir anlam ve ortak member'lar paylaşıyorsa (her sipariş durumu bir OrderState) closed hiyerarşi; tipler birbirinden bağımsızsa ve yalnızca belirli bir noktada "şunlardan biri" olarak bir araya geliyorlarsa (bir method'un olası sonuçları, bir kuyruğa düşen komutlar) union.

Visitor'ın Yeniden Yazımı

Visitor yazısındaki belge modelini closed ile yeniden kuralım. Element'lerde Accept yok, IDocumentVisitor interface'i yok; üç işlemin her biri tek bir switch expression:

namespace Bookstore.Documents;

// Accept yok, visitor interface'i yok; hiyerarşi bu assembly'de kapalı
public closed record DocumentElement;
public sealed record Paragraph(string Text) : DocumentElement;
public sealed record Table(string[] Header, string[][] Rows) : DocumentElement;
public sealed record Image(string Path, int WidthPx) : DocumentElement;
namespace Bookstore.Documents;

// Her işlem eskiden bir visitor class'ıydı; şimdi tek bir switch expression
public static class Html
{
    public static string Render(DocumentElement element) => element switch
    {
        Paragraph p => $"<p>{p.Text}</p>",
        Table t => $"<table>{t.Header.Length} columns, {t.Rows.Length} rows</table>",
        Image i => $"<img src=\"{i.Path}\" width=\"{i.WidthPx}\">",
    };
}

public static class PlainText
{
    public static string Render(DocumentElement element) => element switch
    {
        Paragraph p => p.Text,
        Table t => string.Join(Environment.NewLine, new[] { t.Header }.Concat(t.Rows).Select(r => string.Join(" | ", r))),
        Image i => $"[image: {i.Path}]",
    };
}

public static class WordCount
{
    public static int Count(DocumentElement element) => element switch
    {
        Paragraph p => Words(p.Text),
        Table t => t.Header.Concat(t.Rows.SelectMany(r => r)).Sum(Words),
        Image => 0,
    };

    private static int Words(string text) => text.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
}
using Bookstore.Documents;

DocumentElement[] invoice =
[
    new Paragraph("Order #1042 for Ada Lovelace"),
    new Table(["Title", "Qty"], [["Dune", "2"], ["Emma", "1"]]),
    new Image("logo.png", 120),
];

foreach (DocumentElement element in invoice)
{
    Console.WriteLine(Html.Render(element));
}
Console.WriteLine();
Console.WriteLine(string.Join(Environment.NewLine, invoice.Select(PlainText.Render)));
Console.WriteLine();
Console.WriteLine($"Words: {invoice.Sum(WordCount.Count)}");
<p>Order #1042 for Ada Lovelace</p>
<table>2 columns, 2 rows</table>
<img src="logo.png" width="120">

Order #1042 for Ada Lovelace
Title | Qty
Dune | 2
Emma | 1
[image: logo.png]

Words: 11

Çıktı, Visitor yazısındaki üç visitor'ın çıktısıyla aynı; kelime sayısı yine 11. Şimdi Visitor yazısındaki deneyi tekrarlayalım ve Documents.cs'e yeni element'i ekleyelim, işlemlere dokunmadan:

// Sonradan eklendi; switch'lere dokunulmadı
public sealed record Quote(string Text, string Author) : DocumentElement;
Operations.cs(16,69): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Bookstore.Documents.Quote' is not covered.
Operations.cs(26,65): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Bookstore.Documents.Quote' is not covered.
Operations.cs(6,69): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Bookstore.Documents.Quote' is not covered.

Visitor tasarımında aynı ekleme üç visitor class'ında üç CS0535 hatası üretmişti; burada üç switch'te üç CS8509 uyarısı var ve her biri eksik tipi adıyla söylüyor. Projeye <WarningsAsErrors>CS8509</WarningsAsErrors> eklediğimizde aynı satırlar error CS8509 olarak geldi ve derleme durdu; Visitor'ın garantisi birebir geri geldi.

Peki Visitor öldü mü? Hayır, yeri daraldı. closed ve switch, Visitor'ın expression problem'deki dengesini (yeni işlem ucuz, yeni tip pahalı) çok daha az kodla veriyor, ama yalnız hiyerarşi senin assembly'nde olduğunda. ExpressionVisitor ve Roslyn'in CSharpSyntaxWalker'ı gibi yüzlerce node tipi olan ve başkasının yazdığı yapılarda Visitor hâlâ doğru araç.

State'in Yeniden Yazımı

State yazısında state object'leri paylaşılan ve verisiz object'lerdi: ödenen tutar ve bekletme sebebi method'a parametre olarak gelip yalnızca log satırına düşüyordu. Onları saklamak için Order'a PaidAmount, HoldReason, TrackingNo gibi nullable alanlar eklemek gerekirdi ve ödeme bekleyen bir siparişin takip numarasını okumak derleyicinin gözünde yasal olurdu. closed hiyerarşide her durum kendi verisini taşıyor:

namespace Bookstore.Orders;

// Her durum yalnız kendi verisini taşıyor: kargo numarası yalnız Shipped'de var
public closed record OrderState;
public sealed record PendingPayment : OrderState;
public sealed record Paid(decimal Amount) : OrderState;
public sealed record OnHold(decimal Amount, string Reason) : OrderState;
public sealed record Shipped(string TrackingNo) : OrderState;
public sealed record Delivered : OrderState;
public sealed record Cancelled(string Reason) : OrderState;

// Komutların ortak bir base class'ı yok; union onları kapalı bir kümede topluyor
public sealed record Pay(decimal Amount);
public sealed record Hold(string Reason);
public sealed record Release;
public sealed record Ship(string TrackingNo);
public sealed record Deliver;
public sealed record Cancel(string Reason);
public union OrderCommand(Pay, Hold, Release, Ship, Deliver, Cancel);

public static class OrderWorkflow
{
    // Transition table: listede olmayan her geçiş reddedilir
    public static OrderState Apply(OrderState state, OrderCommand command) => (state, command) switch
    {
        (PendingPayment, Pay pay) => new Paid(pay.Amount),
        (Paid paid, Hold hold) => new OnHold(paid.Amount, hold.Reason),
        (OnHold held, Release) => new Paid(held.Amount),
        (Paid, Ship ship) => new Shipped(ship.TrackingNo),
        (Shipped, Deliver) => new Delivered(),
        (PendingPayment or Paid or OnHold, Cancel cancel) => new Cancelled(cancel.Reason),
        _ => throw new InvalidOperationException(
            $"Cannot {command.Value?.GetType().Name} an order that is {state.GetType().Name}"),
    };

    // Her durumun cevaplamak zorunda olduğu sorular: discard yok
    public static decimal Refundable(OrderState state) => state switch
    {
        Paid paid => paid.Amount,
        OnHold held => held.Amount,
        PendingPayment or Shipped or Delivered or Cancelled => 0m,
    };

    public static string Describe(OrderState state) => state switch
    {
        PendingPayment => "waiting for payment",
        Paid paid => $"paid {paid.Amount:0.00}",
        OnHold held => $"on hold ({held.Reason})",
        Shipped shipped => $"shipped, tracking {shipped.TrackingNo}",
        Delivered => "delivered",
        Cancelled cancelled => $"cancelled ({cancelled.Reason})",
    };
}
using Bookstore.Orders;

OrderState order = new PendingPayment();
OrderCommand[] script =
[
    new Pay(73m), new Hold("Fraud review"), new Ship("TR-5521"), new Release(), new Ship("TR-5521"), new Deliver(),
];

foreach (OrderCommand command in script)
{
    try
    {
        order = OrderWorkflow.Apply(order, command);
        Console.WriteLine($"{command.Value,-30} {OrderWorkflow.Describe(order)}, refundable {OrderWorkflow.Refundable(order):0.00}");
    }
    catch (InvalidOperationException ex)
    {
        Console.WriteLine($"{command.Value,-30} {ex.Message}");
    }
}
Pay { Amount = 73 }            paid 73.00, refundable 73.00
Hold { Reason = Fraud review } on hold (Fraud review), refundable 73.00
Ship { TrackingNo = TR-5521 }  Cannot Ship an order that is OnHold
Release { }                    paid 73.00, refundable 73.00
Ship { TrackingNo = TR-5521 }  shipped, tracking TR-5521, refundable 0.00
Deliver { }                    delivered, refundable 0.00

Takip numarası yalnızca Shipped'de, bekletme sebebi yalnızca OnHold'da; PendingPayment üzerinde TrackingNo okumak artık derlenmiyor. Ortak base class'ı olmayan komut record'larını OrderCommand union'ı topluyor ve Apply'daki Pay pay gibi type pattern'ler union'ın içindeki değere bakıyor.

İki tür switch bilerek farklı yazıldı. Apply bir transition table: listede olmayan geçiş reddediliyor, sondaki _ kolu State yazısındaki "varsayılan cevap izin yok" kuralının kendisi. Refundable ve Describe ise her durumun cevaplamak zorunda olduğu sorular; onlarda _ yok. State yazısının açılışındaki hikâyeyi tekrar oynattık: OnHold record'unu ekleyip başka hiçbir yere dokunmadan derlediğimizde derleyici şunu söyledi:

Orders.cs(35,65): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Bookstore.Orders.OnHold' is not covered.
Orders.cs(41,62): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Bookstore.Orders.OnHold' is not covered.

İade tutarı ve ekrandaki açıklama yeni durumu bilmediklerini derleme zamanında söyledi. Apply sessiz kaldı, çünkü _ kolu yeni durumu zaten "izin yok" olarak karşılıyor ve bu doğru davranış. State yazısında enum'a OnHold eklendiğinde ise iki method sessizce yanlış çalışmıştı.

Bu, State pattern'ini her yerde değiştirmiyor. State class'larında yeni durum tek bir class; closed ve switch'te her switch'e bir kol, ama derleyici onları gösteriyor. Durum başına karmaşık davranış varsa State class'ları, durumlar veri ve geçiş ağırlıklıysa closed hiyerarşi daha okunur.

Result'ın Yeniden Yazımı

Bir önceki yazıdaki Result<T>, hatayı Code ve Message taşıyan tek bir Error tipiyle temsil ediyordu. Çağıran, hangi hataların gelebileceğini imzadan değil, kodu okuyarak öğreniyordu. Union ile her olası sonuç imzada görünüyor:

Checkout checkout = new(stock: new() { ["9780441013593"] = 4, ["9780141439518"] = 0 });

Console.WriteLine(Http.ToResponse(checkout.Place(new Cart("ada", ["9780441013593"]), cardLimit: 50m)));
Console.WriteLine(Http.ToResponse(checkout.Place(new Cart("ada", []), cardLimit: 50m)));
Console.WriteLine(Http.ToResponse(checkout.Place(new Cart("ada", ["9780141439518"]), cardLimit: 50m)));
Console.WriteLine(Http.ToResponse(checkout.Place(new Cart("ada", ["9780441013593", "9780441013593"]), cardLimit: 20m)));

public sealed record Cart(string CustomerId, string[] Isbns);

// Olası her sonuç kendi tipi ve kendi verisiyle
public sealed record Receipt(decimal Charged, int Items);
public sealed record CartEmpty;
public sealed record OutOfStock(string Isbn);
public sealed record PaymentDeclined(decimal Total, decimal Limit);
public union PlaceOrderResult(Receipt, CartEmpty, OutOfStock, PaymentDeclined);

public sealed class Checkout(Dictionary<string, int> stock)
{
    private const decimal UnitPrice = 12.99m;

    public PlaceOrderResult Place(Cart cart, decimal cardLimit)
    {
        if (cart.Isbns.Length == 0)
        {
            return new CartEmpty();
        }

        string? missing = cart.Isbns.FirstOrDefault(isbn => stock.GetValueOrDefault(isbn) <= 0);
        if (missing is not null)
        {
            return new OutOfStock(missing);
        }

        decimal total = cart.Isbns.Length * UnitPrice;
        return total <= cardLimit ? new Receipt(total, cart.Isbns.Length) : new PaymentDeclined(total, cardLimit);
    }
}

public static class Http
{
    // Çağıran, imzadaki her sonucu ele almak zorunda
    public static string ToResponse(PlaceOrderResult result) => result switch
    {
        Receipt r => $"201 Created           charged {r.Charged:0.00} for {r.Items} item(s)",
        CartEmpty => "400 Bad Request       the cart has no items",
        OutOfStock o => $"409 Conflict          {o.Isbn} is out of stock",
        PaymentDeclined d => $"402 Payment Required  {d.Total:0.00} exceeds the limit of {d.Limit:0.00}",
    };
}
201 Created           charged 12.99 for 1 item(s)
400 Bad Request       the cart has no items
409 Conflict          9780141439518 is out of stock
402 Payment Required  25.98 exceeds the limit of 20.00

Place'in dönüş tipi dört olası sonucu söylüyor ve her biri kendi verisini taşıyor: stoksuz kitabın ISBN'i (International Standard Book Number), reddedilen tutar, kart limiti. return new CartEmpty(); implicit dönüşümle derleniyor, üçlü operatör de iki farklı record'u aynı union'a çevirebiliyor. ToResponse'ta _ yok; yarın bir CouponExpired eklenirse HTTP (Hypertext Transfer Protocol) cevabını üreten bu method derleme zamanında uyaracak.

Önceki yazıdaki Then zincirini de kaybetmek zorunda değilsin. Union bildirimi bir gövde alabiliyor; instance field ve auto-property tanımlayamıyorsun, ama method yazabiliyorsun. Case type'lardan biri bir type parameter da olabiliyor:

Result<Cart> ok = new Cart("ada", ["9780441013593"]);
Result<Cart> empty = new Cart("ada", []);

Console.WriteLine(Describe(ok.Then(Validate).Then(Charge)));
Console.WriteLine(Describe(empty.Then(Validate).Then(Charge)));

static Result<Cart> Validate(Cart cart) =>
    cart.Isbns.Length == 0 ? new Error("cart.empty", "The cart has no items.") : cart;

static Result<decimal> Charge(Cart cart) => cart.Isbns.Length * 12.99m;

static string Describe(Result<decimal> result) => result switch
{
    decimal total => $"OK    charged {total:0.00}",
    Error error => $"FAIL  {error.Code}: {error.Message}",
};

public sealed record Cart(string CustomerId, string[] Isbns);
public sealed record Error(string Code, string Message);

public union Result<T>(T, Error) where T : notnull
{
    // Başarılıysa sıradaki adıma geç, değilse hatayı olduğu gibi taşı
    public Result<TNext> Then<TNext>(Func<T, Result<TNext>> next) where TNext : notnull => this switch
    {
        T value => next(value),
        Error error => error,
    };
}
OK    charged 12.99
FAIL  cart.empty: The cart has no items.

IsSuccess, Value! ve Error! ünlemleri ve private constructor gitti; Then iki kollu bir switch. Hangisini seçmeli? Hata türleri bir sınırda farklı muamele görüyorsa (farklı HTTP kodu, farklı mesaj) PlaceOrderResult gibi her sonucu ayrı case yapan bir union; adımları zincirleyip hatayı yalnızca taşıman yetiyorsa generic Result<T>. Veritabanı bağlantısının kopması ise hâlâ bir exception.

Sık Düşülen Tuzaklar

default bir union'ı unutmak. Union bir struct ve hiç atanmamış bir union'ın Value'su null:

PlaceOrderResult[] results = new PlaceOrderResult[2];
results[0] = new CartEmpty();
// results[1] hiç atanmadı: default(PlaceOrderResult)

foreach (PlaceOrderResult result in results)
{
    Console.WriteLine(ToResponse(result));
}

static string ToResponse(PlaceOrderResult result) => result switch
{
    Receipt r => $"201 Created  charged {r.Charged:0.00}",
    CartEmpty => "400 Bad Request  the cart has no items",
    OutOfStock o => $"409 Conflict  {o.Isbn} is out of stock",
};

public sealed record Receipt(decimal Charged, int Items);
public sealed record CartEmpty;
public sealed record OutOfStock(string Isbn);
public union PlaceOrderResult(Receipt, CartEmpty, OutOfStock);
400 Bad Request  the cart has no items
Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException: Non-exhaustive switch expression failed to match its input.
Unmatched value was PlaceOrderResult.

Derleyici uyarmadı, çünkü bütün case type'lar non-nullable olduğunda nullable analizi union'ın içini "null değil" kabul ediyor; hiç atanmamış dizi elemanları ve field'lar bu kapıdan girer. Çözüm bir null kolu: null => "500 Internal Server Error the result was never set" eklediğimizde ikinci satır o metni yazdı. _ değil null yaz ki yeni case uyarısı korunsun.

Exhaustive bir switch'e alışkanlıkla _ kolu eklemek. _ kolu her şeyi yakalar, yarın eklenecek case'i de. Önce Tahmin Et'teki kazancı geri vermenin en kısa yolu bu. _'yi yalnızca Apply'daki gibi "listede olmayan her şey" anlamında bilerek kullan.

CS8509'u hata sanmak. Uyarı olarak kaldığı sürece build sunucusu yeşil yanar ve eksik kol production'da SwitchExpressionException olarak ortaya çıkar. closed ve union kullanan projelerde <WarningsAsErrors>CS8509</WarningsAsErrors> satırı, bu yazıdaki garantinin gerçek bir garantiye dönüşmesi için gerekli.

closed'ın alt seviyelere de yayıldığını sanmak. Paid örneğinde gördüğün gibi kapalılık yalnızca doğrudan alt tipler için geçerli. Ara seviyedeki bir class'ı başkalarının genişletmesini istemiyorsan onu da sealed ya da closed yap.

Generic union'da case'leri çakıştırmak. Result<T>(T, Error)'u Result<Error> olarak kullanınca iki case aynı tipe dönüştü ve new Error(...) ataması CS0457 ("ambiguous user defined conversions") verdi.

Ne Zaman Kullanma

Hiyerarşi gerçekten açıksa closed. Plugin'lerin ya da başka ekiplerin alt tip eklemesi gereken bir yapıyı (ödeme sağlayıcısı, rapor formatı) kapatmak genişletme noktasını yok eder; orada virtual method hâlâ doğru cevap.

Ortak davranış zaten bir interface'teyse union. Çağıran hangi case'in geldiğini hiç sormuyorsa interface ve virtual dispatch daha az kod; union, her case'in ayrı ele alınması gerektiğinde kendini öder.

İki case için. "Bulundu ya da bulunamadı" için bir union kurmak yerine nullable bir dönüş tipi ya da Try pattern genellikle yeter; Clean Code serisinin "Clean Code'da Error Handling: Exception, Try Pattern ve Result Pattern" yazısındaki kural burada da geçerli.

Pattern'in kendisi için. Anti-Pattern'ler yazısındaki pattern hastalığının C# 15 sürümü, her enum'u bir closed hiyerarşiye, her dönüş tipini bir union'a çevirmek olur. Davranışı olmayan bir durum listesi için enum hâlâ yeterli.

Küçük Bir Deneme

★☆☆ Önce Tahmin Et'teki Html.Render'dan genel Image i kolunu sil ve yalnız Image { WidthPx: > 600 } i kolunu bırak. Derleyici switch'i hâlâ exhaustive sayıyor mu? Uyarının örnek olarak gösterdiği değer ne?

★★☆ Sipariş modeline Refunded(decimal Amount) durumunu ve Refund komutunu ekle. Hangi switch'ler uyardı, hangisi sessiz kaldı? Sonra Apply'ı, dıştaki switch durum üzerinde exhaustive olacak şekilde (her durum kendi izinli komutlarını içeride ele alsın) yeniden yaz ve yeni bir durumun nerede gözden kaçabileceğini karşılaştır.

★★★ [Union] attribute'u ile class tabanlı kendi Result<T> tipini yaz: case başına bir constructor ve Value property'si yeterli. Then'e bir de ThenAsync ekle. Generated union ile class tabanlı union'ın default davranışı arasındaki fark ne?

Bu yazıyla Design Patterns serisi tamamlandı: GoF'un 23 pattern'i, anti-pattern'ler, GoF dışındaki pattern'ler ve dilin bu cevapları nasıl değiştirdiği. Sıradaki doğal adım sistem seviyesi: CQRS (Command Query Responsibility Segregation) ve Event Sourcing serisinin "Decider Pattern ile Event-Sourced Aggregate: decide, evolve ve Given-When-Then Test'leri" yazısını bu gözle tekrar oku; komutlar ve event'ler birer union, aggregate'in durumu bir closed hiyerarşi olmaya çok uygun.