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

State Pattern: Sipariş Durumlarını switch Yerine State Object'leriyle Yönetmek

Mert Özen 7 Kas 2026 13 dk 2 görüntülenme
State Pattern: Sipariş Durumlarını switch Yerine State Object'leriyle Yönetmek

State pattern, durumla değişen davranışı her method'daki switch'ten alıp durum başına bir class'a taşır; base class'ın varsayılan cevabı "izin yok" olunca unutulan bir durum sessiz bir hata değil, gürültülü bir exception olur. Yeni eklenen OnHold durumunun kargoya izin verdiği naif koddan başlayıp state class'larını, derleyicinin async state machine'ini, tuple pattern'li switch expression alternatifini ve EF Core'da enum kolondan state object'ini yeniden kurmayı gerçek çıktılarla görüyoruz.

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

Önce Tahmin Et

Bookstore'un siparişleri şimdiye kadar beş durumdan geçiyordu: ödeme bekliyor, ödendi, kargoya verildi, teslim edildi, iptal edildi. Dolandırıcılık ekibi bir durum daha istedi: şüpheli siparişler incelenene kadar OnHold'da beklesin. Geliştirici enum'a yeni değeri ve bir Hold method'u ekledi. Diğer method'lara dokunmadı, çünkü derleyici bir şey söylemedi:

public void Ship()
{
    // Yasaklı durumlar listesi: OnHold eklendiğinde kimse buraya dokunmadı
    if (Status is OrderStatus.PendingPayment or OrderStatus.Cancelled or OrderStatus.Shipped or OrderStatus.Delivered)
        throw new InvalidOperationException($"Cannot ship an order that is {Status}");
    Status = OrderStatus.Shipped;
}

public void Cancel(string reason)
{
    switch (Status)
    {
        case OrderStatus.PendingPayment:
            Status = OrderStatus.Cancelled;
            break;
        case OrderStatus.Paid:
            // iade süreci burada başlıyor
            Status = OrderStatus.Cancelled;
            break;
        default:
            throw new InvalidOperationException($"Cannot cancel an order that is {Status}");
    }
}

Bookstore.Orders.Legacy.Order order = new(1001);
order.Pay(73m);
order.Hold("Fraud review");
order.Ship();
Console.WriteLine($"After Ship: {order.Status}");

Bookstore.Orders.Legacy.Order other = new(1002);
other.Pay(18m);
other.Hold("Address check");
try
{
    other.Cancel("Customer request");
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"Cancel failed: {ex.Message}");
}

İncelemeye alınmış 1001 numaralı siparişi depo yanlışlıkla kargolamaya çalışıyor. Sistem buna izin verir mi? Adres kontrolü için bekletilen 1002 numaralı siparişi müşteri iptal etmek istiyor. İptal başarılı olur mu?

Her Method'da Aynı Soru

Gerçek çıktı:

After Ship: Shipped
Cancel failed: Cannot cancel an order that is OnHold

İki hata, iki yönde. Ship yasaklı durumları sayıyordu; OnHold listede olmadığı için dolandırıcılık incelemesindeki sipariş kargoya verildi. Cancel ise izinli durumları sayıyordu; OnHold orada da olmadığı için müşteri, henüz gönderilmemiş siparişini iptal edemedi. Derleyici ikisinde de sessizdi, çünkü if zincirleri ve default'lu switch bloklarının enum'a yeni bir değer eklendiğini fark etmesi için bir sebep yok.

Asıl problem şu: "sipariş şu an hangi durumda ve bu durumda ne yapabilir?" sorusu, Order'ın her method'unda yeniden soruluyor. Altı method ve altı durum, otuz altı hücreli bir tablo demek; ama bu tablo hiçbir yerde tek parça hâlinde durmuyor, method'lara dağılmış. Yeni bir durum eklemek, her method'u tek tek açıp "bu durumda ne olmalı?" diye düşünmeyi gerektiriyor ve unutulan her method sessiz bir hata.

GoF'un Tanımı: Durum Değişince Class Değişmiş Gibi

