Koşul Yığınından Okunur Tasarıma: Switch, Flag Parametreleri ve Kural Tabloları
İç içe koşulları dönüştürmenin tek doğru hedefi yoktur: yeni tipler sık geliyorsa polymorphism, yeni işlemler sık geliyorsa switch expression, kurallar sık değişiyor ve aynı biçimdeyse kural tablosu daha ucuzdur. Yazıda PricingEngine'in indirim koşulları üç hedefe de aynı 144 kombinasyonluk snapshot'tan geçerek dönüştürülüyor; enum, guard clause, bayrak parametresini bölmek ve Percentage tipi ile CS8509, CS0535 ve eksik kural testinin gerçek çıktıları gösteriliyor.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 (SDK 10.0.402) ile, testler xUnit.net v3 4.0.3 ve Verify.XunitV3 32.0.0 ile derlenip denenmiştir.
Önce Tahmin Et: Öğrenci İndirimi Nereye Eklenir?
Üçüncü yazının sonunda PricingEngine.Calculate okunur hâle geldi, ama iki method olduğu gibi kaldı. Müşteri tipi ve kampanyaya göre indirim:
private static decimal TierDiscount(int customerType, string? campaign, decimal subtotal, int bookCount)
{
decimal discount = 0;
if (customerType == 2)
{
if (campaign == "SUMMER")
discount = subtotal * 0.20m;
else
discount = subtotal * 0.10m;
}
else if (customerType == 1)
{
if (campaign == "SUMMER")
discount = subtotal * 0.15m;
else if (bookCount >= 5)
discount = subtotal * 0.05m;
}
else
{
if (campaign == "SUMMER" && subtotal >= 500)
discount = subtotal * 0.10m;
}
return discount;
}
Bir de kupon: CouponDiscount aynı biçimde iç içe if'lerden oluşuyor ve Calculate'in isPreview bayrağı tek method'a iki iş yaptırıyor (kargolu ödeme tutarı ve kargosuz teklif). Product Owner'dan iki talep geldi. Birincisi: yüzde 12 indirim alan yeni bir öğrenci müşteri tipi. İkincisi: her müşteri tipi için "sabit tutarlı kupon kabul ediliyor mu" sorusunun cevabı ayrı ayrı yönetilebilsin. Bu koşul yığınını ne tür bir yapıya çevirirsen ikinci talep birincisi kadar kolay olur? Yoksa her iki talebi aynı anda kolaylaştıran bir yapı yok mu?
Önce Zemin: Enum, Guard Clause ve Bayrağı Bölmek
Tasarım kararına geçmeden önce üç küçük ve mekanik adımla zemini düzeltiyoruz. Güvenlik ağı üçüncü yazıdaki 144 kombinasyonluk snapshot; her adım ayrı commit ve her adımdan sonra snapshot yeşil.
İlki int customerType'ı bir enum'a çevirmek. 0, 1 ve 2 sayıları okuyanı yorum satırına bakmaya zorluyordu:
public enum CustomerTier
{
Regular = 0,
Member = 1,
Vip = 2
}
public sealed record PriceRequest(List<CartLine> Lines, CustomerTier Tier, string? Campaign, string? Coupon);
Snapshot testinin girdileri [0, 1, 2] olarak kaldı; test sadece (CustomerTier)customerType dönüşümüyle yeni tipe uyarlandı. Böylece onaylı dosyanın tek satırı bile değişmedi ve "aynı girdiler, aynı çıktılar" iddiası kanıtlanmış oldu. Snapshot'ın anahtarlarını da güzelleştirmek istersen bunu ayrı bir test commit'inde yaparsın.
İkincisi kuponu guard clause'larla düzleştirmek. İç içe if'lerin her seviyesi okuyucunun kafasında bir koşul daha tutmasını gerektiriyor. Erken dönüşlerle her kural kendi satırına iniyor:
private static decimal CouponDiscount(string? coupon, CustomerTier tier, string? campaign, decimal subtotal)
{
if (string.IsNullOrEmpty(coupon))
return 0m;
if (coupon == "WELCOME10")
return tier == CustomerTier.Regular ? WelcomeCoupon.Of(subtotal) : 0m;
if (!coupon.StartsWith("FIX"))
return 0m;
// VIP müşteriye yaz kampanyasında sabit tutarlı kupon eklenmez.
if (tier == CustomerTier.Vip && campaign == "SUMMER")
return 0m;
return decimal.Parse(coupon[3..]);
}
Üçüncüsü bayrak parametresini bölmek. Calculate(request, isPreview: true) çağrısını okuyan biri true'nun ne anlama geldiğini bilmek için method'a gitmek zorunda. Bayrak, method'un aslında iki farklı soruya cevap verdiğini söylüyor: "kargo hariç tutar ne?" ve "ödenecek tutar ne?". İkisini adı olan iki method'a ayırıyoruz:
// Sepet önizlemesi ve teklif: kargo hariç.
public decimal Quote(PriceRequest request) => Math.Round(NetAmount(request), 2);
// Ödeme: kargo dahil.
public decimal Checkout(PriceRequest request)
{
decimal net = NetAmount(request);
net += ShippingPolicy.FeeFor(net);
return Math.Round(net, 2);
}
Çağıranlar aynı repo'da olduğu için tek commit'te taşındı; dış çağıranlar olsaydı üçüncü yazıdaki parallel change'i uygulardık. Şimdi asıl karar: TierDiscount'ı neye dönüştüreceğiz?
Hedef 1: Switch Expression ve Pattern Matching
C#'ın switch expression'ı, koşulların hepsini tek bir tabloymuş gibi yan yana okumayı sağlar. when ile aynı müşteri tipine ek koşul bağlanabiliyor:
private static decimal TierDiscount(CustomerTier tier, string? campaign, decimal subtotal, int bookCount)
{
bool summer = campaign == "SUMMER";
return tier switch
{
CustomerTier.Vip => subtotal * (summer ? 0.20m : 0.10m),
CustomerTier.Member when summer => subtotal * 0.15m,
CustomerTier.Member => bookCount >= 5 ? subtotal * 0.05m : 0m,
CustomerTier.Regular => summer && subtotal >= 500 ? subtotal * 0.10m : 0m,
};
}
Snapshot yeşil, ama derleyici bir uyarı veriyor:
src/Bookstore.Pricing/PricingEngine.cs(29,21): warning CS8524: The switch expression does not handle some values of its input type (it is not exhaustive) involving an unnamed enum value. For example, the pattern '(Bookstore.Pricing.CustomerTier)3' is not covered. [src/Bookstore.Pricing/Bookstore.Pricing.csproj]
C#'ta bir enum değişkeni, isim verilmemiş sayıları da tutabilir: (CustomerTier)3 geçerli bir değer. Bu uyarıyı susturmanın en yaygın yolu sona _ => throw ... gibi bir discard kolu eklemek. Ama discard kolu bedava değil: yarın biri enum'a yeni bir değer eklerse o değer de sessizce discard'a düşer ve hata ancak çalışma zamanında çıkar. Daha iyi bir yol, iki uyarıyı birbirinden ayırmak. CS8524 yalnız isimsiz değerler için, CS8509 ise isimli bir enum üyesi unutulduğunda gelir. .editorconfig'te birini kapatıp diğerini hataya yükseltiyoruz:
root = true
[*.cs]
# İsimsiz enum değerleri için uyarıyı kapat; isimli bir üye unutulursa build kırılsın.
dotnet_diagnostic.CS8524.severity = none
dotnet_diagnostic.CS8509.severity = error
Şimdi Product Owner'ın birinci talebini deneyelim ve enum'a Student = 3 ekleyelim. Başka hiçbir şeye dokunmadan build:
src/Bookstore.Pricing/PricingEngine.cs(29,21): error CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Bookstore.Pricing.CustomerTier.Student' is not covered. [src/Bookstore.Pricing/Bookstore.Pricing.csproj]
Derleyici, öğrenci indiriminin nereye yazılması gerektiğini bize söylüyor. Bu yaklaşımın bedeli de belli: dışarıdan gelen bir sayı tanımsız bir enum değerine dönüşürse switch, çalışma zamanında SwitchExpressionException fırlatır. Değeri sistemin sınırında, veritabanından ya da API'den okurken Enum.IsDefined ile doğrulamak bu yüzden önemli.
Hedef 2: Polymorphism ile Strategy
İkinci seçenek, her müşteri tipinin indirim kuralını kendi class'ına taşımak. Bu, Design Patterns serisinin "Strategy Pattern Derinlemesine: Keyed Services, IEnumerable<IStrategy> ve Func ile Runtime Seçim" yazısında anlatılan yapının ta kendisi; burada onu bir refactoring hedefi olarak kullanıyoruz:
public interface ITierPolicy
{
decimal Discount(decimal subtotal, int bookCount, bool summer);
}
public sealed class RegularTier : ITierPolicy
{
public decimal Discount(decimal subtotal, int bookCount, bool summer) =>
summer && subtotal >= 500 ? subtotal * 0.10m : 0m;
}
public sealed class MemberTier : ITierPolicy
{
public decimal Discount(decimal subtotal, int bookCount, bool summer) =>
summer ? subtotal * 0.15m
: bookCount >= 5 ? subtotal * 0.05m
: 0m;
}
public sealed class VipTier : ITierPolicy
{
public decimal Discount(decimal subtotal, int bookCount, bool summer) =>
subtotal * (summer ? 0.20m : 0.10m);
}
public static class TierPolicies
{
public static ITierPolicy For(CustomerTier tier) => tier switch
{
CustomerTier.Regular => new RegularTier(),
CustomerTier.Member => new MemberTier(),
CustomerTier.Vip => new VipTier(),
};
}
Snapshot yine yeşil. İki şeye dikkat. Birincisi, switch kaybolmadı; sistemin sınırına, enum'dan policy'ye geçilen tek bir yere taşındı. Polymorphism switch'i yok etmez, onu tekrar etmekten kurtarır. Aynı müşteri tipine göre karar veren beş farklı yer varsa beş switch yerine tek bir fabrika kalır. İkincisi, yeni bir müşteri tipi eklemek artık yeni bir class yazmak ve fabrikaya bir satır eklemek demek; mevcut class'lara dokunulmuyor.
Peki Product Owner'ın ikinci talebi, yani her tip için "sabit kupon kabul ediliyor mu" sorusu? Bu yeni bir işlem. Interface'e bool AcceptsFixedCoupon(bool summer); eklediğimizde:
src/Bookstore.Pricing/TierPolicies.cs(15,34): error CS0535: 'MemberTier' does not implement interface member 'ITierPolicy.AcceptsFixedCoupon(bool)' [src/Bookstore.Pricing/Bookstore.Pricing.csproj]
src/Bookstore.Pricing/TierPolicies.cs(23,31): error CS0535: 'VipTier' does not implement interface member 'ITierPolicy.AcceptsFixedCoupon(bool)' [src/Bookstore.Pricing/Bookstore.Pricing.csproj]
src/Bookstore.Pricing/TierPolicies.cs(9,35): error CS0535: 'RegularTier' does not implement interface member 'ITierPolicy.AcceptsFixedCoupon(bool)' [src/Bookstore.Pricing/Bookstore.Pricing.csproj]
Yeni bir işlem, her class'a dokunmayı gerektiriyor. Switch expression'da durum tam tersiydi: yeni bir işlem yeni bir switch'ti ve mevcut koda dokunmuyordu, yeni bir tip ise her switch'e dokunmayı gerektiriyordu.
Hedef 3: Kural Tablosu
Üçüncü seçenek, kuralları koddan çıkarıp veriye çevirmek. Her satır bir kural; ilk eşleşen satır uygulanıyor:
public sealed record TierRule(CustomerTier Tier, bool? Summer, int MinBooks, decimal MinSubtotal, Percentage Rate);
public static class TierRules
{
// Sıra önemli: ilk eşleşen kural uygulanır.
public static readonly TierRule[] Default =
[
new(CustomerTier.Vip, Summer: true, MinBooks: 0, MinSubtotal: 0m, Rate: Percentage.FromFraction(0.20m)),
new(CustomerTier.Vip, Summer: false, MinBooks: 0, MinSubtotal: 0m, Rate: Percentage.FromFraction(0.10m)),
new(CustomerTier.Member, Summer: true, MinBooks: 0, MinSubtotal: 0m, Rate: Percentage.FromFraction(0.15m)),
new(CustomerTier.Member, Summer: false, MinBooks: 5, MinSubtotal: 0m, Rate: Percentage.FromFraction(0.05m)),
new(CustomerTier.Regular, Summer: true, MinBooks: 0, MinSubtotal: 500m, Rate: Percentage.FromFraction(0.10m)),
];
public static decimal Discount(IEnumerable<TierRule> rules, CustomerTier tier, bool summer, decimal subtotal, int bookCount)
{
TierRule? rule = rules.FirstOrDefault(r =>
r.Tier == tier
&& (r.Summer is null || r.Summer == summer)
&& bookCount >= r.MinBooks
&& subtotal >= r.MinSubtotal);
return rule is null ? 0m : rule.Rate.Of(subtotal);
}
}
Snapshot yeşil. Öğrenci tipini eklemek burada enum'a bir değer ve tabloya bir satır demek; iki dosyada üç satırlık bir diff, hiçbir derleyici uyarısı yok. Tablo bir gün JSON dosyasından ya da veritabanından okunursa satır eklemek kod değişikliği bile gerektirmez.
Derleyici uyarısının olmaması aynı zamanda bu yaklaşımın zayıf noktası: enum'a Student eklenip tabloya satır eklenmeyi unutulursa öğrenciler sessizce yüzde 0 indirim alır. Switch'te derleyicinin yaptığı kontrolü burada bir test yapmak zorunda:
[Fact]
public void Every_tier_appears_in_the_rule_table()
{
CustomerTier[] missing = Enum.GetValues<CustomerTier>()
.Where(tier => TierRules.Default.All(r => r.Tier != tier))
.ToArray();
Assert.Empty(missing);
}
failed Bookstore.Pricing.Tests.TierRulesTests.Every_tier_appears_in_the_rule_table (8ms)
Assert.Empty() Failure: Collection was not empty
Collection: [Student]
Tablonun bir başka bedeli sıranın anlam taşıması. İki satır aynı siparişe uyuyorsa üstteki kazanır ve bu bilgi kodda değil satırların dizilişinde saklıdır. Satırların çakışmadığını ya da çakışmanın bilinçli olduğunu gösteren testler tablo büyüdükçe gerekli hâle gelir.
Cevap: Yeni Tip mi, Yeni İşlem mi?
Açılıştaki iki talebi kolaylaştıran tek bir yapı yok. Bu, programlama dilleri literatüründe Philip Wadler'ın expression problem adını verdiği gerilim: veri tiplerini ve işlemleri aynı anda genişletilebilir kılmak zordur. Hangi hedefi seçeceğin, kod tabanında hangi tür değişikliğin daha sık geldiğine bağlı:
| Değişiklik | Switch expression | Polymorphism (strategy) | Kural tablosu |
|---|---|---|---|
| Yeni müşteri tipi | Her switch'e bir kol; derleyici CS8509 ile yerini gösterir | Yeni class ve fabrikaya bir satır | Yeni satır; eksikliği test yakalar |
| Yeni işlem (ör. kupon kabulü) | Yeni bir switch; mevcut koda dokunulmaz | Her class'a yeni method; derleyici CS0535 ile zorlar | Yeni kolon; bütün satırlar güncellenir |
| Kuralı kim değiştirir? | Geliştirici, deploy ile | Geliştirici, deploy ile | Gerekirse configuration ya da yönetim ekranı |
| En uygun olduğu yer | Az sayıda, kapalı bir küme; kurallar bir bakışta okunmalı | Tiplerin davranışı zenginse ve sık yeni tip geliyorsa | Kurallar sık değişiyor ve aynı biçimdeyse |
Bookstore için karar: müşteri tipi sayısı az ve nadiren değişiyor, kampanya oranları ise iş birimi tarafından sık değiştiriliyor. Bu yüzden oranlar için kural tablosu, tipler arası farklı davranışlar (kupon kabulü gibi) için switch expression makul bir başlangıç. Polymorphism, her müşteri tipinin indirim dışında da kendine özgü davranışları (sadakat puanı, kargo ayrıcalığı, fatura notu) birikince anlamlı hâle gelir. Birinci yazıdaki ilkeye dönersek: refactoring'in hedefi bugünkü kodu güzel göstermek değil, bir sonraki beklenen değişikliği kolaylaştırmak.
Küçük Tipler: Percentage
Tablodaki Rate kolonu bir decimal değil, küçük bir tip. 0.20m yüzde 20 mi, yüzde 0,2 mi? Bir decimal bunu söylemez; bir tip söyler ve geçersiz değeri oluştuğu anda reddeder:
public readonly record struct Percentage
{
private Percentage(decimal fraction) => Fraction = fraction;
public decimal Fraction { get; }
public static Percentage FromFraction(decimal fraction) =>
fraction is >= 0m and <= 1m
? new Percentage(fraction)
: throw new ArgumentOutOfRangeException(nameof(fraction), fraction, "A percentage must be between 0 and 1.");
public decimal Of(decimal amount) => amount * Fraction;
public override string ToString() => $"{Fraction * 100m:0.##}%";
}
Değeri kesir olarak saklamamın bir nedeni var: Of method'u eski koddaki subtotal * 0.30m çarpımını birebir yapıyor. Bunu denedim: tip yüzde puanını (10 ve 30) saklayıp amount * Points / 100m hesaplayınca, üçüncü yazıda gördüğümüz decimal scale farkı yüzünden snapshot'ın dokuz satırı değişti; örneğin WELCOME10'lu önizleme "108.00" yerine "108.0" bastı. Küçük bir tip eklemek de bir refactoring adımı ve aynı ağdan geçmek zorunda.
İndirim tavanı (MaxDiscount) ve WELCOME10 kuponu da artık Percentage. Aynı mantıkla tutarlar için bir Money tipi ve kupon string'i için "FIX20"'yi ayrıştıran bir Coupon tipi sıradaki adaylar; string içinde saklanan bir sayıyı her kullanımda decimal.Parse(coupon[3..]) ile çıkarmak tipik bir primitive obsession. Value object tasarımının ayrıntısı, eşitlik ve persistence tarafı DDD (Domain-Driven Design; iş alanını merkeze alan tasarım yaklaşımı) serisinde, Ordering context'inin yalnız CRUD (oluşturma, okuma, güncelleme, silme) işlemleri yapan anemic bir modelden zengin modele taşındığı "Uçtan Uca: Bookstore Ordering Context'ini Anemic CRUD'dan Zengin Modele Refactor Etmek" yazısında; burada amacımız yalnız koşullardaki sihirli sayıları adı olan değerlere çevirmek.
Pratik: Gilded Rose Kata'sı
Bu yazıdaki adımları başka bir kod üzerinde denemek için en bilinen alıştırma Emily Bache'nin bakımını yaptığı Gilded Rose Refactoring Kata'sı. Repo MIT (kullanmaya ve değiştirmeye izin veren açık kaynak lisansı) ile lisanslanmış ve onlarca dilde başlangıç kodu içeriyor; C# için csharp.xUnit ve csharp.NUnit, TypeScript için TypeScript klasörleri var. 14 Kasım 2026'daki hâliyle csharp.xUnit projesi .NET 10'u hedefliyor; içinde bilerek kırmızı bırakılmış bir örnek test ve Verify ile yazılmış, 30 günlük çıktıyı onaylayan bir approval test geliyor. Kata'nın kurallarını repo'daki gereksinim dosyasından oku; burada yalnız bu seriye uygun bir yol haritası veriyoruz.
Önce ikinci yazıdaki gibi ağı genişlet: tek bir 30 günlük çıktı yerine ürün adı, kalan gün ve kalite değerlerinin kombinasyonlarını onayla, sonra coverage ve birkaç bilinçli mutasyonla ağın deliklerini bul. Sonra üçüncü yazıdaki ritimle küçük adımlarla ilerle: guard clause, döngü gövdesini ürün başına bir method'a çıkarmak, sihirli string'leri adı olan sabitlere çevirmek. En son bu yazının sorusunu sor: kata'nın istediği yeni ürün türü, yeni bir tip mi yoksa yeni bir işlem mi? Ürün türüne göre bir switch expression mı, ürün türü başına bir strategy mi, yoksa bir kural tablosu mu? Aynı kata'yı üç hedefle ayrı ayrı çözüp diff'leri karşılaştırmak, tablodaki farkları ezberlemekten daha çok şey öğretir.
Sık Düşülen Tuzaklar
Her switch'i polymorphism'e çevirmek. Switch'i "kötü kod" sayıp her birini bir class hiyerarşisine dönüştürmek, tek satırlık kuralları dört dosyaya dağıtır ve yeni bir işlem eklemeyi pahalılaştırır. Soru her zaman aynı: bu kodda daha sık yeni tip mi geliyor, yeni işlem mi?
Discard koluyla derleyiciyi susturmak. _ => 0m kolu CS8524'ü susturur ama CS8509'u da etkisiz bırakır. Yeni bir enum üyesi sessizce varsayılan değere düşer. Discard'ı ancak "geri kalan her şey" gerçekten aynı davranışı hak ediyorsa kullan.
Modern C# özelliklerini sırf yeni oldukları için kullanmak. İç içe dört seviyeli bir property pattern ya da beş elemanlı bir tuple pattern, eski if'lerden daha zor okunabilir. Switch expression, kolları kısa ve paralel olduğunda okunur; kollardan biri on satırlık bir hesaplama yapıyorsa o hesap kendi method'una gitmeli.
Kural tablosunu doğrulamadan büyütmek. Tablo veri olduğu için derleyici onu kontrol etmez. Eksik satır, çakışan satır ve yanlış sıra ancak testlerle yakalanır; tablo dışarıdan okunuyorsa okunduğu anda da doğrulanmalı.
Koşulları dönüştürürken davranışı da değiştirmek. Guard clause'a geçerken kontrollerin sırası değişir. Bu örnekte "WELCOME10" "FIX" ile başlamadığı için sıra farkı sonucu etkilemiyor, ama iki koşulun aynı girdiye uyabildiği bir durumda sıralama davranışı değiştirebilir. Kombinasyon snapshot'ı tam olarak bu tür hataları yakalamak için orada.
Küçük Bir Deneme
★☆☆ Kendi kodunda bir enum üzerinde çalışan, discard kolu olan bir switch expression bul. Discard'ı kaldır, .editorconfig'te CS8524'ü kapatıp CS8509'u hataya çevir ve build'in ne söylediğine bak.
★★☆ Bu yazıdaki üç hedefin her birine Product Owner'ın ikinci talebini ekle: VIP (özel statülü) müşteriler yazın sabit kupon kullanamasın, diğer tipler kullanabilsin. Her biri kaç dosyaya ve kaç satıra dokundu?
★★★ Gilded Rose kata'sını C# ya da TypeScript ile, önce kombinasyon snapshot'ıyla ağ kurup sonra iki farklı hedefle (switch expression ve strategy) iki ayrı branch'te çöz. Kata'nın istediği yeni ürün türünü iki branch'e de ekle ve diff'leri karşılaştır.
Buraya kadar dönüştürdüğümüz kodlar test edilebilir durumdaydı ya da kolayca test edilebilir hâle geldi. Bir sonraki yazıda öyle olmayan bir koda geçiyoruz: CSV (virgülle ayrılmış metin) dosyası okuyan, static bir SMTP (e-posta gönderme protokolü) yardımcısını çağıran, DateTime.Now ve new SqlConnection kullanan OrderImportJob. Ona test yazabilmek için önce dependency'lerini kırmamız gerekecek.