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
newoperatö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:
- Yüksek seviyeli modüller düşük seviyeli modüllere bağımlı olmamalıdır; her ikisi de soyutlamalara bağımlı olmalıdır.
- 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.Nowyü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.
IDisposablebir tipi transient kaydetmek dikkat ister: konteyner onu kapsam bitene kadar tutar ve bırakır — çok sayıda üretilirse bellek şişer (3. hattakiIDisposabledersindeki "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
- İhlali yaz:
SiparisServisi.Onaylametodunu içindenew SahteDepo(),new SahtePosta()veDateTime.Nowile yaz. Sonra şu testi yazmayı dene: "23:50'de onaylanan sipariş ertesi güne kaydolmamalı". Neden yazamıyorsun? - Çevir:
ISiparisDeposu,IBildiriciarayüzlerini çıkar ve üçünü de kurucudan al (saat içinTimeProvider). Alanlarıreadonlyyap, kurucudanulldenetimi ekle. - Testi şimdi yaz: sahte depo (kaydedileni listeye koyan), sahte bildirici
(mesajı listeye koyan) ve sabit saat veren bir
TimeProviderileOnayla'yı çağır. Veritabanı olmadan doğrulayabildiğini gör. - Kompozisyon kökü:
Program.cs'te üç nesneyi elle kurup servise ver (konteynersiz DI). Sonra aynı kurulumuServiceCollectionile yap veprovider.GetRequiredService<SiparisServisi>()ile al. Aynı sonucu aldığını doğrula. - 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ı? - Esir bağımlılık:
ISiparisDeposu'nuScoped,SiparisServisi'niSingletonkaydet.ServiceProviderOptionsiçindeValidateScopes = trueile sağlayıcı oluştur ve hatayı oku. - 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."