GoF (Gang of Four: Gamma, Helm, Johnson ve Vlissides'in 1994 tarihli Design Patterns kitabı) State'i behavioral pattern'ler arasında sayar. Niyeti, bir object'in iç durumu değiştiğinde davranışının da değişmesine izin vermek; dışarıdan bakan için object sanki class'ını değiştirmiş gibi görünür. Roller: Context (dışarıya açık olan object, bizim Order), State (her durumun cevap vereceği interface) ve ConcreteState (her durum için ayrı bir class). Context, gelen her isteği o anki state object'ine devreder. Kitabın örneği bir TCP (Transmission Control Protocol) bağlantısı: aynı Open çağrısı bağlantı kapalıyken bir şey, dinlerken başka bir şey, kurulmuşken bambaşka bir şey yapar.

GoF üç uygulama sorusunu da açıkça tartışır. Geçişleri kim tanımlar: context mi, state'lerin kendisi mi? State'ler tanımlarsa yeni bir geçiş eklemek kolaylaşır ama state'ler birbirini tanır. State object'leri ne zaman oluşturulur: her geçişte mi, baştan bir kez mi? Ve state'in kendi field'ı yoksa tek bir instance'ı bütün context'ler paylaşabilir; kitap bunu Flyweight ile ilişkilendirir. Kitap ayrıca tablo tabanlı bir alternatiften de söz eder: geçişleri bir tabloda tutmak, kuralları koddan veriye taşır ama her geçişe davranış bağlamayı zorlaştırır. Aşağıda üç soruya da cevap veriyoruz.

State Class'ları: Varsayılan Cevap "İzin Yok"

public enum OrderStatus { PendingPayment, Paid, OnHold, Shipped, Delivered, Cancelled }

// Context: davranışı o anki state object'ine devrediyor
public sealed class Order
{
    private OrderState? _state;

    public Order(int id) => Id = id;

    public int Id { get; private set; }
    public OrderStatus Status { get; private set; } = OrderStatus.PendingPayment;

    // Veritabanından yalnız Status gelir; state object'i ilk kullanımda yeniden kuruluyor
    private OrderState State => _state ??= OrderState.From(Status);

    public void Pay(decimal amount) => State.Pay(this, amount);
    public void Hold(string reason) => State.Hold(this, reason);
    public void Release() => State.Release(this);
    public void Ship() => State.Ship(this);
    public void Deliver() => State.Deliver(this);
    public void Cancel(string reason) => State.Cancel(this, reason);

    internal void TransitionTo(OrderState next, string? note = null)
    {
        Console.WriteLine($"  #{Id} {Status} -> {next.Status}{(note is null ? "" : $" ({note})")}");
        _state = next;
        Status = next.Status;
    }
}

// State: varsayılan cevap "izin yok"; her state yalnız izin verdiği geçişi override ediyor
public abstract class OrderState
{
    private static readonly Dictionary<OrderStatus, OrderState> All = new()
    {
        [OrderStatus.PendingPayment] = new PendingPaymentState(),
        [OrderStatus.Paid] = new PaidState(),
        [OrderStatus.OnHold] = new OnHoldState(),
        [OrderStatus.Shipped] = new ShippedState(),
        [OrderStatus.Delivered] = new DeliveredState(),
        [OrderStatus.Cancelled] = new CancelledState(),
    };

    public static OrderState From(OrderStatus status) => All[status];

    public abstract OrderStatus Status { get; }

    public virtual void Pay(Order order, decimal amount) => throw NotAllowed("pay");
    public virtual void Hold(Order order, string reason) => throw NotAllowed("hold");
    public virtual void Release(Order order) => throw NotAllowed("release");
    public virtual void Ship(Order order) => throw NotAllowed("ship");
    public virtual void Deliver(Order order) => throw NotAllowed("deliver");
    public virtual void Cancel(Order order, string reason) => throw NotAllowed("cancel");

    private InvalidOperationException NotAllowed(string action) =>
        new($"Cannot {action} an order that is {Status}");

    private sealed class PendingPaymentState : OrderState
    {
        public override OrderStatus Status => OrderStatus.PendingPayment;
        public override void Pay(Order order, decimal amount) => order.TransitionTo(From(OrderStatus.Paid), $"{amount}");
        public override void Cancel(Order order, string reason) => order.TransitionTo(From(OrderStatus.Cancelled), reason);
    }

    private sealed class PaidState : OrderState
    {
        public override OrderStatus Status => OrderStatus.Paid;
        public override void Hold(Order order, string reason) => order.TransitionTo(From(OrderStatus.OnHold), reason);
        public override void Ship(Order order) => order.TransitionTo(From(OrderStatus.Shipped));
        public override void Cancel(Order order, string reason) => order.TransitionTo(From(OrderStatus.Cancelled), $"{reason}, refund");
    }

    private sealed class OnHoldState : OrderState
    {
        public override OrderStatus Status => OrderStatus.OnHold;
        public override void Release(Order order) => order.TransitionTo(From(OrderStatus.Paid));
        public override void Cancel(Order order, string reason) => order.TransitionTo(From(OrderStatus.Cancelled), $"{reason}, refund");
    }

    private sealed class ShippedState : OrderState
    {
        public override OrderStatus Status => OrderStatus.Shipped;
        public override void Deliver(Order order) => order.TransitionTo(From(OrderStatus.Delivered));
    }

    private sealed class DeliveredState : OrderState
    {
        public override OrderStatus Status => OrderStatus.Delivered;
    }

    private sealed class CancelledState : OrderState
    {
        public override OrderStatus Status => OrderStatus.Cancelled;
    }
}

Açılıştaki hatanın tersine çevrildiği yer base class: her aksiyonun varsayılan cevabı "izin yok". Bir state yalnız izin verdiği geçişleri override ediyor. Yeni bir durum eklediğinde, sen açıkça izin vermedikçe o durumda hiçbir aksiyon çalışmaz; unutmanın sonucu sessiz bir kargo değil, gürültülü bir exception olur. GoF'un üç sorusuna cevaplarımız da kodda: geçişleri state'ler tanımlıyor (PaidState, Ship'in nereye gittiğini biliyor), state object'leri baştan bir kez oluşturuluyor ve field'ları olmadığı için bütün siparişler aynı instance'ları paylaşıyor. State class'ları private nested; dışarıdaki kod yalnız Order'ın method'larını ve Status enum'ını görüyor.

20-order-states-tr.png

Order order = new(1001);
order.Pay(73m);
order.Hold("Fraud review");
Try(() => order.Ship());
order.Release();
order.Ship();
order.Deliver();
Try(() => order.Cancel("Too late"));
Console.WriteLine($"Final: {order.Status}");

Order other = new(1002);
other.Pay(18m);
other.Hold("Address check");
other.Cancel("Customer request");
Console.WriteLine($"Same state instance: {ReferenceEquals(OrderState.From(OrderStatus.Paid), OrderState.From(OrderStatus.Paid))}");
  #1001 PendingPayment -> Paid (73)
  #1001 Paid -> OnHold (Fraud review)
  rejected: Cannot ship an order that is OnHold
  #1001 OnHold -> Paid
  #1001 Paid -> Shipped
  #1001 Shipped -> Delivered
  rejected: Cannot cancel an order that is Delivered
Final: Delivered
  #1002 PendingPayment -> Paid (18)
  #1002 Paid -> OnHold (Address check)
  #1002 OnHold -> Cancelled (Customer request, refund)
Same state instance: True

İki açılış senaryosu da artık doğru: incelemedeki sipariş kargolanamıyor, bekleyen sipariş iptal edilebiliyor ve iptal notuna iadenin başladığı yazılıyor. Try burada exception'ı yakalayıp "rejected" satırı basan küçük bir yardımcı.

.NET'in İçindeki State Machine: Derleyicinin switch'i

.NET'te en çok çalışan state machine'leri sen yazmıyorsun, derleyici yazıyor. Her async method ve her yield return'lü iterator, derleyicinin ürettiği gizli bir state machine tipine dönüşür. Bir async method'un o tipini reflection ile bulalım:

public sealed class Warehouse
{
    public async Task<string> ShipAsync(int orderId)
    {
        await Task.Delay(10);
        string label = $"LBL-{orderId}";
        await Task.Delay(10);
        return label;
    }
}

MethodInfo method = typeof(Warehouse).GetMethod(nameof(Warehouse.ShipAsync))!;
Type machine = method.GetCustomAttribute<AsyncStateMachineAttribute>()!.StateMachineType;
Console.WriteLine($"{machine.Name} ({(machine.IsValueType ? "struct" : "class")})");
foreach (FieldInfo field in machine.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
    Console.WriteLine($"  {field.FieldType.Name} {field.Name}");
<ShipAsync>d__0 (class)
  Int32 <>1__state
  AsyncTaskMethodBuilder`1 <>t__builder
  Int32 orderId
  Warehouse <>4__this
  String <label>5__1
  TaskAwaiter <>u__1

Debug build'de class, Release build'de (aynı kodu -c Release ile çalıştırınca) struct. Durum, <>1__state adlı tek bir int field'ında tutuluyor ve MoveNext method'u bu sayının üzerinde bir switch ile hangi await'ten sonra devam edeceğine karar veriyor. Yani .NET'in kendisi GoF'un State pattern'ini değil, tam olarak bu yazının başında eleştirdiğimiz enum + switch yaklaşımını kullanıyor. Çelişki değil: bu kodu hiçbir insan elle değiştirmiyor; derleyici her seferinde bütün durumları baştan üretiyor ve "bir durumu unutmak" diye bir risk yok. State pattern'in çözdüğü problem bir bakım problemi; kodu insanlar büyütüyorsa anlamlı. Ayrıntılar C# Async serisindeki "C# async/await Nasıl Çalışır: State Machine ve Continuation" yazısında.

Alternatif: Tek Bir switch Expression'da Bütün Geçişler

State pattern her durumu bir class'a dağıtıyor. Geçişlerin bir davranışı yoksa, yani yalnız "hangi durumdan hangi aksiyonla nereye gidilir" sorusuna cevap arıyorsan, C#'ın tuple pattern'leri bütün tabloyu tek bir yerde okunur kılıyor:

public enum OrderTrigger { Pay, Hold, Release, Ship, Deliver, Cancel }

// Alternatif: bütün geçişler tek bir switch expression'da, veri gibi okunuyor
public static class OrderTransitions
{
    public static OrderStatus? TryNext(OrderStatus current, OrderTrigger trigger) => (current, trigger) switch
    {
        (OrderStatus.PendingPayment, OrderTrigger.Pay) => OrderStatus.Paid,
        (OrderStatus.PendingPayment, OrderTrigger.Cancel) => OrderStatus.Cancelled,
        (OrderStatus.Paid, OrderTrigger.Hold) => OrderStatus.OnHold,
        (OrderStatus.Paid, OrderTrigger.Ship) => OrderStatus.Shipped,
        (OrderStatus.Paid, OrderTrigger.Cancel) => OrderStatus.Cancelled,
        (OrderStatus.OnHold, OrderTrigger.Release) => OrderStatus.Paid,
        (OrderStatus.OnHold, OrderTrigger.Cancel) => OrderStatus.Cancelled,
        (OrderStatus.Shipped, OrderTrigger.Deliver) => OrderStatus.Delivered,
        _ => null,
    };

    public static OrderStatus Next(OrderStatus current, OrderTrigger trigger) =>
        TryNext(current, trigger) ?? throw new InvalidOperationException($"Cannot {trigger} an order that is {current}");

    public static IEnumerable<OrderTrigger> Allowed(OrderStatus current) =>
        Enum.GetValues<OrderTrigger>().Where(t => TryNext(current, t) is not null);
}
PendingPayment --Pay--> Paid
Paid --Hold--> OnHold
Cannot Ship an order that is OnHold
Allowed from OnHold: Release, Cancel

Buradaki discard (_ => null) açılıştaki default'tan farklı bir iş yapıyor: listede olmayan her şey reddediliyor, yani yine "varsayılan cevap izin yok". Sekiz satır, şekildeki sekiz okun birebir karşılığı ve Allowed ile "bu siparişte hangi butonları göstereyim?" sorusu bedavaya geliyor. Tuple pattern'lerin ayrıntısı C# İleri serisindeki "C#'ta Pattern Matching Derinlemesine: Property, Positional ve Tuple Pattern'ler" yazısında. C# 15 ile gelen union type'lar ve closed hierarchy'ler bu tür switch'lerde derleyiciye eksik durumu yakalatmayı kolaylaştırıyor; onu serinin son yazısında göreceğiz.

Kurallar sık değişiyor, rol bazlı yetki içeriyor ya da deploy beklemeden güncellenmesi gerekiyorsa geçişleri koddan veritabanına taşımak ve bir Stateless gibi bir state machine library'si kullanmak iki seçenek daha. İkisini, history tablosu ve concurrency ile birlikte Çözüm Desenleri serisinin "Status Field'ı Her Yerden Güncelleniyorsa: Transition Table, State Machine ve Status History" yazısında uçtan uca kurduk; burada tekrar etmiyoruz. O yazıdaki kriterlere bu yazının seçeneğini ekleyen kural kısaca şu: state'e göre değişen gerçek davranış (hesaplama, yan etki, farklı doğrulama) varsa State pattern, yalnız izinli geçişler varsa switch expression ya da tablo.

Persist Etmek: Enum Kolon ve State Object'ini Yeniden Kurmak

State object'leri veritabanına yazılmaz; yazılan şey durumun adıdır. Bizim Order'ımızda bunun için iki ayrı üye var: Entity Framework Core (EF Core) tarafından kalıcı hâle getirilen Status enum property'si ve map edilmeyen, private State property'si. Siparişi veritabanından okuduğunda yalnız Status dolu gelir; State ilk kullanımda OrderState.From(Status) ile paylaşılan instance'a bağlanır. Configuration:

public sealed class BookstoreDb(DbContextOptions<BookstoreDb> options) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder) =>
        modelBuilder.Entity<Order>(e =>
        {
            e.Property(o => o.Id).ValueGeneratedNever();
            e.Property(o => o.Status).HasConversion<string>().HasMaxLength(20);
        });
}

using (BookstoreDb db = new(options))
{
    db.Database.EnsureCreated();
    Console.WriteLine(db.Database.GenerateCreateScript().Trim());
    Order paid = new(1001);
    paid.Pay(73m);
    Order cancelled = new(1002);
    cancelled.Cancel("Customer request");
    db.Orders.AddRange(paid, cancelled);
    db.SaveChanges();
}

Console.WriteLine(string.Join(" | ", ReadRows(connection)));

using (BookstoreDb db = new(options))
{
    Order paid = db.Orders.Single(o => o.Id == 1001);
    paid.Ship();
    Order cancelled = db.Orders.Single(o => o.Id == 1002);
    Try(() => cancelled.Ship());
    db.SaveChanges();
}

Console.WriteLine(string.Join(" | ", ReadRows(connection)));
CREATE TABLE "Orders" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Orders" PRIMARY KEY,
    "Status" TEXT NOT NULL
);
  #1001 PendingPayment -> Paid (73)
  #1002 PendingPayment -> Cancelled (Customer request)
1001=Paid | 1002=Cancelled
  #1001 Paid -> Shipped
  rejected: Cannot ship an order that is Cancelled
1001=Shipped | 1002=Cancelled

Örnekte SQLite'ın in-memory veritabanını kullandık; ReadRows tabloyu EF Core'u atlayarak düz bir SQL (Structured Query Language) sorgusuyla okuyor. Durum kolonda "Paid", "Cancelled" gibi okunur string'ler olarak duruyor; raporlama ekibi 2'nin ne demek olduğunu sormuyor. HasMaxLength(20) SQLite'ta bir şey değiştirmiyor ama SQL Server'da kolonu nvarchar(20) yapar. Yeni bir DbContext ile okunan 1001 kargolandı, iptal edilmiş 1002 için aynı çağrı reddedildi: state object'i veritabanından gelen enum'dan doğru kuruldu. EF Core siparişi Order(int id) constructor'ıyla oluşturup Status'ü private setter üzerinden dolduruyor; enum'un string'e çevrilmesi EF Core serisindeki "EF Core'da Owned Type ve Value Converter: Adres, Enum ve Strongly Typed Id" yazısında. Burada önemli bir kural var: veritabanındaki kolonu doğrudan UPDATE ile değiştiren bir script, state machine'in bütün kurallarını atlar. Bunun nasıl korunacağı yine Çözüm Desenleri'ndeki yazıda.

Sık Düşülen Tuzaklar

State'lerin birbirini fazla tanıması. Geçişleri state'lere verdiğimizde her state, gidebileceği state'leri biliyor. Bizim örneğimizde bunu From(OrderStatus.Paid) ile enum üzerinden yaptık, state class'ları birbirinin tipini hiç görmüyor. Concrete state'leri new PaidState() ile birbirinin içinde oluşturmaya başlarsan, durum sayısı arttıkça bir class ağı örersin.

State'e data koymak. OnHoldState'e "bekletme nedeni" field'ı eklemek cazip görünür, ama o zaman state artık paylaşılamaz ve her geçişte yeni instance gerekir. Siparişe ait data (neden, tarih, iade tutarı) context'te, yani Order'da durmalı; state yalnız davranış taşımalı.

Yan etkileri state'in içine gömmek. PaidState.Cancel'ın içinden ödeme sağlayıcısını çağırıp iade başlatmak, state'i test edilemez hâle getirir ve bir network hatasında siparişi yarım bir durumda bırakır. State geçişi kararı versin; iade, e-posta ve stok iadesi gibi işler bir önceki yazıdaki domain event'lerle, geçiş kaydedildikten sonra çalışsın.

Class patlaması. On iki durumlu ve her durumda iki aksiyonu olan bir akış için on iki class ve altmış satırlık base class, aynı bilgiyi taşıyan on beş satırlık bir switch expression'dan daha okunur değildir. State pattern'in değeri durumlar arasında farklılaşan davranışla orantılı.

Persist edilen enum değerinin anlamını değiştirmek. Durumları int olarak saklıyorsan enum'un ortasına yeni bir değer eklemek mevcut kayıtların anlamını kaydırır. String saklamak ya da enum değerlerine açık sayı vermek bu riski kapatır.

Ne Zaman Kullanma

İki üç durumlu, davranışı durumlara göre pek değişmeyen bir field için (IsActive, "okundu/okunmadı") State pattern kurma; bir bool ya da bir domain method'u yeter. Durumlar arasında yalnız izinli geçişler farklıysa ve davranış aynıysa, yukarıdaki switch expression daha az kodla aynı güvenliği verir. Geçiş kurallarını iş birimi değiştiriyorsa ve deploy beklemek istemiyorsa kural veridedir, kodda değil. State pattern'i, aynı method'un durumlara göre gerçekten farklı şeyler yaptığı yerde kullan: fiyat hesaplaması siparişin durumuna göre değişiyorsa, iptal bir durumda iade, diğerinde yalnız kayıt güncellemesiyse. State ile Strategy'nin yapısı neredeyse aynı; farkı ve hangisinin ne zaman seçileceği bir sonraki yazının konusu.

Küçük Bir Deneme

★☆☆ OrderState'e ReturnRequested durumunu ekle: yalnız Delivered'dan RequestReturn ile gelinsin, Cancel ile değil. Kaç dosyada, kaç method'u değiştirdin? Aynı değişikliği OrderTransitions'ta yap ve karşılaştır.

★★☆ OrderTransitions.Allowed'ın State pattern sürümünü yaz: her state class'ı izin verdiği aksiyonları bir IReadOnlyList<OrderTrigger> olarak döndürsün. Bu listenin override edilen method'larla tutarlı kaldığını garanti eden bir test nasıl yazılır?

★★★ Kargo ücretinin hesaplanmasını State'e taşı: PendingPayment'ta tahmini ücret, Paid'de kesinleşmiş ücret, Shipped'ten sonra kargo firmasından gelen gerçek ücret dönsün. Bu davranış switch expression sürümünde nasıl görünürdü? Hangi noktada State pattern'in kendini ödemeye başladığını değerlendir.

State'te davranışı seçen şey object'in kendi durumuydu. Bir sonraki yazıda seçimi dışarıdan, çalışma zamanında yapıyoruz: kargo firmasına ve bölgeye göre ücret hesaplayan Strategy'ler, .NET 8'in keyed service'leri, IEnumerable<IStrategy> ve Func ile üç seçim yöntemi.