DRY, KISS ve YAGNI: Tekrar, Basitlik ve "Şimdilik Gerekmiyor"
DRY kodun değil bilginin tek kaynağı olmasıdır; farklı nedenlerle değişen iki kuralı aynı göründükleri için birleştirmek DRY değil, yanlış bir abstraction'dır. Metz'in wrong abstraction'dan geri dönüşü, rule of three, KISS'i CA1502 ile ölçmek ve Fowler'ın YAGNI için saydığı dört maliyet.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'da iki indirim var. Pazarlamanın üye indirimi: üyelere 200 EUR ve üstü sepetlerde yüzde 10. Satış ekibinin kurumsal müşterilerle imzaladığı sözleşme indirimi: 200 EUR ve üstü siparişlerde yüzde 10, en fazla 500 EUR. İki yıl önce bir geliştirici iki kuralın aynı göründüğünü fark edip tek bir method'da birleştirdi. Bu hafta pazarlama Black Friday için eşiği 150'ye indirmek istedi; değişiklik tek satır, pull request beş dakikada onaylandı:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Console.WriteLine($"Member, 180: {DiscountCalculator.CalculateDiscount(180m, CustomerType.Member):0.00}");
Console.WriteLine($"Corporate, 180: {DiscountCalculator.CalculateDiscount(180m, CustomerType.Corporate):0.00}");
Console.WriteLine($"Corporate, 6000: {DiscountCalculator.CalculateDiscount(6000m, CustomerType.Corporate):0.00}");
public enum CustomerType { Regular, Member, Corporate }
public static class DiscountCalculator
{
private const decimal Threshold = 150m; // pazarlamanın isteği: 200 → 150
private const decimal Rate = 0.10m;
private const decimal CorporateCap = 500m;
// Üye indirimi ve kurumsal sözleşme indirimi "aynı kod" diye birleştirildi
public static decimal CalculateDiscount(decimal subtotal, CustomerType type)
{
if (type == CustomerType.Regular || subtotal < Threshold)
return 0m;
decimal discount = subtotal * Rate;
return type == CustomerType.Corporate ? Math.Min(discount, CorporateCap) : discount;
}
}
Pazarlama yalnız üye indirimini değiştirmek istedi. Sence 180 EUR'luk bir kurumsal sipariş şimdi ne kadar indirim alıyor? Sözleşmede yazan rakam 200.
İki Kural, Bir Method
Çıktı:
Member, 180: 18.00
Corporate, 180: 18.00
Corporate, 6000: 500.00
Kurumsal müşteri sözleşmesinde olmayan 18 EUR'luk bir indirim aldı. Kimse bunu istemedi; satış ekibinin haberi bile yok. Hata faturalar kesildikten sonra, muhtemelen bir müşteri temsilcisinin "bu indirim nereden çıktı?" sorusuyla fark edilecek. Testler de geçti, çünkü pazarlamanın yazdığı yeni test yalnız üyeyi kontrol ediyordu.
Burada yanlış olan şey 150 rakamı değil, iki ayrı iş kuralını tek bir koda bağlayan karar. İki kural aynı gün aynı rakamlarla doğduğu için kodları da aynıydı. Ama farklı nedenlerle ve farklı insanların isteğiyle değişiyorlar: biri kampanya takvimine, diğeri imzalı sözleşmelere bağlı. Bu serinin SRP (Single Responsibility Principle) yazısındaki dille söylersek, iki ayrı actor'ün kuralı tek bir yerde duruyor. DRY'ı (Don't Repeat Yourself) uyguladığını düşünen geliştirici aslında iki bilgiyi tek bilgi sanmış.
DRY'ın Asıl Anlamı: Bilginin Tek Kaynağı
DRY, Andy Hunt ve Dave Thomas'ın 1999 tarihli The Pragmatic Programmer kitabından geliyor ve tanımında "kod" kelimesi geçmiyor: sistemdeki her bilgi parçasının tek, belirsizlik taşımayan ve yetkili bir temsili olmalı. Yazarlar 2019'daki 20. yıl baskısında DRY bölümünü bu yüzden genişletip prensibin çoğu zaman yalnız "kodu kopyalama" diye anlaşıldığını, oysa asıl konunun bilginin ve niyetin tekrarı olduğunu vurguladılar.
Bu ayrım iki yöne de keser. Birincisi, birbirine benzeyen iki kod parçası her zaman DRY ihlali değil; yukarıdaki iki indirim gibi tesadüfen aynı görünen iki ayrı bilgi olabilir. Microsoft Learn'deki "Architectural principles" sayfası da DRY'ı anlatırken aynı notu düşüyor: aynı değeri taşıyan iki sabit kavramsal olarak farklı şeyleri anlatıyorsa tek sabite indirilmemeli; yanlış bir abstraction'a bağlanmaktansa tekrar her zaman tercih edilir. İkincisi, kod hiç tekrar etmese bile DRY ihlal edilebilir. KDV (katma değer vergisi) oranı C#'ta bir sabitte, veritabanında bir CHECK constraint'inde ve Angular tarafında bir pipe'ta ayrı ayrı yazılıysa, üç farklı dilde üç farklı kod var ama bilgi üç yerde.
.NET'te iyi bir örnek JSON'un (JavaScript Object Notation) web kuralları. Bir web API'si (Application Programming Interface) property adlarını camelCase yazar, gelen JSON'da büyük/küçük harfe takılmaz, string içinde gelen sayıları kabul eder. Bu bilgi tek bir yerde, JsonSerializerDefaults.Web'de duruyor ve .NET 9'dan beri JsonSerializerOptions.Web singleton'ı ile hazır geliyor:
using System.Globalization;
using System.Net.Http.Json;
using System.Text.Json;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Book dune = new("978-0441013593", "Dune", 12.99m);
// Varsayılan: property adları olduğu gibi
Console.WriteLine(JsonSerializer.Serialize(dune));
// Web varsayılanları: tek bir yerde tanımlı
Console.WriteLine(JsonSerializer.Serialize(dune, JsonSerializerOptions.Web));
Console.WriteLine(JsonSerializer.Deserialize<Book>("""{"ISBN":"978-0141439518","TITLE":"Emma","price":"7.50"}""", JsonSerializerOptions.Web));
// HttpClient tarafı aynı bilgiyi yeniden yazmıyor, aynı varsayılanları kullanıyor
Console.WriteLine(await JsonContent.Create(dune).ReadAsStringAsync());
public sealed record Book(string Isbn, string Title, decimal Price);
{"Isbn":"978-0441013593","Title":"Dune","Price":12.99}
{"isbn":"978-0441013593","title":"Dune","price":12.99}
Book { Isbn = 978-0141439518, Title = Emma, Price = 7.50 }
{"isbn":"978-0441013593","title":"Dune","price":12.99}
Son satır önemli: System.Net.Http.Json'daki JsonContent.Create'e hiçbir option vermedik, yine de camelCase çıktı üretti. ASP.NET Core'un varsayılan ayarları da dokümana göre aynı web varsayılanları. Böylece sunucu ve istemci "web'de JSON nasıl yazılır" bilgisini ayrı ayrı tanımlamıyor; aynı kaynağı kullanıyor. Biri değişirse diğeri kendiliğinden uyuyor.
Wrong Abstraction'dan Geri Dönmek
Sandi Metz (Practical Object-Oriented Design in Ruby kitabının yazarı) bu durumu 2014'teki RailsConf (Ruby on Rails konferansı) konuşmasında tek cümleye sığdırdı, 2016'daki "The Wrong Abstraction" yazısında da açtı: tekrar, yanlış abstraction'dan çok daha ucuzdur. Yazıda anlattığı hikâye açılıştaki CalculateDiscount'un iki yıllık geçmişi gibi. Biri tekrarı görür ve ortak bir method'a çıkarır. Yeni bir ihtiyaç gelir ve abstraction'a neredeyse uyar; bir parametre ve bir if eklenir. Bir sonraki ihtiyaçla bir parametre daha. Bir süre sonra method'u kimse tam anlamıyor, ama o kadar emek verilmiş ki kimse de silmeye cesaret edemiyor.
Metz'in önerisi sezgiye ters: ileri gitmenin en hızlı yolu geri dönmek. Abstraction'ı onu çağıran her yere inline et, her çağıranda yalnız gerçekten kullandığı dalı bırak, sonra kalan gerçek tekrarlara yeniden bak. İndirim örneğinde bu, iki kurala kendi class'larını ve kendi sabitlerini geri vermek demek:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Console.WriteLine($"Member, 180: {Discounts.For(CustomerType.Member, 180m):0.00}");
Console.WriteLine($"Corporate, 180: {Discounts.For(CustomerType.Corporate, 180m):0.00}");
Console.WriteLine($"Corporate, 6000: {Discounts.For(CustomerType.Corporate, 6000m):0.00}");
public enum CustomerType { Regular, Member, Corporate }
// Sahibi: pazarlama. Kampanyaya göre sık değişir.
public static class MemberDiscount
{
private const decimal Threshold = 150m;
private const decimal Rate = 0.10m;
public static decimal For(decimal subtotal) => subtotal >= Threshold ? subtotal * Rate : 0m;
}
// Sahibi: satış. Sözleşmeyle değişir.
public static class CorporateContractDiscount
{
private const decimal Threshold = 200m;
private const decimal Rate = 0.10m;
private const decimal Cap = 500m;
public static decimal For(decimal subtotal) =>
subtotal >= Threshold ? Math.Min(subtotal * Rate, Cap) : 0m;
}
public static class Discounts
{
public static decimal For(CustomerType type, decimal subtotal) => type switch
{
CustomerType.Member => MemberDiscount.For(subtotal),
CustomerType.Corporate => CorporateContractDiscount.For(subtotal),
_ => 0m
};
}
Member, 180: 18.00
Corporate, 180: 0.00
Corporate, 6000: 500.00
Artık iki class'ta Rate = 0.10m var ve bu bilerek bırakılmış bir tekrar. İkisi bugün aynı oranı kullanıyor, ama biri kampanya kararıyla, diğeri sözleşme müzakeresiyle değişecek. Pazarlama eşiği 120'ye indirdiğinde satış ekibinin kodu değişmez; kurumsal sözleşme 2027'de yüzde 12'ye çıktığında üyeler etkilenmez. Her class'ın başındaki yorum, ileride biri "bunlar aynı, birleştirelim" dediğinde neden ayrı durduklarını hatırlatıyor.
Rule of Three, WET ve AHA
Ne zaman birleştirmeli? En eski cevap rule of three. Martin Fowler, 1999 tarihli Refactoring kitabında bunu Don Roberts'a (ilk otomatik refactoring aracı Refactoring Browser'ın geliştiricilerinden) dayandırır: bir şeyi ilk kez yaparsın; ikinci kez yaparken tekrarı fark edip yine de yaparsın; üçüncüde refactor edersin. Mantık şu: iki örnekten bir genelleme çıkarmak tahmin, üç örnek ise ortak olanla değişeni ayırmak için yeterli veri.
Aynı fikrin iki yeni adı var. WET (Write Everything Twice) bir şeyi iki kez yazmaya izin verip üçüncüde durmayı anlatır. AHA (Avoid Hasty Abstractions) ise aceleyle abstraction çıkarmamayı söyler; Kent C. Dodds (JavaScript ve test konularında tanınan eğitmen) 2020'deki yazısında kısaltmayı Cher Scarlett'e borçlu olduğunu söyler ve Metz'in uyarısına "önce değişime göre optimize et" önerisini ekler. Dodds aynı yazıda WET'i de fazla katı bulur; sayıya değil anlamaya bakılmasını ister.
Bu kuralların hepsini tek bir soruya indirmek mümkün: bu iki parça aynı nedenle ve aynı kişinin isteğiyle mi değişir? Cevap evetse tek bilgi var ve birleştirmek DRY'dır. Cevap hayırsa iki bilgi var ve birleştirmek coupling'dir. Sayıyı üçe kadar beklemek, çoğu zaman bu sorunun cevabını öğrenmek için zaman kazandırır.
KISS: Basitliği Ölçülebilir Kılmak
KISS (Keep It Simple, Stupid) genelde Lockheed'in Skunk Works ekibini yöneten mühendis Kelly Johnson'a atfedilir: bir uçak, sahada sıradan aletlerle tamir edilebilecek kadar basit tasarlanmalıydı. Yazılımda "basit" kelimesi tartışmaya açık. Rich Hickey'nin (Clojure dilinin yaratıcısı) 2011 tarihli "Simple Made Easy" konuşmasındaki ayrım işe yarıyor: basit, birbirine dolanmamış demek; kolay ise tanıdık, elinin altında demek. Bir framework'ü eklemek kolaydır ama sistemi basitleştirmeyebilir.
Tartışmayı somutlaştırmanın bir yolu ölçmek. .NET analyzer'larındaki CA1502, bir method'un cyclomatic complexity'si (bağımsız yol sayısı) eşiği aştığında uyarı verir. Kural varsayılan olarak kapalı ve eşiği 25; Bookstore'da 10 yapalım. Projeye bir CodeMetricsConfig.txt dosyası ekleyip AdditionalFiles olarak işaretliyoruz ve kuralı .editorconfig'te açıyoruz:
# CodeMetricsConfig.txt
CA1502: 10
# .editorconfig
[*.cs]
dotnet_diagnostic.CA1502.severity = warning
namespace Bookstore.Shipping;
public static class ShippingCalculator
{
public static decimal Cost(string country, decimal weightKg, bool express, bool isMember, decimal subtotal)
{
decimal cost;
if (country == "TR")
{
if (weightKg <= 1m) cost = 40m;
else if (weightKg <= 5m) cost = 60m;
else cost = 90m;
}
else if (country == "DE" || country == "NL" || country == "FR")
{
if (weightKg <= 1m) cost = 120m;
else if (weightKg <= 5m) cost = 180m;
else cost = 260m;
}
else
{
if (weightKg <= 1m) cost = 200m;
else cost = 350m;
}
if (express) cost *= 2m;
if (isMember && country == "TR" && subtotal >= 500m && !express) cost = 0m;
return cost;
}
}
Shipping.cs(5,27): warning CA1502: 'Cost' has a cyclomatic complexity of '15'. Rewrite or refactor the code to decrease its complexity below '11'.
On beş farklı yol, on beş farklı test senaryosu demek. Aynı kuralları tablo ve iki küçük method'la yazınca:
namespace Bookstore.Shipping;
public static class ShippingCalculator
{
private static readonly HashSet<string> EuZone = ["DE", "NL", "FR"];
// Bölge başına ağırlık basamakları: (en fazla kg, ücret)
private static readonly Dictionary<string, (decimal MaxKg, decimal Price)[]> Rates = new()
{
["TR"] = [(1m, 40m), (5m, 60m), (decimal.MaxValue, 90m)],
["EU"] = [(1m, 120m), (5m, 180m), (decimal.MaxValue, 260m)],
["World"] = [(1m, 200m), (decimal.MaxValue, 350m)],
};
public static decimal Cost(string country, decimal weightKg, bool express, bool isMember, decimal subtotal)
{
if (IsFreeForMember(country, express, isMember, subtotal))
return 0m;
decimal baseCost = Rates[ZoneOf(country)].First(r => weightKg <= r.MaxKg).Price;
return express ? baseCost * 2m : baseCost;
}
private static string ZoneOf(string country) =>
country == "TR" ? "TR" : EuZone.Contains(country) ? "EU" : "World";
private static bool IsFreeForMember(string country, bool express, bool isMember, decimal subtotal) =>
isMember && country == "TR" && subtotal >= 500m && !express;
}
Bu sürümde CA1502 uyarı vermiyor ve iki örnek çağrı yine 0 ve 360 döndürüyor. Karmaşıklık yok olmadı, yer değiştirdi: tarife artık bir veri tablosu ve kargo firmasının gönderdiği fiyat listesine birebir benziyor. Yeni bir ağırlık basamağı eklemek bir satır, bir ülkeyi Avrupa Birliği bölgesine almak bir string. Üye kuralı kendi adıyla ayrı duruyor.
Metriği hüküm değil alarm olarak kullan. Microsoft'un dokümanı da büyük bir switch'in kolay anlaşılır olabileceğini ve uyarının bastırılabileceğini söylüyor. Yedi günü sayan bir switch'in complexity'si 8'dir ama okuması bir saniye sürer. Uyarı "buraya bir daha bak" der, "bunu böl" demez.
YAGNI ve Dört Maliyet
YAGNI (You Aren't Gonna Need It) Extreme Programming'den geliyor. Fowler'ın 2015 tarihli bliki (sitesindeki blog ile wiki karışımı kısa yazılar) yazısına göre ifade C3 (Chrysler Comprehensive Compensation: Chrysler'ın bordro sistemi, Extreme Programming'in ilk uygulandığı proje) projesinde Kent Beck (Extreme Programming'i ortaya koyan yazılımcı) ile Chet Hendrickson arasındaki konuşmalardan doğdu: Chet sistemin yakında ihtiyaç duyacağı özellikleri önerdikçe Kent "ona ihtiyacın olmayacak" diye cevap veriyordu. Fowler aynı yazıda "ileride lazım olur" diye bugünden yazılan bir özelliğin dört maliyetini ayırıyor. Bookstore'da Türkiye dışına hiç satış yapılmazken çoklu para birimi desteği eklendiğini düşünelim:
| Maliyet | Fowler'ın tanımı | Bookstore'da çoklu para birimi |
|---|---|---|
| Build | Özelliği analiz etmek, yazmak, test etmek | Money, kur servisi, kur tablosu ve testleri için iki sprint |
| Delay | Onun yerine yapılmayan işin geciken değeri | O iki sprintte yapılmayan iade akışı |
| Carry | Ek karmaşıklığın her değişikliği yavaşlatması | Her fiyat hesabında kur parametresi, her test'te sahte kur |
| Repair | Yanlış yapıldığı anlaşılınca düzeltmek | İlk gerçek yurt dışı satışında kurun sipariş anında değil fatura anında alınması gerektiğinin anlaşılması |
Fowler'ın vurguladığı iki nokta YAGNI'yi doğru uygulamak için şart. Birincisi, özellik sonunda gerçekten gerekse bile build ve carry maliyeti ödenmiş olur; erken yazmanın kazancı yalnız delay maliyeti kadar. İkincisi, YAGNI kodu değiştirilebilir tutmak için harcanan emeğe uygulanmaz: refactoring, otomatik testler, continuous delivery. Bunlar YAGNI'nin ön koşulu; "şimdilik gerekmiyor" diyebilmek için ihtiyaç geldiğinde değişikliği ucuza yapabileceğinden emin olman gerekiyor.
.NET'in kendi tasarım kuralları da aynı yerde duruyor. Framework Design Guidelines (Krzysztof Cwalina ve Brad Abrams'ın .NET kütüphane tasarım kuralları kitabı), sanal üyeleri tasarlamanın, test etmenin ve korumanın maliyetini bilmeden ve iyi bir nedenin yoksa bir member'ı virtual yapmamayı ve extensibility'yi gerçekten gerekli olanla sınırlamayı öneriyor. Bir framework için bile her açık kapı bakım gerektiren bir söz.
Sık Düşülen Tuzaklar
Satır benzerliğine bakıp birleştirmek. İki method'un gövdesi aynı diye ortak bir yardımcıya taşımadan önce "bunları kim, neden değiştirir?" diye sor. Farklı actor'lerin kurallarını birleştirmek, açılıştaki gibi bir ekibin değişikliğinin başka bir ekibin faturasına sızması demek.
Wrong abstraction'ı parametreyle yaşatmak. bool isCampaign, bool applyCap, bool skipThreshold gibi bayraklar eklenmeye başladığında method artık birden fazla şeyi temsil ediyor. Bir bayrak daha eklemek yerine inline edip ayırmayı düşün.
DRY'ı kodla sınırlı sanmak. Aynı doğrulama kuralının C# attribute'unda, SQL (Structured Query Language) constraint'inde ve frontend'de ayrı ayrı yazılması kod tekrarı gibi görünmez ama DRY ihlalidir. Tek kaynak mümkün değilse en azından biri diğerinden üretilmeli ya da bir test ikisini karşılaştırmalı.
KISS'i "en az satır" sanmak. Tek satıra sıkıştırılmış iç içe ternary'ler kısa ama basit değil. Basitlik okuyan kişinin kafasında tutması gereken şeylerin azlığıyla ölçülür, karakter sayısıyla değil.
YAGNI'yi kalitesizliğe bahane yapmak. "Test'e şimdilik gerek yok" ya da "bu method'u şimdilik bölmeyelim" YAGNI değil; Fowler'ın açıkça dışarıda bıraktığı şeyler tam olarak bunlar. YAGNI özelliklere uygulanır, kodun sağlığına değil.
Prensipleri birbirine karşı kullanmak. DRY "birleştir", YAGNI "şimdilik ekleme", KISS "basit tut" der ve üçü aynı kodda çatışabilir. Ortak paydaları değişim maliyeti: hangisi bir sonraki değişikliği ucuzlatıyorsa o kazanır.
Küçük Bir Deneme
★☆☆ Satış ekibi kurumsal sözleşmede eşiği 250'ye çıkarıyor. Açılıştaki DiscountCalculator'da bu değişikliği yapmaya çalış ve üyelerin indirimini bozmadan nasıl yapabileceğini düşün. Aynı değişikliği ayrılmış sürümde yap ve iki deneyimi karşılaştır.
★★☆ Kendi kod tabanında CA1502'yi eşik 10 ile aç. En yüksek complexity'li üç method'u bul. Her biri için "bu sayı gerçekten okumayı zorlaştırıyor mu, yoksa düz bir switch mi?" sorusunu cevapla.
★★★ Bookstore'un KDV oranının hangi yerlerde yazılı olduğunu düşün: C# sabiti, veritabanı, fatura şablonu, frontend. Bu bilgiyi tek bir kaynaktan besleyecek bir tasarım öner ve yeni bir oran geldiğinde kaç dosyanın değişeceğini say.
Bir sonraki yazıda inheritance'a dönüyoruz. Bu yazıdaki wrong abstraction'ın class seviyesindeki karşılığı yanlış bir base class: fragile base class problemi, composition ve delegation ile davranışı takıp çıkarmak ve iki eksende büyüyen davranışların neden subclass patlamasına yol açtığı.