Sertaç Yıldırım saha notları

Ana sayfa → Teknik

SOLID: Amaç Değil, Araç — Ne Zaman Kullanmalı, Ne Zaman Kaçınmalı?

Code review’da bir PR: “Hoş geldin mesajı.” 5 dosya, 2 interface, 2 class, 1 factory, 30 satır. İşin tamamı tek bir string: “Hoş geldin, Ayşe!” Yorumuma gelen cevap: “SOLID’e uygun olsun diye.” Kurala uyuyordu — kısmen: bir yıl önce code review rehberine “her servisin bir interface’i olur” satırını yazan bendim; o satır IGreetingService’i açıklıyor. Provider ve factory ise “ileride farklı diller olabilir” diye eklenmişti.

Özet
  • SOLID bir amaç değil, araç. Beş prensibin her biri belirli bir acıyı çözmek için var. Acı yoksa araç da gerekmez.
  • Doğru yer: değişimin ve çoğulluğun olduğu yer. Sık değişen iş kuralı, birden fazla implementasyon, test edilemeyen class, gerçekçi bir provider değişimi.
  • Yanlış yer: tek implementasyon, kısa ömür, “ileride lazım olur”. Burada prensipler esneklik değil, dosya sayısı üretir.
  • Ölçü prensibe uyum değil. Kodun anlaşılırlığı ve değişikliğin kaç dosyaya dokunduğu.
  • Doğru soru: “SOLID’e uyuyor mu?” değil, “bu yapı hangi problemi çözüyor ve o problem gerçekten var mı?”

SOLID neyi çözmek için var?

Beş harf, beş ayrı acı. Her birini önce “acı” ile, sonra kodla anlatıyorum. Örnekler C#; fikir dilden bağımsız.

S — Single Responsibility: tek iş değil, tek değişme sebebi

Fatura tutarını hesaplayan class, faturayı e-posta ile de gönderiyorsa iki ayrı sebeple değişir: vergi oranı değişince ve e-posta şablonu ya da gönderim yöntemi değişince. İkisi aynı class’taysa e-posta değişikliği hesaplama tarafını da riske atar, yeniden test ister; hesaplama testi de e-posta bağımlılığı yüzünden zorlaşır.

İki sebep, bir class
public class InvoiceService
{
    public decimal CalculateTotal(Invoice invoice) { /* hesaplama */ }
    public void SendByEmail(Invoice invoice)       { /* e-posta gonderimi */ }
}
Her sebebe bir class
public class InvoiceCalculator
{
    public decimal CalculateTotal(Invoice invoice) { /* hesaplama */ }
}

public class InvoiceEmailSender
{
    public void Send(Invoice invoice) { /* e-posta gonderimi */ }
}

Dikkat: “single responsibility” “tek metot” demek değil. Ölçü, class’ı kaç farklı sebebin değiştirdiği.

O — Open/Closed: yeni else if değil, yeni class

Her yeni ödeme yönteminde aynı metodu açıp bir dal daha ekliyorsan, çalışan kodu her seferinde yeniden riske atıyorsun. Her yeni yöntem, öncekilerin hepsini yeniden test etmek demek.

Her yöntem = metodu aç, değiştir
public void Pay(string method, decimal amount)
{
    if (method == "CreditCard")   { /* kredi karti */ }
    else if (method == "PayPal")  { /* PayPal */ }
    // yeni yontem = yeni else if = calisan kodu tekrar test et
}
Her yöntem = yeni class, eski koda dokunma
public interface IPaymentMethod
{
    void Pay(decimal amount);
}

public class CreditCardPayment : IPaymentMethod
{
    public void Pay(decimal amount) { /* kredi karti */ }
}

public class PayPalPayment : IPaymentMethod
{
    public void Pay(decimal amount) { /* PayPal */ }
}

Dürüst olalım: method string’inden hangi class’ın seçileceği kararı yok olmuyor, tek bir yere taşınıyor — bir factory, bir dictionary ya da DI kaydı. Yeni yöntemde orası da değişir. Kazanç if zincirinin kaybolması değil; çalışan ödeme kodunun her seferinde yeniden riske girmemesi.

L — Liskov: “matematikte öyle” değil, behavior contract

Alt class, üst class’ın yerine konduğunda üst tipi kullanan kodun beklentileri — yani contract — bozulmamalı. Davranışın farklı olması normal, polimorfizm bunun için var; bozulmaması gereken şey çağıran kodun doğruluğu. Klasik tuzak: kare bir dikdörtgendir, evet — matematikte. Kodda Square’i Rectangle’dan türetip genişliği değiştirince yüksekliği de değiştirirsen, dikdörtgen bekleyen kod yanlış alan hesaplar.

