Simple Design'ın Dört Kuralı: Testler Geçer, Niyet Açık, Tekrar Yok, En Az Eleman
Kent Beck'in simple design kuralları öncelik sırasıyla şunlar: testler geçer, niyet açıktır, tekrar yoktur, eleman sayısı en azdır. Yazıda bir kupon indirimi hesabı bu dört soruyla adım adım büyütülüyor, tesadüfi benzerlik ile gerçek tekrar ayrılıyor ve aynı testleri geçen aşırı genelleştirilmiş bir versiyonun bedeli gösteriliyor.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et: Hangisi Daha Basit?
Bookstore'un sepet sayfasına kupon ve VIP (Very Important Person, sadık müşteri segmenti) indirimi geliyor. Ekipte iki implementation var ve ikisi de aynı yedi testi geçiyor. Birincisi tek bir class:
public sealed class DiscountCalculator
{
private const decimal WelcomeCouponRate = 0.10m;
private const decimal SummerCouponAmount = 25m;
private const decimal VipRate = 0.05m;
public decimal Calculate(decimal subtotal, string? couponCode, CustomerTier tier)
{
decimal discount = CouponDiscount(subtotal, couponCode);
if (tier == CustomerTier.Vip) discount += PercentOf(subtotal, VipRate);
return Math.Min(discount, subtotal);
}
private static decimal CouponDiscount(decimal subtotal, string? couponCode) => couponCode switch
{
null => 0m,
"WELCOME10" => PercentOf(subtotal, WelcomeCouponRate),
"SUMMER25" => SummerCouponAmount,
_ => throw new ArgumentException($"Unknown coupon '{couponCode}'.", nameof(couponCode)),
};
// İndirimler kuruşa yuvarlanır; 0,005 yukarı gider
private static decimal PercentOf(decimal amount, decimal rate) =>
Math.Round(amount * rate, 2, MidpointRounding.AwayFromZero);
}
İkincisi bir IDiscountRule interface'i, bir DiscountContext record'u, bir abstract PercentageDiscountRule, dört rule class'ı, bir DiscountRuleFactory ve onları toplayan bir DiscountCalculator'dan oluşuyor: dokuz tip, altmış satır. Pull request'te biri ikinciyi "genişletilebilir, yeni kural eklemek bir class eklemek kadar kolay" diye savunuyor, biri birinciyi "okuyunca bitiyor" diye.
Kafanda cevapla: hangisi daha basit, ve "basit" derken neyi ölçüyorsun? Satır sayısını mı, tip sayısını mı, yeni bir kural eklemenin maliyetini mi? Bir de şunu düşün: birinci versiyondaki 0.10m ile sitenin başka bir yerindeki yüzde 10'luk vergi oranı aynı sayı. İkisini tek bir TenPercent sabitinde birleştirmek tekrarı azaltır mı, yoksa başka bir şey mi yapar?
Bu yazı bu soruları Kent Beck'in dört kuralıyla cevaplıyor ve sonunda iki implementation'a geri dönüyor.
Dört Kural Nereden Geliyor?
Kurallar Extreme Programming'den, yani XP'den (Extreme Programming, Kent Beck'in 1990'ların sonunda tarif ettiği yazılım geliştirme yaklaşımı) geliyor. Beck, Extreme Programming Explained kitabının 1999'daki ilk baskısında "simple design" pratiğini dört maddeyle tanımladı: sistem bütün testleri çalıştırır, tekrar eden mantık içermez, programcılar için önemli olan her niyeti ifade eder ve mümkün olan en az class ve method'a sahiptir. Martin Fowler 2015'te Beck Design Rules adlı bliki yazısında bunları bugün en çok alıntılanan kısa hâline getirdi: passes the tests, reveals intention, no duplication, fewest elements. Yazıyı Beck de gözden geçirmiş.
Bu kurallar Clean Code (Robert C. Martin'in 2008 tarihli kitabı) okurlarına da tanıdık. Kitabın ilk baskısında Jeff Langr'ın yazdığı Emergence bölümü tamamen bu dört kural üzerine kurulu. 2025'te çıkan ikinci baskıda ise konu Simple Design adıyla kendi bölümünü aldı; yayıncının içindekiler listesine göre bölümün alt başlıkları YAGNI (You Aren't Gonna Need It), Covered by Tests, Maximize Expression ve Minimize Duplication. Yani Martin ilk üç kuralı "testlerle korunuyor, niyeti en iyi ifade ediyor, tekrarı en aza indiriyor" diye yeniden adlandırıyor; içindekiler listesinde dördüncü kural için ayrı bir başlık yok, onun yerine YAGNI başlığı var.
Kuralların ortak iddiası şu: tasarım kalitesini tartışırken zevk yerine dört soru sorabilirsin. Fowler'ın notuna göre Beck de tasarım kalitesinin tamamen öznel olmadığını, bu kuralların sonradan bakınca iyi çıkan tasarımı önceden tahmin etmede genelde işe yaradığını söylüyor.
Sıralama Neden Önemli?
Kurallar öncelik sırasıyla yazılır. Testleri geçmeyen kod ne kadar okunaklı olursa olsun basit değildir, çünkü yanlıştır. Test geçtikten sonra okuyucuya niyeti anlatmak, tekrar azaltmaktan önce gelir. En sonda da gereksiz element'ler temizlenir. Dördüncü kural bilerek en zayıf olandır: ilk üçü için gereken bir class'ı "element sayısı artmasın" diye silmezsin.
İkinci ve üçüncü kuralın sırası ise tartışmalı. Fowler bu ikisinin sırasının pek önemli olmadığını, birbirini beslediğini yazıyor; netlik için tekrarı korumak çoğu zaman çözülmesi gereken bir problemi örtüyor. Beck'in Fowler'a aktardığı nadir istisna ise test kodu: orada okuyucuya empati, teknik bir metrikten önce gelebiliyor. J.B. Rainsberger The Four Elements of Simple Design yazısında sırayı tersine çeviriyor: tekrarı kaldırmayı netlikten biraz önde tutuyor, ama hemen ardından sıranın aslında önemli olmadığını ekliyor. Rainsberger'in pratik özeti işe yarar: "remove duplication" ve "improve names" küçük döngülerde birbirini besler. Tekrarı kaldırdıkça yapı ortaya çıkar, isimleri düzelttikçe sorumluluklar doğru yere kayar. Dördüncü kurala ise hiç başvurmak zorunda kalmadığını söylüyor, çünkü ilk ikisini düzgün yapan bir tasarım zaten makul sayıda element'e iniyor.
Pratikte sıralamayı şöyle okumak işime yarıyor: ilk kural pazarlık konusu değil, son kural en kolay feda edilen, ortadaki ikisi birlikte çalışır.
Emergent Design: Tasarım Testlerin Arkasından Gelir
Bu kuralların ilginç yanı, baştan bir tasarım çizmeyi şart koşmamaları. Fikir şu: küçük adımlarla ilerle, her adımda testleri geçir, sonra kodu dört kurala göre düzelt. Tasarım bu döngünün içinden ortaya çıkar; Clean Code'un ilk baskısının bölüm adı da bu yüzden Emergence. Bu, TDD (Test-Driven Development) döngüsünün refactor adımıyla aynı şey değil ama ona çok yakın: TDD testi önce yazmayı söyler, simple design kuralları ise testler yeşilken kodu nereye doğru düzelteceğini söyler.
Bunun bir ön koşulu var: testler. İkinci, üçüncü ve dördüncü kural kodu değiştirmeyi gerektiriyor ve testi olmayan kodu değiştirmek tasarım değil kumar. Bu yüzden ilk kural sadece "kod doğru çalışsın" demiyor, "değiştirmeye cesaret edebileceğin bir güvenlik ağı olsun" da diyor. Testleri temiz tutmanın kurallarını bir önceki yazıda, xUnit'in teknik tarafını C# İleri serisinin "C#'ta Unit Test: xUnit, Theory/InlineData ve Test Edilebilir Kod" yazısında konuştuk.
Uygulama: İndirim Hesabını Dört Kuralla Büyütmek
Açılıştaki tek class'lık versiyona nasıl varıldığını adım adım görelim. İlk istek tek bir kupon: WELCOME10 sepet toplamından yüzde 10 düşer.
Adım 1: Testler geçer
İlk kural için önce bir test, sonra testi geçiren en basit kod:
public class DiscountCalculatorTests
{
private readonly DiscountCalculator calculator = new();
[Fact]
public void Welcome_coupon_takes_ten_percent_off()
{
Assert.Equal(20m, calculator.Calculate(200m, "WELCOME10"));
}
}
public sealed class DiscountCalculator
{
public decimal Calculate(decimal subtotal, string? couponCode) => subtotal * 0.1m;
}
Bu kod kuponu hiç kontrol etmiyor ve bu bilinçli. Elimdeki tek örnek bunu gerektiriyor; ikinci bir örnek gelmeden genelleme yapmıyorum. İkinci istek gelince testleri ekliyorum: SUMMER25 sabit 25 TL düşer, kuponsuz sepette indirim yoktur. Mevcut kodla çalıştırınca:
Failed Bookstore.Discounts.Tests.DiscountCalculatorTests.Summer_coupon_takes_a_fixed_amount_off [2 ms]
Expected: 25
Actual: 20.0
Failed Bookstore.Discounts.Tests.DiscountCalculatorTests.No_coupon_means_no_discount [< 1 ms]
Expected: 0
Actual: 20.0
Failed! - Failed: 2, Passed: 1, Skipped: 0, Total: 3, Duration: 10 ms - Bookstore.Discounts.Tests.dll (net10.0)
Kırmızı test bana tam olarak neyi genellemem gerektiğini söylüyor. En basit düzeltme:
public sealed class DiscountCalculator
{
public decimal Calculate(decimal subtotal, string? couponCode)
{
if (couponCode == "WELCOME10") return subtotal * 0.1m;
if (couponCode == "SUMMER25") return 25m;
return 0m;
}
}
Adım 2: Niyet açık mı?
Üç test yeşil. Şimdi ikinci kuralı soruyorum: bu kodu ilk kez okuyan biri 0.1m'nin ne olduğunu anlar mı? Bu boyutta anlar, WELCOME10 hemen yanında. Bu yüzden henüz bir şey değiştirmiyorum; niyet açık. Yeni istek geliyor: VIP müşteriler ek yüzde 5 alır, yüzdelik indirimler kuruşa yuvarlanır ve toplam indirim sepet tutarını geçemez. Bilinmeyen kupon da sessizce yok sayılmamalı. Testler:
public class DiscountCalculatorTests
{
private readonly DiscountCalculator calculator = new();
[Theory]
[InlineData(200, "WELCOME10", 20)]
[InlineData(200, "SUMMER25", 25)]
[InlineData(200, null, 0)]
public void Coupon_discounts_for_a_normal_customer(decimal subtotal, string? coupon, decimal expected)
{
Assert.Equal(expected, calculator.Calculate(subtotal, coupon, CustomerTier.Normal));
}
[Fact]
public void Discount_never_exceeds_the_subtotal()
{
Assert.Equal(20m, calculator.Calculate(20m, "SUMMER25", CustomerTier.Normal));
}
[Fact]
public void Vip_discount_stacks_with_the_coupon()
{
Assert.Equal(30m, calculator.Calculate(200m, "WELCOME10", CustomerTier.Vip));
}
[Fact]
public void Percentage_discounts_are_rounded_to_the_kurus()
{
Assert.Equal(5.00m, calculator.Calculate(99.99m, null, CustomerTier.Vip));
}
[Fact]
public void Unknown_coupon_is_rejected()
{
Assert.Throws<ArgumentException>(() => calculator.Calculate(200m, "FREE", CustomerTier.Normal));
}
}
Bunları geçiren ilk versiyon:
public sealed class DiscountCalculator
{
public decimal Calculate(decimal subtotal, string? couponCode, CustomerTier tier)
{
decimal discount = 0m;
if (couponCode == "WELCOME10") discount += Math.Round(subtotal * 0.10m, 2, MidpointRounding.AwayFromZero);
else if (couponCode == "SUMMER25") discount += 25m;
else if (couponCode != null) throw new ArgumentException($"Unknown coupon '{couponCode}'.", nameof(couponCode));
if (tier == CustomerTier.Vip) discount += Math.Round(subtotal * 0.05m, 2, MidpointRounding.AwayFromZero);
if (discount > subtotal) discount = subtotal;
return discount;
}
}
Adım 3: Tekrar yok mu?
Testler geçiyor ve üçüncü kural devreye giriyor. Math.Round(... , 2, MidpointRounding.AwayFromZero) iki kez yazılmış. Bu sadece benzeyen iki satır değil, aynı bilgi: "yüzdelik indirim kuruşa, yarım kuruş yukarı yuvarlanır". Muhasebe yuvarlama kuralını değiştirirse iki yeri birden değiştirmem gerekir ve birini unutursam testler belki yakalar, belki yakalamaz. Kuralı tek yere alıyorum. Aynı anda ikinci kurala da dönüyorum: 0.10m ve 0.05m artık üç farklı oranın arasında kayboluyor, onlara isim veriyorum; if/else if zinciri de bir kupon tablosu gibi okunsun diye switch expression'a dönüyor:
public sealed class DiscountCalculator
{
private const decimal WelcomeCouponRate = 0.10m;
private const decimal SummerCouponAmount = 25m;
private const decimal VipRate = 0.05m;
public decimal Calculate(decimal subtotal, string? couponCode, CustomerTier tier)
{
decimal discount = CouponDiscount(subtotal, couponCode);
if (tier == CustomerTier.Vip) discount += PercentOf(subtotal, VipRate);
return Math.Min(discount, subtotal);
}
private static decimal CouponDiscount(decimal subtotal, string? couponCode) => couponCode switch
{
null => 0m,
"WELCOME10" => PercentOf(subtotal, WelcomeCouponRate),
"SUMMER25" => SummerCouponAmount,
_ => throw new ArgumentException($"Unknown coupon '{couponCode}'.", nameof(couponCode)),
};
// İndirimler kuruşa yuvarlanır; 0,005 yukarı gider
private static decimal PercentOf(decimal amount, decimal rate) =>
Math.Round(amount * rate, 2, MidpointRounding.AwayFromZero);
}
Passed! - Failed: 0, Passed: 7, Skipped: 0, Total: 7, Duration: 8 ms - Bookstore.Discounts.Tests.dll (net10.0)
Testler hiç değişmedi ve hâlâ yeşil. Kodu değiştiren şey yeni bir istek değil, dört kurala sorduğum sorular oldu. Yuvarlama yorumu da yerinde duruyor, çünkü "neden AwayFromZero" sorusunun cevabı koddan okunmuyor; bu, comment'ler yazısındaki "kodun anlatamadığını yaz" ilkesi.
Tekrar mı, Tesadüf mü?
Üçüncü kural en kolay yanlış uygulanan kural. Beck'in orijinal maddesi "tekrar eden mantık" diyor, tekrar eden karakter değil. Andy Hunt ve Dave Thomas'ın The Pragmatic Programmer kitabında adını koyduğu DRY (Don't Repeat Yourself) prensibi de bilgiden bahseder: her bilginin sistemde tek, net ve yetkili bir temsili olmalı. Eric Raymond'ın The Art of Unix Programming kitabında SPOT (Single Point Of Truth) dediği şey de aynısı.
Açılıştaki soruya dönelim. Sitenin fiyatlandırmasında vergi oranı da 0.10m. "Tekrarı kaldıralım" diyerek şunu yapmak kolay:
// İki ayrı iş kuralı, tesadüfen aynı sayı
public static class Rates
{
public const decimal TenPercent = 0.10m;
}
public static class Pricing
{
public static decimal Tax(decimal net) => net * Rates.TenPercent;
public static decimal WelcomeDiscount(decimal subtotal) => subtotal * Rates.TenPercent;
}
Bu kod tekrarı azaltmadı, iki bağımsız kararı birbirine bağladı. Vergi oranını devlet değiştirir, kupon oranını pazarlama ekibi. Biri değiştiğinde TenPercent'i değiştiren kişi farkında olmadan diğerini de değiştirir; ya da sabitin adı TenPercent kalırken değeri 0.20m olur ve isim yalan söylemeye başlar. Kural şu: iki kod parçası aynı nedenle ve aynı anda değişecekse aynı bilgidir, birleştir. Farklı nedenlerle değişecekse benzerlik tesadüftür, ayrı bırak. Bu ayrımın derin tartışması, "yanlış abstraction tekrardan pahalıdır" görüşüyle birlikte, ilerideki SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation ve Dependency Inversion prensipleri) ve Design Principles serisinin DRY, KISS (Keep It Simple, Stupid) ve YAGNI yazısında.
.NET'in kendisinde gerçek bir tekrar temizliği örneği var. ASP.NET Core'un authentication katmanı 1.0'dan beri test edilebilir zaman için kendi ISystemClock interface'ini kullanıyordu. .NET 8 ile BCL'e (Base Class Library) tek bir TimeProvider abstraction'ı geldi ve Microsoft Learn'deki breaking change notuna göre authentication ISystemClock'u obsolete yapıp TimeProvider'a geçti. Aynı notta küçük bir detay var: eski SystemClock zamanı en yakın saniyeye kesiyordu, TimeProvider kesmiyor. Gerçek tekrarı birleştirmek bile davranışı değiştirebilir; bu yüzden birinci kural her zaman önde.
Fewest Elements: Spekülatif Esnekliğin Bedeli
Şimdi açılıştaki ikinci implementation. Aynı yedi testi geçiyor:
public interface IDiscountRule
{
decimal Apply(DiscountContext context);
}
public sealed record DiscountContext(decimal Subtotal, string? CouponCode, CustomerTier Tier);
public abstract class PercentageDiscountRule(decimal rate) : IDiscountRule
{
public decimal Apply(DiscountContext context) =>
IsApplicable(context) ? Math.Round(context.Subtotal * rate, 2, MidpointRounding.AwayFromZero) : 0m;
protected abstract bool IsApplicable(DiscountContext context);
}
public sealed class WelcomeCouponRule() : PercentageDiscountRule(0.10m)
{
protected override bool IsApplicable(DiscountContext context) => context.CouponCode == "WELCOME10";
}
public sealed class VipRule() : PercentageDiscountRule(0.05m)
{
protected override bool IsApplicable(DiscountContext context) => context.Tier == CustomerTier.Vip;
}
public sealed class SummerCouponRule : IDiscountRule
{
public decimal Apply(DiscountContext context) => context.CouponCode == "SUMMER25" ? 25m : 0m;
}
public sealed class CouponValidationRule : IDiscountRule
{
private static readonly HashSet<string> KnownCoupons = ["WELCOME10", "SUMMER25"];
public decimal Apply(DiscountContext context) =>
context.CouponCode is null || KnownCoupons.Contains(context.CouponCode)
? 0m
: throw new ArgumentException($"Unknown coupon '{context.CouponCode}'.", nameof(context));
}
public static class DiscountRuleFactory
{
public static IReadOnlyList<IDiscountRule> CreateDefaultRules() =>
[new CouponValidationRule(), new WelcomeCouponRule(), new SummerCouponRule(), new VipRule()];
}
public sealed class DiscountCalculator(IReadOnlyList<IDiscountRule> rules)
{
public DiscountCalculator() : this(DiscountRuleFactory.CreateDefaultRules())
{
}
public decimal Calculate(decimal subtotal, string? couponCode, CustomerTier tier)
{
DiscountContext context = new(subtotal, couponCode, tier);
return Math.Min(rules.Sum(rule => rule.Apply(context)), subtotal);
}
}
Her parçası kendi içinde makul görünüyor. Sorun toplamda: bir indirimin nasıl hesaplandığını anlamak için beş tipe bakman, kuralların hangi sırayla toplandığını factory'den bulman, CouponValidationRule'un neden 0 dönüp exception fırlattığını çözmen gerekiyor. Bilinmeyen kupon kontrolü artık bir "indirim kuralı" kılığında. Dördüncü kural tam olarak bunu soruyor: bu element'ler ilk üç kuralın hangisine hizmet ediyor? Testleri geçirmek için gerekli değiller, niyeti daha açık yapmıyorlar, tekrarı da azaltmıyorlar; sadece henüz gelmemiş bir isteği bekliyorlar. YAGNI'nin söylediği de bu.
Bu, interface ve strategy pattern'in kötü olduğu anlamına gelmiyor. Kurallar çalışma zamanında bir admin panelinden açılıp kapatılacaksa, her tenant kendi kural setini seçecekse ya da on beşinci kural geldiğinde switch expression okunmaz hâle gelirse, o gün bu yapıya refactor etmek bir öğleden sonra sürer ve testler zaten hazırdır. Bugün yapmak ise o güne kadar her okuyucuya bedel ödetir. Seri boyunca temizlediğimiz legacy OrderService.cs'teki IOrderProcessor<T> da aynı hikâye: "ileride farklı sipariş tipleri için" yazılmış ama dosyada da, solution'da da tek bir implementation'ı yok.
Sık Düşülen Tuzaklar
Birinci kuralı atlamak. "Önce temiz yazayım, testi sonra eklerim" dediğinde ikinci ve üçüncü kural için güvenlik ağın olmaz. Refactor ettiğin kodun hâlâ doğru çalıştığını bilmiyorsan yaptığın şey refactoring değil, yeniden yazmak.
Tesadüfi benzerliği tekrar sanmak. Aynı görünen iki satır her zaman aynı bilgi değildir. Birleştirmeden önce "bu ikisi aynı nedenle mi değişir?" diye sor.
"Fewest elements"ı satır sayısı yarışına çevirmek. Açıklayıcı bir ara değişken, isimli bir sabit ya da küçük bir private method element sayısını artırır ama ikinci kurala hizmet ediyorsa yerinde durur. Dördüncü kural en düşük önceliklidir.
Kuralları bir kerelik checklist sanmak. Dört kural her küçük adımda tekrar sorulur. Adım 2'de niyet açıktı, Adım 3'te iki yeni oran gelince açık olmaktan çıktı.
Emergent design'ı "tasarım yapmayalım" diye okumak. Kurallar küçük ölçekte, method ve class seviyesinde çok iyi çalışır. Servis sınırları, veritabanı şeması ya da public API (Application Programming Interface) gibi değiştirmesi pahalı kararlar için önceden düşünmek hâlâ gerekir. Bu, John Ousterhout'un Martin'e yönelttiği eleştirilerden biri ve iki yazı sonra konuşacağız.
Küçük Bir Deneme
★☆☆ Final versiyona BOOKLOVER kuponu ekle: 300 TL üzeri sepetlerde 40 TL indirim. Önce testi yaz, kırmızıyı gör, sonra en basit kodla geçir ve dört kuralı sırayla sor. Switch expression'a mı girdi, ayrı bir method'a mı?
★★☆ Vergi hesabı yapan küçük bir TaxCalculator yaz ve oranı 0.10m olsun. DiscountCalculator'daki WelcomeCouponRate ile aynı sabiti paylaşmalarını isteyen bir code review yorumuna, değişme nedenlerini gerekçe göstererek iki cümlelik bir cevap yaz.
★★★ Aşırı genelleştirilmiş versiyonu al ve dört kurala göre adım adım geri sadeleştir: her adımda testleri çalıştır, sildiğin her tip için "hangi kurala hizmet ediyordu?" diye not al. Sonunda tek class'lık versiyona mı vardın, yoksa korumaya değer bir parça buldun mu?
Bu yazıda tekrarı ve gereksiz element'leri dört kuralın merceğinden gördük. Bir sonraki yazıda aynı problemleri isimleriyle tanıyacağız: Martin Fowler ve Kent Beck'in code smell kataloğunu C# örnekleriyle gruplayıp, seri boyunca temizlediğimiz OrderService.cs'te her birini bulmaya çalışacağız.