Skip to content

Bölüm 7 / 15

SOLID: Dependency Inversion

Bağımlılığın tersine çevrilmesi ve DI ile ilişkisi.

12 dakikalık okuma

Önceki derste "arayüzü çağıran tanımlar" demiştik. DIP, o cümlenin mimari ölçekteki hâlidir: iş kuralların veritabanına, dosya sistemine ya da e-posta sağlayıcısına bağlı olmasın; onlar senin iş kurallarının koyduğu sözleşmeye bağlı olsun. Ders ayrıca en sık karıştırılan üç kavramı ayırıyor: DIP bir ilke, DI bir teknik, IoC konteyneri bir araçtır.

01Ne öğreneceksin

  • DIP'in iki maddesi ve "tersine çevrilen" şeyin tam olarak ne olduğu
  • new operatörünün gizli bağımlılık üretmesi ve nerede serbest olduğu
  • DIP · DI · IoC konteyneri ayrımı — konteynersiz DI (fakir adam DI'ı)
  • Kurucu enjeksiyonu, kompozisyon kökü ve Service Locator'ın neden anti-desen sayıldığı
  • Arayüzün hangi projeye ait olduğu — paket bağımlılık okunun yönü
  • Yaşam süreleri: singleton / scoped / transient ve esir bağımlılık (captive dependency) tuzağı
  • Sınır: her sınıfa arayüz açmanın maliyeti ve soyutlamanın sızması

02Tanım: ok yönünü çevirmek

İlke iki maddedir:

  1. Yüksek seviyeli modüller düşük seviyeli modüllere bağımlı olmamalıdır; her ikisi de soyutlamalara bağımlı olmalıdır.
  2. Soyutlamalar ayrıntılara bağımlı olmamalıdır; ayrıntılar soyutlamalara bağımlı olmalıdır.

"Yüksek seviye" = iş kuralların, uygulamanın var olma sebebi. "Düşük seviye" = teknik ayrıntılar: SQL Server, dosya sistemi, SMTP, kuyruk, HTTP çağrısı. Doğal akış şudur — sipariş servisi veritabanına yazar, yani ona bağımlıdır:

SiparisServisi  ──►  SqlSiparisDeposu  ──►  System.Data.SqlClient

Bu düzende iş kuralın, sağlayıcı kütüphanesinin ömrüne, sürümüne ve kurulmuş bir veritabanının varlığına bağlıdır. DIP oku çevirir:

SiparisServisi  ──►  ISiparisDeposu  ◄──  SqlSiparisDeposu

Tersine çevrilen şey, kaynak kod bağımlılığının yönüdür. Çalışma zamanında SiparisServisi yine gerçek veritabanına yazar — akış aynıdır. Değişen şudur: derleme zamanında artık iş kuralı ayrıntıyı tanımaz; ayrıntı iş kuralının koyduğu sözleşmeyi tanır. İkinci madde de bunun devamıdır: sözleşme SqlDataReader, DbConnection ya da DataTable gibi ayrıntı tipleri içeriyorsa, ok görünüşte çevrilmiştir ama bağımlılık aynen sürer.

03Problem: new ile gizlenen bağımlılık

public sealed class SiparisServisi
{
    public void Onayla(Siparis s)
    {
        var depo = new SqlSiparisDeposu("Server=.;Database=Sat;...");   // gizli bağımlılık
        var posta = new SmtpEpostaGonderici("smtp.sirket.local");       // gizli bağımlılık
        var saat = DateTime.Now;                                        // gizli bağımlılık

        s.Durum = Durum.Onayli;
        s.OnayZamani = saat;
        depo.Kaydet(s);
        posta.Gonder(s.MusteriEposta, "Siparişiniz onaylandı");
    }
}

Bu sınıfın imzasında hiçbir bağımlılık görünmez; kurucusu boştur, "bağımsız" görünür. Gerçekte üç sert bağı vardır ve sonuçları şunlardır:

  • Test edilemez. Tek bir birim testi yazmak için çalışan bir SQL Server ve bir SMTP sunucusu gerekir. DateTime.Now yüzünden "gece yarısını geçen sipariş" senaryosunu test etmek imkânsızdır.
  • Değiştirilemez. PostgreSQL'e geçmek ya da e-postayı kuyruğa almak, iş kuralı dosyasını açmayı gerektirir — OCP ihlali.
  • Yapılandırma sızmıştır. Bağlantı dizesi iş kuralının içindedir.
  • Yeniden kullanılamaz. Sınıfı başka bir bağlamda (toplu iş, farklı müşteri) kullanmak mümkün değildir.

new yasak değildir. Ayrım şudur: değer taşıyan, davranışı olmayan ve dışarıyla konuşmayan nesneleri (DTO, List<T>, StringBuilder, kendi record'ların, istisnalar) serbestçe new'lersin. Süreç sınırını aşan, durumu olan ya da davranışını değiştirmek isteyeceğin her şey — veritabanı, ağ, dosya, saat, rastgele sayı üreteci, kimlik üreteci — dışarıdan gelir. "Saat" ve "rastgele" ilk bakışta şaşırtır, ama testte belirlenebilir olmaları gerektiği için klasik enjeksiyon adaylarıdır; .NET 8 bunun için TimeProvider soyutlamasını standartlaştırmıştır.

04Çözüm: kurucu enjeksiyonu ve kompozisyon kökü

public interface ISiparisDeposu { void Kaydet(Siparis s); }
public interface IBildirici    { void Gonder(string alici, string mesaj); }

public sealed class SiparisServisi
{
    private readonly ISiparisDeposu _depo;
    private readonly IBildirici _bildirici;
    private readonly TimeProvider _saat;

    public SiparisServisi(ISiparisDeposu depo, IBildirici bildirici, TimeProvider saat)
    {
        _depo = depo ?? throw new ArgumentNullException(nameof(depo));
        _bildirici = bildirici ?? throw new ArgumentNullException(nameof(bildirici));
        _saat = saat ?? throw new ArgumentNullException(nameof(saat));
    }

    public void Onayla(Siparis s)
    {
        s.Durum = Durum.Onayli;
        s.OnayZamani = _saat.GetUtcNow();
        _depo.Kaydet(s);
        _bildirici.Gonder(s.MusteriEposta, "Siparişiniz onaylandı");
    }
}

Üç ayrıntı kasıtlıdır ve üretim kodunda beklenir: alanlar readonly (nesne kurulduktan sonra bağımlılık değişmez), kurucuda null denetimi (hata, kullanım anında değil kurulum anında patlar), ve kurucuda başka hiçbir iş yapılmaması — kurucu yalnız atama yapar; veritabanına gitmez, dosya okumaz.

Sınıfın kurucusu artık bir bağımlılık listesidir: ne kullandığını okumak için gövdeye bakmaya gerek yoktur. Bu aynı zamanda ücretsiz bir SRP ölçer verir: kurucusu altı-yedi bağımlılık isteyen sınıf, büyük ihtimalle birden fazla iş yapıyordur.

Peki gerçek nesneleri kim oluşturur? Kompozisyon kökü (composition root): uygulamanın en dış katmanında, başlangıçta bir kez çalışan ve tüm bağlantıların kurulduğu tek yer. Bir konsol uygulamasında elle de yapılabilir — buna "fakir adam DI'ı" denir ve konteyner bir zorunluluk değildir:

// Program.cs — kompozisyon kökü, konteynersiz
var saat = TimeProvider.System;
var depo = new SqlSiparisDeposu(baglantiDizesi);
var bildirici = new SmtpEpostaGonderici(smtpAyarlari);
var servis = new SiparisServisi(depo, bildirici, saat);
servis.Onayla(siparis);

Uygulama büyüdükçe bu elle kurulum yorucu olur; işte konteyner tam olarak bunu otomatikleştirmek içindir:

// Program.cs — kompozisyon kökü, .NET yerleşik konteyneriyle
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<ISiparisDeposu, SqlSiparisDeposu>();
builder.Services.AddScoped<IBildirici, SmtpEpostaGonderici>();
builder.Services.AddScoped<SiparisServisi>();

Konteyner, SiparisServisi istendiğinde kurucusuna bakar, ihtiyaç duyduğu tipleri kayıt listesinden bulur, onların da kurucularına bakar ve zinciri kendisi kurar. Yaptığı iş budur — sihir değil, kurucu imzasına bakıp ağacı kurmak. 5. hatta bunun ayrıntısına, 3. hattaki extension dersinde gördüğün services.AddSatisModulu() paketleme kalıbıyla birlikte gireceksin.

05DIP · DI · IoC konteyneri

Mülakatta en sık karıştırılan üçlüdür; ayrımı net kur:

  • DIP (ilke). Bağımlılık okunun yönüyle ilgili bir tasarım kararı: iş kuralı ayrıntıya değil, soyutlamaya baksın. Konteyner olmadan da uygulanır.
  • DI — bağımlılık enjeksiyonu (teknik). Bir nesnenin ihtiyaçlarını kendi üretmek yerine dışarıdan alması. Üç biçimi vardır: kurucu (varsayılan tercih; zorunlu bağımlılıklar için), özellik/setter (gerçekten isteğe bağlı bağımlılıklar için; nesneyi yarı kurulmuş bırakabildiği için nadir), metot parametresi (yalnız o çağrıda gereken şey için — en hafifi).
  • IoC konteyneri (araç). DI'ı otomatikleştiren kütüphane (Microsoft.Extensions.DependencyInjection, Autofac…). Yalnızca kolaylıktır; ilkeyi o sağlamaz.

Kritik nokta: konteyner kullanmak DIP uyguladığın anlamına gelmez. services.AddScoped<SqlSiparisDeposu>() yazıp servisin kurucusunda somut SqlSiparisDeposu istersen, DI kullanıyorsundur ama bağımlılık oku hâlâ ayrıntıya bakmaktadır. Tersi de doğrudur: yukarıdaki elle kurulmuş Program.cs'te konteyner yoktur ama DIP tam olarak uygulanmıştır.

"Inversion of Control" daha geniş bir şemsiye terimdir: kontrolün senden çerçeveye geçmesi. Olay tabanlı programlama, şablon metodu ve ASP.NET Core'un senin denetleyicini çağırması de birer IoC örneğidir — "Hollywood ilkesi": bizi arama, biz seni ararız.

Service Locator neden anti-desendir? Bağımlılığı kurucudan almak yerine sınıfın içinden konteynerden istemek:

public sealed class SiparisServisi
{
    public void Onayla(Siparis s)
    {
        var depo = ServisSaglayici.Al<ISiparisDeposu>();   // gizli bağımlılık geri geldi
        ...
    }
}

Bağımlılık yine imzada görünmez; sınıf artık konteynerin kendisine bağımlıdır; eksik kayıt hatası derlemede değil, o kod yolu çalıştığında ortaya çıkar. Kural: konteyner yalnız kompozisyon kökünde görünür, iş kodunda hiç görünmez. IServiceProvider'ın iş sınıfına enjekte edildiğini görüyorsan aynı sorunun kılık değiştirmiş hâlidir.

06Arayüz hangi projeye ait?

DIP'in çoğu anlatımda atlanan, kıdem sinyali veren kısmı budur. Arayüzü tanımlamak yetmez; onu doğru projeye koymak gerekir. Yanlış yerleşim:

Uygulama.Alan      ──►  Uygulama.Altyapi   (ISiparisDeposu burada)
                          ok hâlâ ayrıntıya bakıyor: DIP uygulanmadı

Doğru yerleşim — arayüz, onu kullanan katmanda yaşar:

Uygulama.Alan        ISiparisDeposu  (arayüz burada, iş kurallarıyla birlikte)
       ▲
       │  proje referansı
Uygulama.Altyapi     SqlSiparisDeposu : ISiparisDeposu

Artık Uygulama.Alan projesinin hiçbir altyapı referansı yoktur: ne Entity Framework, ne SQL istemcisi, ne HTTP. Derleyici bunu zorlar — birisi iş kuralı içinde veritabanı çağırmak isterse derleyemez. Bu düzen 13. dersteki katmanlı mimari ve Clean Architecture anlatımının temelidir; oradaki "bağımlılık kuralı" cümlesi tam olarak bu okun yönünü söyler.

İki pratik sonuç: (1) Arayüz ismi çağıranın dilinde olur — ISiparisDeposu iyi, ISqlSiparisDeposu kötü; isimde ayrıntı geçiyorsa soyutlama sızmıştır. (2) Arayüzün imzasında ayrıntı tipi bulunmaz. Task<DataTable> Getir() ya da IQueryable<Siparis> Sorgula() döndüren bir "soyutlama", uygulamayı sessizce EF Core'a bağlar — çünkü çağıran tarafta yazılan sorgunun hangi sağlayıcıda çalışacağı sözleşmenin parçası hâline gelir. 7. hattaki depo desenlerinde bu tuzağa tekrar döneceğiz.

07Yaşam süreleri ve sınırlar

Bağımlılıkları dışarıdan alan bir sistemde yeni bir soru doğar: o nesne ne kadar yaşayacak? .NET'in üç yaşam süresi vardır:

  • Singleton — uygulama boyunca tek örnek. Durumsuz servisler, önbellek, TimeProvider, yapılandırma. İş parçacığı güvenli olmak zorundadır (2. hattaki eşzamanlılık dersi).
  • Scoped — her HTTP isteğinde bir örnek. Veritabanı bağlamı ( DbContext), istek başına birim iş. Web'in varsayılanıdır.
  • Transient — her istendiğinde yeni örnek. Hafif, durumsuz yardımcılar. IDisposable bir tipi transient kaydetmek dikkat ister: konteyner onu kapsam bitene kadar tutar ve bırakır — çok sayıda üretilirse bellek şişer (3. hattaki IDisposable dersindeki "yaratan atar" kuralının konteyner tarafındaki karşılığı).

En sık yapılan hata esir bağımlılıktır (captive dependency): singleton bir servisin kurucusuna scoped bir bağımlılık almak. Singleton bir kez kurulduğu için o scoped nesne uygulama boyunca hapsolur; DbContext ise iş parçacığı güvenli değildir — sonuç, yük altında ortaya çıkan ve tekrarı zor hatalardır. .NET'in geliştirme ortamındaki kapsam doğrulaması bunu başlangıçta yakalar; üretimde yakalamaz. Gerçekten gerekiyorsa IServiceScopeFactory ile iş anında kapsam açılır.

Sınır — her sınıfa arayüz açma. DIP "her şeye arayüz" demez; kararsız olana arayüz demektir. Ayrıntı barındıran (veritabanı, ağ, dosya, saat), üçüncü parti olan ya da testte yerine geçmesi gereken bağımlılıklar soyutlanır. Saf hesap yapan bir KdvHesaplayici için IKdvHesaplayici açmak, 2. dersteki YAGNI ihlalidir: dosya sayısını ikiye katlar, "tanıma git" akışını kırar ve karşılığında hiçbir esneklik vermez. Aynı şekilde, bir arayüzün tek uygulaması varsa ve ikincisi hiç beklenmiyorsa — testte sahtelenmesi de gerekmiyorsa — somut sınıfı doğrudan enjekte etmek geçerli bir karardır.

Sonraki ders hattın ikinci yarısını açıyor: tasarım deseni nedir. Bu yedi derste öğrendiğin ilkeler "neyi hedefle" diyordu; desenler ise o hedefe giden, adlandırılmış ve tekrar tekrar sınanmış çözümlerdir. DIP'in kendisi de orada göreceğin birçok desenin (Strategy, Repository, Adapter) ortak zeminidir.

08Mini alıştırma — Oku çevir

Yaklaşık 20 dakika · dotnet run

  1. İhlali yaz: SiparisServisi.Onayla metodunu içinde new SahteDepo(), new SahtePosta() ve DateTime.Now ile yaz. Sonra şu testi yazmayı dene: "23:50'de onaylanan sipariş ertesi güne kaydolmamalı". Neden yazamıyorsun?
  2. Çevir: ISiparisDeposu, IBildirici arayüzlerini çıkar ve üçünü de kurucudan al (saat için TimeProvider). Alanları readonly yap, kurucuda null denetimi ekle.
  3. Testi şimdi yaz: sahte depo (kaydedileni listeye koyan), sahte bildirici (mesajı listeye koyan) ve sabit saat veren bir TimeProvider ile Onayla'yı çağır. Veritabanı olmadan doğrulayabildiğini gör.
  4. Kompozisyon kökü: Program.cs'te üç nesneyi elle kurup servise ver (konteynersiz DI). Sonra aynı kurulumu ServiceCollection ile yap ve provider.GetRequiredService<SiparisServisi>() ile al. Aynı sonucu aldığını doğrula.
  5. Anti-deseni gör: bağımlılığı kurucudan almak yerine metot içinde provider.GetRequiredService<ISiparisDeposu>() ile iste. Sonra o kaydı kaldır: hata hangi anda ortaya çıkıyor — derlemede mi, uygulama açılışında mı, yoksa o kod yolu çalıştığında mı?
  6. Esir bağımlılık: ISiparisDeposu'nu Scoped, SiparisServisi'ni Singleton kaydet. ServiceProviderOptions içinde ValidateScopes = true ile sağlayıcı oluştur ve hatayı oku.
  7. Sızan soyutlama (isteğe bağlı): arayüze IQueryable<Siparis> Sorgula() ekle. Bellek içi sahte uygulama ile SQL uygulaması arasında hangi davranış farkları doğabilir? Bir cümleyle yaz.

09Kendini sına

DIP nedir, "tersine çevrilen" tam olarak nedir?

İki maddedir: yüksek seviyeli modüller düşük seviyeli modüllere değil, her ikisi de soyutlamalara bağımlı olmalı; ve soyutlamalar ayrıntılara değil, ayrıntılar soyutlamalara bağımlı olmalı. Tersine çevrilen şey kaynak kod bağımlılığının yönüdür — çalışma zamanı akışı değil. Sipariş servisi yine gerçek veritabanına yazar; değişen şey, derleme zamanında iş kuralının ayrıntıyı tanımaması, ayrıntının iş kuralının koyduğu sözleşmeyi tanımasıdır. İkinci madde de kritiktir: sözleşme DataTable, DbConnection ya da IQueryable gibi ayrıntı tipleri içeriyorsa ok görünüşte çevrilmiştir ama bağımlılık sürer. Aynı şekilde arayüz ismi de çağıranın dilinde olmalıdır — ISqlSiparisDeposu adı soyutlamanın sızdığını gösterir.

DIP, DI ve IoC konteyneri arasındaki fark nedir?

DIP bir ilkedir: bağımlılık okunun yönüyle ilgili tasarım kararı. DI bir tekniktir: nesnenin ihtiyaçlarını kendi üretmek yerine dışarıdan alması — kurucu (varsayılan tercih), özellik (gerçekten isteğe bağlı bağımlılıklar) ya da metot parametresi yoluyla. IoC konteyneri ise DI'ı otomatikleştiren bir araçtır; kurucu imzasına bakıp nesne ağacını kurar. Üçü bağımsızdır: konteyner kullanıp somut tip enjekte edersen DI vardır ama DIP yoktur; nesneleri Program.cs'te elle kurarsan konteyner yoktur ama DIP tam olarak uygulanmıştır (fakir adam DI'ı). IoC daha geniş bir şemsiyedir — kontrolün çerçeveye geçmesi; olay tabanlı programlama ve şablon metodu da IoC örnekleridir.

Service Locator neden anti-desen sayılır?

Bağımlılığı kurucudan almak yerine sınıfın içinden konteynerden istemek, DIP'in çözdüğü sorunu geri getirir: bağımlılık yine imzada görünmez, sınıfın gerçekte neye ihtiyaç duyduğunu anlamak için gövdeyi okumak gerekir. Sınıf ayrıca konteynerin kendisine bağımlı hâle gelir, yani çerçeveden bağımsız test edilemez. En kötüsü, eksik kayıt hatasının uygulama açılışında değil, o kod yolu ilk kez çalıştığında ortaya çıkmasıdır. Kural: konteyner yalnız kompozisyon kökünde görünür, iş kodunda hiç görünmez. IServiceProvider'ın bir iş sınıfına enjekte edilmesi de aynı sorunun kılık değiştirmiş hâlidir.

Esir bağımlılık (captive dependency) nedir, nasıl önlenir?

Uzun ömürlü bir servisin, kendisinden kısa ömürlü bir bağımlılığı kurucudan alması — tipik olarak singleton bir servisin scoped bir DbContext alması. Singleton bir kez kurulduğu için o scoped nesne uygulama boyunca hapsolur: bağlantı kapanmaz, izleme (change tracking) verisi birikir ve DbContext iş parçacığı güvenli olmadığından yük altında tekrarı zor hatalar çıkar. Önlem, yaşam sürelerini dıştan içe doğru tutarlı seçmektir; .NET'in geliştirme ortamındaki kapsam doğrulaması (ValidateScopes) bunu başlangıçta yakalar ama üretimde varsayılan olarak yakalamaz. Singleton bir servis gerçekten kısa ömürlü bir bağımlılığa ihtiyaç duyuyorsa, IServiceScopeFactory ile iş anında kapsam açılır ve iş bitince bırakılır.

10Sınav köşesi

Yazılıda nasıl sorulur

(1) Tanım: DIP'in iki maddesini yazma — en sık çıkan SOLID sorusudur. (2) Ayırt etme: "DIP ile DI aynı şey midir?" — ilke/teknik/araç ayrımı beklenir. (3) Kod okuma: içinde new SqlBaglanti(...) geçen bir servis verilip hangi ilkelerin ihlal edildiği ve nasıl düzeltileceği. (4) Diyagram: bağımlılık okunun yönünü çizmek; arayüzün hangi katmana konacağı. (5) .NET bilgisi: AddSingleton / AddScoped / AddTransient farkları ve hangi tipin hangisiyle kaydedileceği; DbContext neden scoped. (6) Anti-desen: Service Locator'ın neden sakıncalı olduğu. (7) Doğru/yanlış: "IoC konteyneri kullanan her uygulama DIP uygular" (yanlış); "DIP çalışma zamanı akışını tersine çevirir" (yanlış — kaynak kod bağımlılığını çevirir). (8) Uygulama: verilen sınıfı test edilebilir hâle getirecek kurucu imzasını yazma.

Sözlüde nasıl anlatılır — 2 dakikalık taslak

Düzen: (1) Tanım — "iş kuralım ayrıntıya bağlı olmasın; ikisi de soyutlamaya baksın. Tersine çevirdiğim şey kaynak kod bağımlılığının yönü, çalışma zamanı akışı değil." (2) Somut örnek — "sipariş servisi içinde new SqlSiparisDeposu varsa o sınıfı test etmek için veritabanı kurmam gerekir; arayüzü çıkarıp kurucudan alınca sahte uygulamayla test ederim." (3) Kavram ayrımı — "DIP ilke, DI teknik, konteyner araçtır; konteynersiz de DIP uygulanır, konteyner kullanıp somut tip enjekte edersem DIP uygulanmamış olur." (4) Yerleşim — "arayüzü altyapı projesine değil, onu kullanan iş katmanına koyarım; böylece iş katmanının hiçbir altyapı referansı olmaz ve bunu derleyici zorlar." (5) Kıdem vuruşu — "iki tuzağı da söylerim: konteyneri iş kodunda kullanmak Service Locator anti-desenidir; ve her sınıfa arayüz açmak DIP değil aşırı mühendisliktir — soyutlamayı kararsız olana, yani ayrıntı barındıran veya testte yerine geçmesi gereken bağımlılığa uygularım. Yaşam sürelerinde de esir bağımlılığa dikkat ederim: singleton bir servise scoped DbContext almam."