20 beklerken 16
public class Rectangle
{
    public virtual int Width  { get; set; }
    public virtual int Height { get; set; }
    public int Area => Width * Height;
}

public class Square : Rectangle
{
    public override int Width  { set { base.Width = base.Height = value; } }
    public override int Height { set { base.Width = base.Height = value; } }
}

Rectangle r = new Square();
r.Width  = 5;
r.Height = 4;
Console.WriteLine(r.Area);   // 20 bekleniyor, 16 cikiyor
Inheritance değil, ortak abstraction
public interface IShape
{
    int Area { get; }
}

public class Rectangle : IShape
{
    public int Width  { get; set; }
    public int Height { get; set; }
    public int Area => Width * Height;
}

public class Square : IShape
{
    public int Side { get; set; }
    public int Area => Side * Side;
}

I — Interface Segregation: dev interface değil, amaca özel interface

Basit bir yazıcı, tarama ve faks metotlarını da uygulamak zorunda kalıyorsa interface yanlış kesilmiş demektir. Belirtisi net: throw new NotSupportedException() ile dolu class’lar.

Uygulayamadığını uygulamak
public interface IMachine
{
    void Print(Document doc);
    void Scan(Document doc);
    void Fax(Document doc);
}

public class BasicPrinter : IMachine
{
    public void Print(Document doc) { /* yazdir */ }
    public void Scan(Document doc)  => throw new NotSupportedException();
    public void Fax(Document doc)   => throw new NotSupportedException();
}
Herkes yapabildiğini imzalar
public interface IPrinter { void Print(Document doc); }
public interface IScanner { void Scan(Document doc); }

public class BasicPrinter : IPrinter
{
    public void Print(Document doc) { /* yazdir */ }
}

public class MultiFunctionPrinter : IPrinter, IScanner
{
    public void Print(Document doc) { /* yazdir */ }
    public void Scan(Document doc)  { /* tara */ }
}

D — Dependency Inversion: detaya değil, kendi tanımladığın abstraction’a bağlan

İş mantığı doğrudan SqlServerOrderRepository’yi kendi içinde oluşturuyorsa, o class’ı test etmek için gerçek bir SQL Server gerekir. Veritabanı değişince de iş mantığına dokunursun. Prensibin söylediği şu: üst seviye modül (sipariş mantığı) alt seviye detaya (SQL Server) bağlı olmasın; ikisi de bir abstraction’a bağlansın ve o abstraction’ı üst seviye taraf kendi ihtiyacına göre tanımlasın. IOrderRepository bu yüzden veri erişim katmanının değil, sipariş mantığının interface’i. Bunu pratikte uygulamanın yolu da dependency’yi dışarıdan vermek: dependency injection. İkisi aynı şey değil; constructor’dan somut SqlServerOrderRepository almak “dışarıdan verir” ama bağımlılığı tersine çevirmez.

Dependency içeride doğuyor
public class OrderService
{
    private readonly SqlServerOrderRepository _repository = new();

    public void PlaceOrder(Order order) => _repository.Save(order);
}
Dependency abstraction üzerinden dışarıdan geliyor
public interface IOrderRepository
{
    void Save(Order order);
}

public class OrderService
{
    private readonly IOrderRepository _repository;

    public OrderService(IOrderRepository repository)
    {
        _repository = repository;
    }

    public void PlaceOrder(Order order) => _repository.Save(order);
}

Artık OrderService SQL Server, PostgreSQL ya da testteki mock repository ile aynı şekilde çalışır. Beş prensibin içinde en sık işe yarayanı bu; çünkü “test için bir mock” ihtiyacı neredeyse her projede gerçek.

SOLID bir amaç değil, araç. Araç, problemin olduğu yerde işe yarar; olmadığı yerde ağırlık yapar.

Ne zaman gerçekten işe yarar?

Prensipler belirli sinyaller için üretildi. Sinyal projende gerçekten varsa karşılığını fazlasıyla alırsın. Sinyalin ne olduğunu ve hangi prensibin karşılık geldiğini tabloya koydum:

SinyalHangi prensipNe kazanırsın
İş kuralı sık değişiyor (kampanya, vergi, fiyatlandırma)S, ODeğişikliğin etkisi tek class’la sınırlı kalır; regresyon alanı küçülür
Aynı yapının birden fazla implementasyonu var (2 ödeme provider’ı, e-posta + SMS + push)O, DYeni provider = yeni class; mevcut kod dokunulmadan kalır
Class’ı test etmek için gerçek DB / dış servis gerekiyorDMock’la test yazılabilir hâle gelir
Provider değişimi gerçekçi (kütüphane, bulut, storage)D, IGeçiş bir class’ın değişimine iner
Inheritance hiyerarşisinde “bu metot bende çalışmaz” varL, IAlt class’lar sürpriz üretmez; NotSupportedException kaybolur
Yıllarca yaşayacak, birden fazla ekip dokunacakHepsi — değişen ve çoğalan parçalardaKimse kimin kodunu neden değiştirdiğini sormak zorunda kalmaz. Ama bu sinyal “her parçayı soyutla” demez; uzun ömürlü projede de tek implementasyonlu parçalar olur

