Prensiplerin Eleştirisi: CUPID, Over-Engineering ve SOLID'in Gerçekten Gerektiği Yer
SOLID'in değeri bağlama bağlı: çok ekipli, uzun ömürlü ve sık değişen kodda değişim maliyetini düşürür, script, prototip ve küçük CRUD uygulamalarında ise çoğu zaman yalnız katman ekler. Aynı bildirim özelliğinin minimum, dengeli ve aşırı üç tasarımında push kanalı eklemenin ölçülen maliyeti, Dan North'un CUPID önerisi ve Ousterhout'un deep module uyarısı.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Serinin ilk yazısında Bookstore'un sipariş module'ünü ve bildirimlerini başlangıç noktası yapmış, push kanalının bir gün geleceğini söylemiştik. O gün geldi. Aynı özelliği, sipariş verilince müşteriye tercih ettiği kanaldan haber vermeyi, üç farklı ekip üç farklı biçimde yazmış olsun:
- Minimum: ilk yazıdaki gibi,
OrderServiceiçinde birswitch. İki dosya. - Dengeli: bir
INotificationChannelinterface'i, her kanal için bir class, bağlantılar composition root'ta. Üç dosya. - Aşırı: her şeyin bir interface'i var; channel factory, message formatter, recipient resolver, notification service, DTO (Data Transfer Object) ve mapper. Yirmi dosya.
Üçü de bugün aynı çıktıyı veriyor. Push kanalını eklemek her birinde kaç dosyaya dokunmayı ve kaç satır yazmayı gerektirir? Hangisi SOLID'e (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) en çok uyuyor, hangisi en ucuz?
Üç Tasarım, Bir Değişiklik
Üç tasarımı da yazıp her birine push kanalını ekledim, sonra ikisini git diff --no-index ile karşılaştıran küçük bir script'le ölçtüm:
#!/bin/zsh
# Her tasarımın boyutu (v1) ve push kanalını eklemenin maliyeti (v1 → v2)
cd "$(dirname "$0")"
printf "%-10s %6s %6s %6s %8s %8s\n" design files lines types changed added
for d in minimal balanced excessive; do
files=$(find $d/v1 -name '*.cs' | wc -l | tr -d ' ')
lines=$(cat $d/v1/**/*.cs | wc -l | tr -d ' ')
types=$(cat $d/v1/**/*.cs | grep -cE '^public (sealed |static )?(class|interface|record|enum) ')
stat=$(git diff --no-index --shortstat $d/v1 $d/v2)
changed=$(echo $stat | grep -oE '[0-9]+ files? changed' | grep -oE '[0-9]+')
added=$(echo $stat | grep -oE '[0-9]+ insertions?' | grep -oE '[0-9]+')
printf "%-10s %6s %6s %6s %8s %8s\n" $d $files $lines $types $changed $added
done
design files lines types changed added
minimal 2 50 5 2 8
balanced 3 62 8 3 12
excessive 20 191 21 7 33
Her üçünde de 2 satır demo'nun kendisi (yeni müşteri ve yeni sipariş); kalanlar tasarımın bedeli. Sonuçlar tek başına bir hüküm vermiyor, o yüzden değişikliklere tek tek bakalım. Minimum tasarımda fark şu (git diff çıktısının başlık satırlarını çıkardım):
-public enum NotificationChannel { Email, Sms }
+public enum NotificationChannel { Email, Sms, Push }
-public record Customer(int Id, string Name, string Email, string Phone, NotificationChannel PreferredChannel);
+public record Customer(int Id, string Name, string Email, string Phone, NotificationChannel PreferredChannel,
+ string? DeviceToken = null);
@@ -32,6 +33,9 @@ public sealed class OrderService
case NotificationChannel.Sms:
Console.WriteLine($"[sms] to {customer.Phone}: {message}");
break;
+ case NotificationChannel.Push:
+ Console.WriteLine($"[push] to {customer.DeviceToken}: {message}");
+ break;
En az satır bu. Ama yeni satırlar sipariş kaydını yapan method'un ortasına girdi: push'u eklemek için siparişin nasıl kaydedildiğini bilen kodu açıp değiştirdin ve onun testlerini yeniden koşman gerekiyor. Dengeli tasarımda OrderService'e hiç dokunulmadı:
// Değişmesi beklenen tek nokta: kanal
public interface INotificationChannel
{
void Send(Customer customer, string message);
}
public sealed class OrderService(IReadOnlyDictionary<NotificationChannel, INotificationChannel> channels)
{
private readonly List<Order> _orders = [];
public Order PlaceOrder(Customer customer, IReadOnlyList<OrderItem> items)
{
Order order = new(1001 + _orders.Count, customer, items);
_orders.Add(order);
channels[customer.PreferredChannel].Send(customer, $"Order {order.Id} confirmed: {order.Total:0.00} EUR");
return order;
}
}
namespace Bookstore.Orders;
public sealed class PushChannel : INotificationChannel
{
public void Send(Customer customer, string message) => Console.WriteLine($"[push] to {customer.DeviceToken}: {message}");
}
// Composition root
OrderService orders = new(new Dictionary<NotificationChannel, INotificationChannel>
{
[NotificationChannel.Email] = new EmailChannel(),
[NotificationChannel.Sms] = new SmsChannel(),
[NotificationChannel.Push] = new PushChannel(),
});
Dört satır fazla yazıldı ama değişiklik yeni bir dosyada ve composition root'ta; enum ve Customer'a eklenen field her tasarımda ortak. Aşırı tasarımın dosya listesi:
Abstractions/IChannelFactory.cs Abstractions/IMessageFormatter.cs Abstractions/INotificationChannel.cs Abstractions/INotificationService.cs Abstractions/IRecipientResolver.cs Channels/EmailChannel.cs Channels/SmsChannel.cs Domain/Customer.cs Domain/NotificationChannel.cs Domain/Order.cs Dtos/NotificationRequest.cs Formatters/EmailMessageFormatter.cs Formatters/SmsMessageFormatter.cs Mapping/NotificationRequestMapper.cs Program.cs Resolvers/EmailRecipientResolver.cs Resolvers/SmsRecipientResolver.cs Services/ChannelFactory.cs Services/NotificationService.cs Services/OrderService.cs
using Bookstore.Orders.Abstractions;
using Bookstore.Orders.Channels;
using Bookstore.Orders.Domain;
using Bookstore.Orders.Formatters;
using Bookstore.Orders.Resolvers;
namespace Bookstore.Orders.Services;
public sealed class ChannelFactory : IChannelFactory
{
// Bütün katmanlara rağmen switch geri geldi
public (INotificationChannel Channel, IMessageFormatter Formatter, IRecipientResolver Resolver) Create(NotificationChannel channel) =>
channel switch
{
NotificationChannel.Email => (new EmailChannel(), new EmailMessageFormatter(), new EmailRecipientResolver()),
NotificationChannel.Sms => (new SmsChannel(), new SmsMessageFormatter(), new SmsRecipientResolver()),
NotificationChannel.Push => (new PushChannel(), new PushMessageFormatter(), new PushRecipientResolver()),
_ => throw new NotSupportedException($"Channel {channel} is not supported.")
};
}
Push için üç yeni class (channel, formatter, resolver) ve factory'deki switch'e bir satır gerekti. Bütün o katmanlara rağmen switch geri geldi, sadece başka bir dosyaya taşındı. E-posta ve SMS (Short Message Service) formatter'ları birebir aynı metni üretiyor; ayrı class olmalarının tek nedeni "ileride farklılaşabilir" düşüncesi. Bu, DRY (Don't Repeat Yourself) ve YAGNI (You Aren't Gonna Need It) yazısındaki carry cost'un somut hâli: minimum tasarımın 50 satırına karşı 191 satır, ama ek satırların hiçbiri bugün bir değişikliği kolaylaştırmıyor; üstelik her yeni kanalda üç dosya daha istiyor ve kodu okuyan biri bir bildirimin nasıl gönderildiğini anlamak için altı dosya açıyor.
Bu ölçümden çıkan sonuç "minimum en iyisi" değil. İki kanal ve tek ekip varken minimum tasarım çok makul; üçüncü kanal geldiğinde dengeliye geçmek küçük bir refactoring. Asıl soru, değişimin hangi yönden ve hangi sıklıkla geldiği. Prensipler bu soruya cevap vermez; soruyu sormanı sağlar.
Prensiplere Yöneltilen Eleştiriler
Belirsizlik. SRP (Single Responsibility Principle) "bir class'ın değişmek için tek bir nedeni olmalı" der, ama "neden"in ne olduğunu söylemez. Robert C. Martin (SOLID'i derleyip yaygınlaştıran yazar) 2014'te bu prensibi açıklamak için ayrı bir yazı yazmak zorunda kaldı ve nedeni "değişikliği isteyen kişi ya da grup", yani actor olarak yeniden tanımladı; bu serinin SRP yazısı da bu tanımdan yola çıktı. Bir prensibin yazarının yirmi yıl sonra onu yeniden anlatmak zorunda kalması, kelimelerin ne kadar farklı okunabildiğini gösteriyor.
Interface enflasyonu. DIP (Dependency Inversion Principle) yazısında gördüğümüz header interface'ler, her class'ın yanında bir I'lı ikiz. Yukarıdaki aşırı tasarımda yirmi bir tipin beşi interface ve hiçbirinin ikinci bir implementation'ı yok. ISP (Interface Segregation Principle) de tek method'lu interface'lere doğru yanlış okunduğunda aynı enflasyonu besler.
Katman başına mapping. API'den (Application Programming Interface) gelen request bir DTO'ya, DTO bir command'e, command bir entity'ye, entity bir persistence modeline çevrilir; her katman "bağımsız" olsun diye. Dört tip, üç mapper ve her yeni field için dört yerde değişiklik. Bağımsızlığın değeri varsa bu bedel ödenir; yoksa yalnız bedel kalır.
Bağlam. SOLID 1990'ların C++ dünyasında doğdu; ISP'nin bir gerekçesi, bir header dosyası değişince ona bağımlı her şeyin yeniden derlenmesiydi. Martin 2020'deki "Solid Relevance" yazısında bunun derlenen dillerde hâlâ geçerli olduğunu savunur; önceki yazıdaki reference assembly ölçümü de public yüzey değişince bağımlıların gerçekten yeniden derlendiğini gösterdi. Ama aynı yazıda gösterdiğimiz gibi, yalnız method gövdesi değişince .NET bağımlıları yeniden derlemiyor; bazı gerekçelerin ağırlığı değişti.
Dan North ve CUPID
En bilinen eleştiri Dan North'tan (behaviour-driven development'ı ortaya koyan yazılımcı) geldi. 2016'da PubConf London'da yaptığı beş dakikalık "Why Every Element of SOLID is Wrong" konuşmasında her harf için aynı öneriyi tekrarladı: basit kod yaz. Martin 2020'de "Solid Relevance" yazısıyla harf harf cevap verdi; "basit kod yaz" önerisine katıldığını, ama basitliğe ulaşmak için disiplin ve prensip gerektiğini, yalnız "basit ol" demenin karmaşık bir dağınıklığın en kısa yolu olduğunu savundu.
North 2022'de eleştiriyi bir öneriye çevirdi: CUPID (Composable, Unix philosophy, Predictable, Idiomatic, Domain-based). "CUPID—for joyful coding" yazısındaki temel fikir, prensip yerine property: prensip bir kuraldır, kod ya uyar ya uymaz; property ise doğru yöne ilerleyebileceğin bir niteliktir, kod ona her zaman biraz daha yakın ya da uzak olur. Kısaltmadaki beş property:
- Composable: başkalarıyla iyi geçinir; küçük yüzey, az bağımlılık, niyeti açık isimler. C#'ta LINQ (Language Integrated Query) operatörleri iyi bir örnek.
- Unix philosophy: tek bir işi iyi yapar; SRP'ye benzer ama "değişim nedeni" yerine "amaç" üzerinden.
- Predictable: beklendiği gibi davranır; deterministik, gözlemlenebilir, sürprizsiz. Principle of Least Astonishment ve fail fast'in akrabası.
- Idiomatic: doğal hissettirir; dilin ve ekibin alışkanlıklarına uyar. .NET'te bu, Framework Design Guidelines'ın (Microsoft'un .NET kütüphane tasarım kuralları) isimlendirme ve API convention'larına uymak demek.
- Domain-based: çözümün dili ve yapısı problemin dilini ve yapısını yansıtır; ilerideki Domain-Driven Design serisinin ana fikri.
CUPID, SOLID'in yerine geçen bir kural seti değil, başka bir bakış açısı. SOLID kodun değişim karşısındaki yapısına bakar; CUPID kodu okuyan ve kullanan insanın deneyimine. İkisini birlikte kullanmak mümkün: "bu class SRP'ye uyuyor mu?" sorusunun yanına "bu kodu ilk kez gören biri ne yaptığını tahmin edebilir mi?" sorusunu eklemek.
Deep Module'ler: Ousterhout'un Uyarısı
John Ousterhout (Stanford'da bilgisayar bilimi profesörü, Tcl dilinin yaratıcısı) 2018 tarihli A Philosophy of Software Design kitabında farklı bir açıdan aynı yere varır. İyi bir module "deep" olmalıdır: basit bir interface'in arkasında güçlü bir işlevsellik. Kötü bir module "shallow"dur: interface'i, sağladığı işlevsellik kadar karmaşıktır. Küçük class'ları kendi başına erdem sayan yaklaşıma "classitis" der ve örnek olarak Java'da bir dosyadan object okumak için üç stream'i elle üst üste takmak gerektiğini gösterir; buffering gibi neredeyse herkesin istediği bir şey varsayılan olmalıydı.
Bu, composition yazısında övdüğümüz Stream decorator'larına ilginç bir karşı ağırlık. .NET burada iki yolu birden sunar: FileStream varsayılan olarak kendi içinde buffer kullanır, File.ReadAllText ise dosya açmayı, encoding tespitini ve kapatmayı tek bir çağrının arkasına saklar. İkisi de deep module. Aşırı tasarımdaki EmailRecipientResolver ise shallow'ın ders kitabı örneği: tek satırlık bir property erişimini bir interface ve bir class'ın arkasına saklıyor.
SOLID'i Ne Zaman Gevşetmeli?
Script'ler ve prototipler. Bir kez çalışıp atılacak bir veri taşıma script'i ya da fikrin işe yarayıp yaramadığını görmek için yazılan bir prototip için abstraction kurmak, cevabını bilmediğin bir soruya yatırım yapmaktır. Prototip yaşamaya karar verdiğinde yeniden yazmak ya da refactor etmek daha ucuz.
Küçük CRUD (Create, Read, Update, Delete) uygulamaları. İş kuralı olmayan, veriyi tablodan ekrana taşıyan bir uygulamada repository, service ve mapper katmanları çoğu zaman yalnız aktarım yapar. ASP.NET Core Web API serisindeki "Katmanlı Yapı: Service Katmanı ve Repository Tartışması" yazısı Entity Framework Core'un (EF Core) DbContext'inin zaten bir repository ve unit of work olduğunu tartışıyordu.
Hot path'ler. Saniyede milyonlarca kez çağrılan bir döngüde her interface çağrısı bir dolaylılık demek. .NET 8'den beri varsayılan olarak açık olan dynamic PGO (profile-guided optimization), JIT'in (Just-in-Time compiler) çalışma anında en sık görülen implementation'ı tahmin edip çağrıyı doğrudan yapmasını sağlayarak bu maliyetin çoğunu siliyor; sealed class'lar da JIT'e aynı bilgiyi baştan verir. Yine de ölçmeden karar verme: önce BenchmarkDotNet, sonra tasarım değişikliği.
SOLID Nerede Gerçekten Şart?
Çok ekipli kod tabanları. Melvin Conway'in 1968'de dile getirdiği gözleme göre sistemler, onları tasarlayan organizasyonun iletişim yapısını kopyalar. Bir sipariş module'ünü üç ekip birlikte değiştiriyorsa SRP'nin actor tanımı ve component prensipleri teorik değil, günlük sürtünmenin ilacıdır.
Uzun ömürlü ve sık değişen domain'ler. Beş yıl yaşayacak, her ay yeni kanal, yeni kampanya, yeni ödeme sağlayıcısı gelecek bir sistemde değişim noktalarını önceden ayırmak, açılıştaki gibi yirmi dosyalık bir tasarım değil, doğru yerdeki üç dosyalık bir tasarım demek.
Public kütüphaneler. Başkalarının kullandığı bir API'yi geri almak çok pahalı. Bir NuGet paketi yayınlıyorsan LSP (Liskov Substitution Principle), ISP ve component prensipleri bir tercih değil, kullanıcılarına verdiğin sözün parçası. .NET ekibinin virtual member'lara ve extensibility noktalarına bu kadar temkinli yaklaşması bu yüzden.
Seriyi Toparlamak
On iki yazı boyunca her prensibi bir soru olarak ele aldık. Tek bir tabloda:
| Prensip | Sorduğu soru |
|---|---|
| Coupling, cohesion, Law of Demeter | Bu iki parça ne kadar sıkı bağlı ve neden? |
| SRP | Bu kodu kim, hangi nedenle değiştirmek ister? |
| OCP (Open/Closed Principle) | Beklenen değişiklik mevcut kodu açmadan eklenebilir mi? |
| LSP | Subtype, base type'ın verdiği sözü tutuyor mu? |
| ISP | Client gerçekten kullanmadığı şeylere bağımlı mı? |
| DIP | Compile-time ok politikadan detaya mı, detaydan politikaya mı? |
| DRY, KISS (Keep It Simple, Stupid), YAGNI | Bu bilgi kaç yerde, bu karmaşıklık bugün neye hizmet ediyor? |
| Composition over inheritance | Davranış bir hiyerarşiye mi gömülü, takılıp çıkarılabilir mi? |
| Fail fast, CQS (Command-Query Separation), GRASP (General Responsibility Assignment Software Patterns) | Hata ne zaman görünür, soru cevabı değiştirir mi, sorumluluk kimde? |
| Component prensipleri | Bu project'te birlikte değişenler mi duruyor, oklar stabil olana mı gidiyor? |
Hepsinin altındaki ortak ölçüt aynı: bir sonraki muhtemel değişiklik ne kadara mal olacak? İlk yazıda saydığımız dört belirti (rigidity, fragility, immobility, viscosity) bu maliyetin dört farklı görünüşüydü.
Sık Düşülen Tuzaklar
Prensibi hedef yapmak. "Bu kod SOLID olsun" bir hedef değil; "push kanalı eklemek bir dosya olsun" bir hedef. Prensipler hedefe giden yolda karar vermeye yardım eder.
Eleştiriyi prensibi atmanın gerekçesi yapmak. North'un eleştirisi de Ousterhout'un uyarısı da aşırı uygulamaya yönelik. Hiç ayrıştırılmamış, her şeyin her şeye bağlı olduğu bir kod tabanı CUPID'e de uymaz.
Tasarımı bir kez yapıp bırakmak. Minimum tasarım üçüncü kanal geldiğinde dengeliye, dengeli tasarım on kanal ve beş ekip olduğunda daha fazla ayrışmaya evrilmeli. Doğru tasarım bir an değil, bir yön.
Tartışmayı kısaltmalarla yapmak. "Bu OCP ihlali" yerine "push kanalı geldiğinde OrderService'i açmamız gerekecek ve onun testleri yeniden koşacak" demek hem daha ikna edici hem doğrulanabilir.
Küçük Bir Deneme
★☆☆ Üç tasarıma da kanal eklemekten daha sıradan bir değişiklik uygula: sipariş onay metnine sipariş kalemlerini ekle. Hangi tasarımda kaç dosya değişti? Push kanalındaki sıralama aynı kaldı mı?
★★☆ Aşırı tasarımı dengeli tasarıma doğru refactor et: hangi interface'leri silebilirsin, hangi class'ları birleştirebilirsin? Silmeden önce her biri için "bunun ikinci bir implementation'ı bugün var mı ya da yakında olacak mı?" diye sor.
★★★ İlk yazının sonunda push kanalını eklerken dokunduğun her satırı rigidity, fragility, immobility ve viscosity'ye göre not etmeni istemiştik. O listeyi aç ve dengeli tasarımda aynı değişikliği yaparak her satırın hangi prensip sayesinde ortadan kalktığını yaz.
SOLID ve Design Principles serisi burada bitiyor. Prensipler bir tasarımın iyi olup olmadığını değerlendirmek için kullandığın ölçütlerdi; bir sonraki adım, bu ölçütlerin tekrar tekrar karşılaştığı problemlere verilmiş adlandırılmış çözümler. İlerideki Design Patterns serisinde Strategy, Decorator, Adapter ve diğerlerini bu serideki sorularla birlikte okuyacağız; method ve isim seviyesindeki okunabilirlik ise Clean Code serisinin konusu.