Clean Code'da Class'lar: Küçük Class, Cohesion ve Değişime Göre Düzenlemek
Class'ın büyüklüğü satırla değil, değişikliğini isteyen actor sayısıyla ölçülür; method'ların field paylaşmaması ve CA1822'nin "static yap" uyarıları düşük cohesion'ın işaretidir. Legacy OrderService'te aynı class'taki iki fiyat hesabı ayrışıp 731,50 TRY teklif edip 690,52 TRY çekiyordu; fiyatı tek bir OrderPricing class'ına toplayıp primary constructor, sealed ve required ile modern C# class'ları kuruyoruz.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et: Aynı Sepet, İki Fiyat
Ada sepet sayfasında bir fiyat teklifi görüyor, sonra aynı sepetle siparişi veriyor. İki çağrı da legacy OrderService'in içinde, aynı class'ta, birkaç satır arayla duruyor:
var service = new OrderService();
var cart = new Dictionary<string, int> { ["978-0441013593"] = 1, ["978-0553380163"] = 1 }; // Dune + sesli kitap
decimal quote = service.GetQuote(1, cart, null, 1);
int id = service.ProcessData(1, cart, null, false, false, 1);
Console.WriteLine($"{DateTime.Now.DayOfWeek}: quote={quote} charged={service.GetOrder(id).Total}");
Sepette Dune (450 TRY (Türk lirası)) ve A Brief History of Time'ın sesli kitap sürümü (250 TRY) var, Ada VIP (Very Important Person) müşteri, kupon yok. Ekranda gösterilen teklif ile karttan çekilen tutar aynı mı?
Class Büyüklüğü Satırla Değil Sorumlulukla Ölçülür
Bu yazıyı bir cumartesi çalıştırdım:
Saturday: quote=731.50 charged=690.52
Müşteriye 731,50 TRY gösterildi, 690,52 TRY çekildi. Bu sefer müşterinin lehine, ama tersi de olabilirdi. ProcessData sesli kitaplara %10 indirim ve hafta sonu %2 indirim uyguluyor; GetQuote ikisini de bilmiyor. Fiyat kuralının iki kopyası aynı class'ta, aynı dosyada duruyor ve yine de birbirinden ayrışmış. Sorun dosyanın 600 satır olması değil; o 600 satırın içinde birbirinden habersiz değişen işler olması.
Robert C. Martin (Uncle Bob) Clean Code kitabının (2008; ikinci baskısı 2025) classes bölümünde class'ın büyüklüğünü satırla değil sorumlulukla ölçer. Önerdiği pratik test isimle ilgili: class'ın ne yaptığını 25 kelime kadar bir cümleyle, "ve", "veya", "ama" kullanmadan anlatabiliyor musun? OrderService için deneyelim: "Sipariş alır ve fiyat teklifi hesaplar ve iptal eder ve kargoya verir ve müşteri oluşturur ve satış raporu üretir ve fatura yazar ve CSV (Comma-Separated Values) dosyasından kitap yükler." Adı OrderService olduğu için hepsini taşıyabiliyor; her şey bir şekilde siparişle ilgili.
Daha işe yarar bir soru şu: bu class'ın değişmesini kimler ister? Kupon ve VIP indirimini pazarlama, vergi oranını ve fatura biçimini muhasebe, kargo ücretini lojistik, iptal kurallarını müşteri hizmetleri, raporun kolonlarını yönetim, CSV formatını içerik ekibi değiştirir. Altı farklı ekip, altı farklı takvimle aynı dosyaya commit atıyor. Martin'in SRP (Single Responsibility Principle) tanımındaki "actor" kavramı bu; prensibin kendisi, actor tanımı ve aşırıya kaçınca ortaya çıkan parçalanma ilerideki SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) ve Design Principles serisinin "Single Responsibility Principle: 'Tek İş' Değil, Tek Actor" yazısında derinlemesine anlatılacak. Bu yazı daha mekanik bir şeye bakıyor: sorumlulukların ayrıştığını koddan nasıl görürsün.
Cohesion'ı Field Kullanımıyla Görmek
Cohesion (bir class'ın parçalarının birbirine ne kadar ait olduğu) için Martin'in ölçüsü basit: ideal class'ta her method class'ın field'larının çoğunu kullanır. Method'lar field'ları paylaşmıyorsa class aslında yan yana konmuş birkaç class'tır. Shyam Chidamber ve Chris Kemerer'in 1994 tarihli object-oriented metrik makalesindeki LCOM (Lack of Cohesion of Methods) metriği aynı fikrin sayısal hâli. Legacy class'ın method'larını kullandıkları state'e göre dizince:
| Method | Instance field | Static state | Değişikliği kim ister? |
|---|---|---|---|
ProcessData | pc, log, debug | LastError, cnt, Db, Mailer | Pazarlama, muhasebe, lojistik |
GetQuote | yok | Db | Pazarlama |
KargoUcretiHesapla | yok | yok | Lojistik |
CancelOrder | pc | LastError, Db, Mailer | Müşteri hizmetleri |
ShipOrder | yok | LastError, Db, Mailer, dosya sistemi | Lojistik |
CreateCustomer | yok | LastError, Db | Üyelik |
Report | yok | Db | Yönetim |
FaturaOlustur | yok | Db | Muhasebe |
ImportBooks | yok | LastError, Db, dosya sistemi | İçerik ekibi |
GetFromCache, ClearAll | cache, log | LastError, cnt | Kimse |
Altı instance field'dan pc'yi iki method, cache'i iki method kullanıyor; method'ların çoğu hiçbir instance field'a dokunmuyor. Class'ı bir arada tutan şey kendi state'i değil, herkesin eriştiği static Db. Bunu elle saymak zorunda değilsin; CA (Code Analysis, .NET'in yerleşik analyzer kuralları) kurallarından CA1822'yi .editorconfig'de açınca derleyici aynı şeyi söylüyor:
OrderService.cs(194,24): warning CA1822: Member 'KargoUcretiHesapla' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)
OrderService.cs(241,22): warning CA1822: Member 'GetOrder' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)
OrderService.cs(249,28): warning CA1822: Member 'GetOrders' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)
OrderService.cs(269,21): warning CA1822: Member 'CheckStock' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)
OrderService.cs(281,21): warning CA1822: Member 'TryCalc' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)
OrderService.cs(314,20): warning CA1822: Member 'CalcPoints' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)
OrderService.cs(324,20): warning CA1822: Member 'CreateCustomer' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)
OrderService.cs(343,23): warning CA1822: Member 'Report' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)
OrderService.cs(407,20): warning CA1822: Member 'ImportBooks' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)
OrderService.cs(439,25): warning CA1822: Member 'OldCalcDiscount' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)
OrderService.cs(451,21): warning CA1822: Member 'SendMail' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)
Yirmi method'dan on biri "beni static yap" diyor. CA1822'nin amacı aslında küçük bir performans kazancı, ama cohesion için güçlü bir işaret: instance state'e dokunmayan bir method, o class'ın içinde olmak için bir sebep taşımıyor. Aynı dosyada CA1506 (class coupling) ve CA1502 (cyclomatic complexity) kurallarını da CodeMetricsConfig.txt ile düşük eşiklerle açınca tablo tamamlanıyor:
OrderService.cs(11,18): warning CA1506: 'OrderService' is coupled with '28' different types from '10' different namespaces.
OrderService.cs(31,20): warning CA1502: 'ProcessData' has a cyclomatic complexity of '27'.
OrderService'i Parçalara Ayırmak
Tablodaki son kolon ayrıştırma planını zaten veriyor. Değişme sebebi aynı olan method'ları aynı class'a topluyoruz:
OrderPricing: birim fiyat, kupon, VIP, adet ve hafta sonu indirimi, kargo ücreti, vergi; hem teklif hem sipariş bunu kullanır.OrderPlacement: müşteri ve stok kontrolü, ödeme, stok düşme (yedinci yazıdakiResult<T>dönen hâliyle).OrderNotifier: onay, iptal ve kargo e-postalarının metni ve gönderimi.OrderCancellation,ShippingLabelService,InvoiceGenerator,SalesReport,CustomerRegistrationve yedinci yazıdakiBookCsvImporter.
Önce fiyat, çünkü açılıştaki hatanın kaynağı o:
// Fiyatı tek bir yer hesaplar: hem sepet teklifi hem sipariş
public sealed class OrderPricing(TimeProvider clock)
{
private const decimal TaxRate = 0.10m;
public PriceQuote Quote(IReadOnlyList<CartLine> lines, CustomerTier tier, string? couponCode, ShippingMethod shipping, string city)
{
decimal subtotal = lines.Sum(l => UnitPrice(l.Book) * l.Quantity);
decimal discount = CouponDiscount(couponCode, subtotal) + TierDiscount(tier, subtotal)
+ VolumeDiscount(lines.Count) + WeekendDiscount(subtotal);
decimal net = subtotal - discount;
decimal fee = ShippingFee(shipping, net, city);
decimal tax = net * TaxRate;
return new PriceQuote(subtotal, discount, fee, tax, Math.Round(net + tax + fee, 2));
}
private static decimal UnitPrice(Book book) => book.Format switch
{
BookFormat.Ebook => book.Price * 0.8m,
BookFormat.Audio => book.Price * 0.9m,
_ => book.Price
};
private static decimal CouponDiscount(string? code, decimal subtotal) => code?.ToUpperInvariant() switch
{
null or "" => 0m,
"WELCOME10" => subtotal * 0.10m,
"SUMMER25" => 25m,
_ => throw new ArgumentException($"Unknown coupon '{code}'.", nameof(code))
};
private static decimal TierDiscount(CustomerTier tier, decimal subtotal) => tier == CustomerTier.Vip ? subtotal * 0.05m : 0m;
private static decimal VolumeDiscount(int lineCount) => lineCount > 5 ? 15m : 0m;
private decimal WeekendDiscount(decimal subtotal) =>
clock.GetLocalNow().DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday ? subtotal * 0.02m : 0m;
private static decimal ShippingFee(ShippingMethod method, decimal amount, string city) => method switch
{
ShippingMethod.Standard when amount >= 500m => 0m,
ShippingMethod.Standard => city == "Istanbul" ? 29.90m : 39.90m,
ShippingMethod.Express => 89.90m,
ShippingMethod.StorePickup => 0m,
_ => throw new ArgumentOutOfRangeException(nameof(method), method, null)
};
}
var pricing = new OrderPricing(TimeProvider.System);
PriceQuote quote = pricing.Quote(cart, CustomerTier.Vip, null, ShippingMethod.Standard, "Istanbul");
Console.WriteLine($"{DateTime.Now.DayOfWeek}: {quote}");
Saturday: PriceQuote { Subtotal = 675.0, Discount = 47.250, Shipping = 0, Tax = 62.77500, Total = 690.52 }
Toplam legacy'nin çektiği tutarla aynı: 690,52. Teklif ekranı da sipariş de artık bu tek method'u çağırıyor, yani iki sayının ayrışması mümkün değil. Class'ın bütün private method'ları tek bir soruya cevap veriyor ve tek field'ı clock, hafta sonu kuralı için. Pazarlama yeni bir kupon istediğinde açılacak dosya belli, ve o dosyada fatura ya da CSV kodu yok. TimeProvider burada bilerek duruyor: .NET 8 ile BCL'ye (Base Class Library) eklenen bu saat abstraction'ı sayesinde hafta sonu kuralı cumartesiyi beklemeden test edilebilecek; nasıl yapılacağı bir sonraki yazının konusu.
Bildirim class'ı da aynı mantıkla küçük:
public sealed class OrderNotifier(IEmailSender sender)
{
public Task SendConfirmationAsync(OrderConfirmation order, CancellationToken ct = default) =>
sender.SendAsync(new EmailMessage(order.CustomerEmail, $"Order #{order.OrderId}", ConfirmationBody(order)), ct);
private static string ConfirmationBody(OrderConfirmation order)
{
var body = new StringBuilder();
body.AppendLine(CultureInfo.InvariantCulture, $"Dear {order.CustomerName},");
body.AppendLine(CultureInfo.InvariantCulture, $"Your order #{order.OrderId} has been received.");
foreach (ConfirmationLine line in order.Lines)
body.AppendLine(CultureInfo.InvariantCulture, $" - {line.Title} x{line.Quantity} = {line.LineTotal:0.00}");
body.AppendLine(CultureInfo.InvariantCulture, $"Shipping: {order.Shipping:0.00}");
body.AppendLine(CultureInfo.InvariantCulture, $"Total: {order.Total:0.00} TRY");
return body.ToString();
}
}
MAIL to ada@example.com: Order #1000
Dear Ada Lovelace,
Your order #1000 has been received.
- Dune x1 = 450.00
- A Brief History of Time x1 = 225.00
Shipping: 0.00
Total: 690.52 TRY
OrderNotifier'ın Order entity'sini değil, kendi ihtiyacı kadar veriyi taşıyan OrderConfirmation record'unu aldığına dikkat et; e-posta metni değiştiğinde siparişin modeline dokunmuyorsun. Aynı CA1502 ve CA1506 eşikleriyle yeni class'ları ölçünce OrderPricing 18 tipe, OrderNotifier 12 tipe bağlı çıkıyor ve en karmaşık method'un cyclomatic complexity'si 2. Coupling sayısının büyük kısmı decimal, Enumerable, Func<T, TResult> gibi BCL tipleri; bu metrik onları da sayıyor, o yüzden sayıları mutlak bir hedef değil yön göstergesi olarak oku.
.NET'in kendisinde de büyük işler küçük class'lara bölünmüş durumda. HttpClient istek göndermeyi, HttpMessageHandler zinciri bağlantıyı, DelegatingHandler türevleri retry ya da log gibi kesişen işleri üstleniyor. System.Text.Json'da Utf8JsonReader okumayı, Utf8JsonWriter yazmayı, JsonSerializer ikisini kullanarak object'e çevirmeyi yapıyor. Her biri tek bir sebeple değişiyor.
Modern C# ile Class Kurmak
Küçük class'ın C# 12 ile gelen en doğal aracı primary constructor. Bağımlılıkları ve configuration'ı alan, method'larında kullanan bir class için ideal; OrderPricing(TimeProvider clock) ve OrderNotifier(IEmailSender sender) böyle. Bilmen gereken bir tuzak var: primary constructor parametresi readonly değildir ve onu ayrıca bir field'a kopyalarsan aynı değerin iki ayrı kopyasını taşırsın:
public sealed class StockCounter(int initial)
{
private readonly int _initial = initial;
public int Remaining => initial;
public void Reset() => initial = _initial - 1;
}
Program.cs(25,41): warning CS9124: Parameter 'int initial' is captured into the state of the enclosing type and its value is also used to initialize a field, property, or event.
Reset içindeki atama derleniyor; parametre class'ın içinde sıradan, değiştirilebilir bir değişken gibi yaşıyor. Kural basit: parametreyi ya doğrudan kullan ya da bir readonly field'a ata, ikisini birden yapma. Derleyicinin CS9124 uyarısı tam olarak bu karışıklığı yakalıyor.
İkinci araç sealed. Bu serideki class'ların hepsi sealed, çünkü inheritance için tasarlanmamış bir class'ı açık bırakmak, kimsenin düşünmediği bir extension point vaat etmek demek. Inheritance gerçekten gerekirse sealed'ı kaldırmak geriye dönük uyumlu bir değişiklik; tersi değil. Ek olarak JIT (Just-in-Time compiler) sealed tiplerde virtual çağrıları ve type check'leri daha kolay devirtualize edebiliyor. Internal tipler için CA1852 bunu hatırlatıyor:
Program.cs(18,20): warning CA1852: Type 'InvoiceNumberFormatter' can be sealed because it has no subtypes in its containing assembly and is not externally visible (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1852)
Üçüncüsü required ve init ile geçerli object kurmak. Legacy Book'un bütün property'leri { get; set; }; ISBN'i (International Standard Book Number) olmayan bir kitap kurmak da, kurduktan sonra fiyatı her yerden değiştirmek de mümkün. Yeni sürümde:
public sealed class Book
{
public required string Isbn { get; init; }
public required string Title { get; init; }
public required decimal Price { get; init; }
public BookFormat Format { get; init; } = BookFormat.Print;
}
ISBN'i unutan bir object initializer artık derlenmiyor:
var book = new Book { Title = "Dune", Price = 450m };
Program.cs(3,16): error CS9035: Required member 'Book.Isbn' must be set in the object initializer or attribute constructor.
required "bu değer olmadan object kurulamaz" der, init "kurulduktan sonra değişmez" der. Doğrulama gerekiyorsa (negatif fiyat gibi) constructor ya da altıncı yazıdaki value object'ler daha güçlü bir garanti verir; required yalnız atanıp atanmadığına bakar.
Son olarak static. CA1822'nin işaret ettiği method'ların hepsi kötü değil: OrderPricing.UnitPrice gibi state'siz, saf bir hesaplama private static olarak class'ın içinde gayet doğru duruyor. Kötü olan static state: Db, Mailer, LastError, cnt. Saf static fonksiyon test edilebilir ve paylaşılabilir; static state ise her class'ı görünmez bir şekilde birbirine bağlar.
Manager, Helper, Util: Çöplük Class'lar
OrderService'i bölerken en kolay hata, ayıramadığın method'ları OrderHelper'a ya da CommonUtils'e koymak. Bu isimler bir sorumluluk değil bir konum tarif eder: "başka yere sığmayanlar". Altı ay sonra OrderHelper'da fatura numarası formatı, telefon doğrulaması ve bir SQL (Structured Query Language) sorgusu yan yana durur ve yeni bir OrderService doğmuş olur.
İsim testi burada da işe yarıyor. Bir method'u Helper'a koymak üzereysen, onun neyle ilgili olduğunu sor ve o kavramın adını kullan: fatura numarası InvoiceNumber'ın, telefon doğrulaması PhoneNumber value object'inin işi. Manager için de aynısı geçerli: OrderManager ile OrderService arasında kimse bir fark söyleyemez. Bu yazıdaki class adlarının (OrderPricing, OrderNotifier, OrderCancellation) her biri neyi yaptığını ve neyi yapmadığını aynı anda söylüyor.
Sık Düşülen Tuzaklar
Her method'u kendi class'ına taşımak. CouponDiscountCalculator, TierDiscountCalculator, VolumeDiscountCalculator ve bunları birleştiren bir DiscountCalculatorAggregator aynı değişme sebebini dört dosyaya dağıtır; pazarlama yeni bir kural istediğinde dört dosyayı açarsın. Cohesion'ın ölçüsü yine aynı: birlikte değişen birlikte durmalı. John Ousterhout'un A Philosophy of Software Design (2018) kitabındaki "shallow module" eleştirisi tam olarak bu aşırılığa yönelik; on üçüncü yazıda bu tartışmaya döneceğiz.
Ayrıştırmayı dosya bazında yapıp state'i paylaşmaya devam etmek. Yeni class'ların hepsi hâlâ static Db'ye erişiyorsa, OrderService'i sadece altı dosyaya bölmüş olursun; coupling aynı kalır. Her class bağımlılığını constructor'dan almalı ve DI (Dependency Injection) container'ı onları bağlamalı. Mekaniği C# İleri serisinin "C#'ta Dependency Injection Temelleri: Lifetime'lar, Container ve Captive Dependency" yazısında.
Class'ları teknik katmana göre ayırmak. OrderValidator, OrderCalculator, OrderPersister gibi bir bölme her iş değişikliğinde üç class'ı birlikte değiştirmeni gerektirebilir. Ayırma çizgisini değişikliği isteyen kişiye göre çek.
Order entity'sinin kendisini unutmak. Altıncı yazıda gördüğümüz gibi bazı kurallar (satır ekleme, toplam) bir service'te değil, verinin sahibi olan object'te durmalı. Her şeyi yeni service class'larına taşırsan anemic bir domain model'e varırsın.
Ne Zaman Kullanma
Küçük ve stabil bir class'ı yalnız uzun diye bölme. 300 satırlık bir InvoicePdfRenderer tek bir şeyi yapıyorsa ve yalnız muhasebe değiştiriyorsa, onu beşe bölmek okuyanın işini zorlaştırır; satır sayısı sorumluluk sayısı değildir.
Prototip ya da bir haftalık bir iç araç için baştan beş class tasarlama. Önce çalışan bir class yaz; ikinci actor değişiklik istediğinde ayır. Ayırmanın en iyi zamanı, iki farklı sebeple aynı dosyaya dokunduğun ilk an.
Primary constructor'ı her yerde kullanma. Parametrelerini doğrulaman gereken ya da birden fazla constructor'ı olan bir class'ta klasik constructor daha açık okunur. required de bir domain doğrulaması değil; zorunlu ama her değeri kabul eden alanlar içindir.
Küçük Bir Deneme
★☆☆ Legacy FaturaOlustur'u InvoiceGenerator class'ına taşı. Vergiyi kendi içinde yeniden hesaplamak yerine OrderPricing'in PriceQuote'unu kullansın; legacy kodda faturadaki vergi ile siparişteki vergi aynı formülle mi hesaplanıyordu?
★★☆ OrderCancellation'ı yaz: iade, stok iadesi, puan düşme ve bildirim. Hangi bağımlılıkları constructor'dan alıyor? Puan düşme kuralı bu class'a mı, Customer'a mı ait?
★★★ CA1822, CA1502 ve CA1506'yı bütün projede warning yap ve eşikleri CodeMetricsConfig.txt ile belirle. Legacy dosyada kaç uyarı çıkıyor, ayrıştırılmış hâlinde kaç? Hangi uyarıları bilinçli olarak kapatırdın?
Bu yazıda OrderService'i değişme sebeplerine göre böldük ve fiyat kuralını tek bir yere topladık. OrderPricing'in hafta sonu kuralı ise bir soruyu açık bırakıyor: cumartesi olmadığı bir günde bu kodu nasıl test edersin? Bir sonraki yazı test kodunun kendisine bakıyor: F.I.R.S.T. (Fast, Independent, Repeatable, Self-Validating, Timely) kuralları, okunabilir assert'ler, test data builder ve saati TimeProvider ile kontrol etmek.