Ne zaman zarar verir?

Madalyonun bu yüzü daha az konuşuluyor. Prensipleri ihtiyaç olmadan uygulamak kodu daha iyi değil, daha uzun yapar. Sinyaller:

  • Tek implementasyon. Bir class’ın bir implementasyonu var ve yakın gelecekte ikincisi görünmüyor. Interface burada esneklik değil, bir dosya daha.
  • Küçük ya da kısa ömürlü proje. Birkaç ekranlık iç araç, iki haftalık prototip. Layered architecture, factory ve abstraction hiyerarşisi harcanan emeğe değmez.
  • “İleride lazım olabilir.” Bu cümleyle yazılan abstraction’ların çoğu hiç kullanılmaz; ama okunacak, anlaşılacak ve bakımı yapılacak kod olarak projede kalır.
  • Birkaç satırlık iş için beş layer. Basit bir okuma için Controller → Service → Manager → Repository → Mapper. Bir alan eklemek 5 dosyaya dokunmak demek.
  • Kontrol listesi olarak SOLID. Odak çözülen problemden “doğru görünen” koda kayar. PR’daki cevap tam olarak buydu: “SOLID’e uygun olsun diye.”
Abstraction hak eder
  • İkinci implementasyon bugün var ya da sprint planında
  • Class, mock olmadan test edilemiyor
  • Aynı pattern üçüncü kez karşına çıktı
  • Değişiklik her seferinde aynı if zincirini açıyor
Abstraction yük olur
  • Tek implementasyon, ikincisi “belki bir gün”
  • Interface’in tek implementasyonu var ve onu yalnızca DI kaydı biliyor
  • Factory’nin ürettiği tek bir tip var
  • Layer’lar parametreyi olduğu gibi bir alta geçiriyor

Sahadan: 30 satır ile 4 satır arasında

Girişteki PR. Özellik: kullanıcıya hoş geldin mesajı göstermek. “SOLID’e tam uyumlu” hâli şuydu:

2 interface, 2 class, 1 factory
public interface IGreetingMessageProvider
{
    string GetMessage(string userName);
}

public class DefaultGreetingMessageProvider : IGreetingMessageProvider
{
    public string GetMessage(string userName) => $"Hoş geldin, {userName}!";
}

public interface IGreetingService
{
    string Greet(string userName);
}

public class GreetingService : IGreetingService
{
    private readonly IGreetingMessageProvider _provider;

    public GreetingService(IGreetingMessageProvider provider)
    {
        _provider = provider;
    }

    public string Greet(string userName) => _provider.GetMessage(userName);
}

public class GreetingServiceFactory
{
    public IGreetingService Create() =>
        new GreetingService(new DefaultGreetingMessageProvider());
}

İhtiyaç ise şuydu:

İhtiyacın tamamı
public class GreetingService
{
    public string Greet(string userName) => $"Hoş geldin, {userName}!";
}

İlk sürümde iki interface, iki class ve bir factory var; hiçbirinin çözdüğü gerçek bir problem yok. Tek mesaj biçimi, tek implementasyon, değişme ihtimali düşük bir kural. Tabloya vurursan: sık değişen iş kuralı yok, ikinci implementasyon yok, test için mock gerekmiyor (string döndüren saf bir fonksiyon), provider değişimi diye bir şey yok, inheritance yok. Altı sinyalin beşi yok. Altıncısı — ürün yıllarca yaşayacak — doğru; ama o sinyal projenin değişen ve çoğalan parçaları için konuşur, tek satırlık bir mesaj için değil.

Konuşma beş dakika sürdü ve şu soruda bitti: “Yarın farklı dil ya da kullanıcı tipine göre mesaj gerekirse ne yaparız?” Cevap: o gün interface çıkarıp DI’a kaydederiz; çağrı yeri sayısına bağlı, küçük bir refactor. Bugün yazarsak bir yıl boyunca her okuyan beş dosyayı dolaşır. PR beş tipten bire, 30 satırdan 4 satıra indi.

Asıl düzeltme kodda değildi. Code review rehberindeki “her servisin bir interface’i olur” satırını sildim; yerine “ikinci implementasyon ya da test ihtiyacı varsa interface” yazdım. Bu kural implementasyon sayısına bakar; aşağıdaki rule of three ise tekrar eden koda. İki ayrı soru: “kaç gerçek implementasyon var?” ve “aynı kod kaç kez yazıldı?” Eski kural kısaydı, uygulaması kolaydı ve yanlıştı. Yanlış kural, kuralsızlıktan daha pahalı; çünkü herkes ona uyar.

“İleride lazım olur” diye yazılan abstraction çoğu zaman lazım olmaz; ama her gün okunur.

Dengeyi nasıl kurarsın?

  • YAGNI. You Aren’t Gonna Need It: bugün ihtiyaç duymadığın esnekliği bugün inşa etme. Esnekliğin de bakım maliyeti var; kullanılmayan esneklik saf maliyet.
  • Rule of three. İlk kez doğrudan yaz. İkincide tekrarı fark et. Üçüncüde abstraction çıkar. Böylece abstraction tahmine değil, gerçek pattern’e dayanır; interface’in şekli de üç somut örnekten çıktığı için doğru olma ihtimali artar.
  • KISS. Keep It Simple, Stupid: gereksiz karmaşıklık ekleme. Bir metodun ne yaptığını görmek için üç dosya gezmek gerekiyorsa karmaşıklık var demektir.
  • Refactoring’e güven. Testle desteklenen bir codebase’de basit başlayıp ihtiyaç doğduğunda yapıyı büyütmek, baştan her ihtimale göre tasarlamaktan ucuz. Instance bir metottan interface çıkarmak IDE’de tek komut.
  • Soruyu değiştir. “Bu kod SOLID’e uyuyor mu?” yerine “Bu yapı hangi problemi çözüyor ve o problem gerçekten var mı?”

Ne izlemeli?

Codebase’de aşırı abstraction’ın belirtileri
  • Tek implementasyonlu interface sayısı — IDE’de “implementations” sayısı 1 olan her interface bir soru işareti — test double’ı saymadan; gerekçe test ise interface hakkını verir (DIP bölümündeki IOrderRepository gibi).
  • Bir alan eklemek için dokunulan dosya sayısı — 5 ve üstü, layer’ların iş yapmadan parametre geçirdiğini gösterir.
  • Tek tip üreten factory’ler — factory seçim yapmıyorsa new yeterli.
  • NotSupportedException fırlatan implementasyonlar — interface yanlış kesilmiş (I) ya da inheritance yanlış (L).
  • Her yeni durumda büyüyen if/switch — üçüncü daldan sonra O için gerçek bir sinyal.
  • Gerçek DB ya da dış servis isteyen unit test — D’nin en somut ihtiyacı.

Kontrol listesi

Interface, layer ya da factory eklemeden önce
  • Bu abstraction’ın ikinci implementasyonu bugün var mı, ya da sprint planında mı?
  • Class’ı mock olmadan test edebiliyor muyum? (Evetse interface’in gerekçesi ne?)
  • Aynı pattern üçüncü kez mi karşıma çıktı, yoksa ilk mi?
  • Bu değişiklik sık değişen bir iş kuralına mı dokunuyor?
  • Eklediğim layer bir iş yapıyor mu, yoksa parametreyi olduğu gibi aşağı mı geçiriyor?
  • Factory bir seçim yapıyor mu?
  • Bir alan eklemek kaç dosyaya dokunuyor; 5 ve üstü mü?
  • Hiyerarşide NotSupportedException fırlatan metot var mı?
  • Abstraction’ı bugün değil ihtiyaç günü ekleseydim kaç dakika sürerdi?
  • Code review rehberimde “her X için Y” biçiminde koşulsuz bir kural var mı?

Sonuç

SOLID, değişen gereksinimler karşısında kodun bozulmasını önlemek için üretilmiş değerli bir araç seti. Değişimin yoğun olduğu, birden fazla implementasyonun bulunduğu, yıllarca yaşayacak projelerde vazgeçilmez. Küçük, basit ya da kısa ömürlü işlerde aynı prensipler gereksiz layer’a ve anlaşılması zor bir yapıya dönüşür.

O PR’daki 30 satır beş prensibin hiçbirini çiğnemiyordu. Problem kodda değil, kuraldaydı: prensibi araç olarak değil, amaç olarak yazmıştım. Beş tipi bire indirmek beş dakika sürdü; yanlış cümle rehberde bir yıl yaşadı.

İyi tasarımın ölçüsü prensiplere ne kadar uyulduğu değil; kodun ne kadar anlaşılır olduğu ve değişikliğin kaç dosyaya dokunduğu. Prensibi ezberleyip her yere uygulamak kolay; arkasındaki acıyı tanıyıp bu projede var mı diye sormak zor. Ustalık ikincisinde.