.NET'te Dependency Injection: Yaşam Döngüleri, Doğru Kullanım ve Sık Yapılan Hatalar
.NET'in yerleşik Dependency Injection altyapısını; Transient, Scoped ve Singleton yaşam döngülerini, captive dependency tuzağını, Options pattern'i ve keyed servisleri gerçek örneklerle anlatan rehber.
ASP.NET Core ile yazdığınız neredeyse her satır, farkında olsanız da olmasanız da Dependency Injection (DI) üzerine kuruludur. Controller'ınıza gelen DbContext, logger, yapılandırma ayarları; hepsi DI container'ı tarafından oluşturulup verilir. DI'ı yüzeysel bilmek uygulamayı çalıştırmaya yeter; ama yaşam döngülerini yanlış seçmek, sadece yük altında ortaya çıkan bellek sızıntılarına ve tutarsız verilere yol açabilir. Bu yazıda DI'ı temelden alıp production'da insanı uğraştıran tuzaklara kadar ele alıyoruz.
Dependency Injection nedir?
Bir sınıfın ihtiyaç duyduğu nesneleri (bağımlılıklarını) kendisinin oluşturması yerine dışarıdan alması prensibidir. Karşılaştıralım:
// Bağımlılığı kendisi oluşturuyor: test etmek ve değiştirmek zor
public class OrderService
{
private readonly SmtpEmailSender _email = new SmtpEmailSender("smtp.site.com");
}
// Bağımlılığı dışarıdan alıyor: gevşek bağlı ve test edilebilir
public class OrderService(IEmailSender email)
{
}
İkinci versiyonda OrderService, e-postanın nasıl gönderildiğini bilmez; sadece IEmailSender sözleşmesine güvenir. Testte sahte bir gönderici, production'da gerçek SMTP veya bir API istemcisi verilebilir. Bu, SOLID'in "D"si olan Dependency Inversion prensibinin pratikteki karşılığıdır.
Servis kaydı ve çözümleme
.NET'in yerleşik container'ı Program.cs içinde yapılandırılır:
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
builder.Services.AddSingleton<IClock, SystemClock>();
Container, OrderService istendiğinde constructor'ına bakar, IEmailSender gerektiğini görür, onu da oluşturur ve hepsini birbirine bağlar. Bu bağımlılık zinciri ne kadar derin olursa olsun, siz sadece en üstteki servisi istersiniz.
İyi kurulmuş bir DI yapısı, bileşenleri tak-çıkar parçalar gibi değiştirilebilir kılar.
Üç yaşam döngüsü
Doğru yaşam döngüsünü seçmek, DI'ın en kritik kararıdır.
| Yaşam döngüsü | Ne zaman oluşturulur? | Tipik kullanım |
|---|---|---|
| Transient | Her istendiğinde yeni örnek | Hafif, durumsuz servisler, formatlayıcılar |
| Scoped | Her HTTP isteği için bir örnek | DbContext, istek bazlı iş servisleri, unit of work |
| Singleton | Uygulama boyunca tek örnek | Cache, yapılandırma, HttpClient factory, thread-safe yardımcılar |
Transient
Her enjeksiyonda yeni nesne üretilir. Durum tutmayan, oluşturması ucuz servisler için uygundur. Ancak IDisposable uygulayan transient servisler, scope bitene kadar container tarafından tutulur; çok sık üretiliyorlarsa beklenmedik bellek kullanımına yol açabilir.
Scoped
Bir HTTP isteği boyunca aynı örnek kullanılır. DbContext'in scoped olmasının sebebi budur: bir istek içinde farklı servisler aynı context'i paylaşır, böylece tek bir SaveChanges ile tüm değişiklikler tutarlı şekilde kaydedilir.
Singleton
Uygulama ömrü boyunca tek örnek vardır ve tüm istekler tarafından eşzamanlı kullanılır. Bu yüzden singleton servisler mutlaka thread-safe olmalıdır. İçinde List<T> gibi korumasız, değişen bir durum tutmak yarış koşullarına (race condition) davetiye çıkarır; ConcurrentDictionary gibi eşzamanlılığa uygun yapılar kullanın.
En tehlikeli tuzak: captive dependency
Bir singleton servisin constructor'ına scoped bir servis enjekte ettiğinizi düşünün:
public class PriceCache(AppDbContext db) // PriceCache singleton, AppDbContext scoped!
{
}
Singleton bir kez oluşturulur ve ilk isteğin DbContext'ini sonsuza kadar yakalar. Sonuç: farklı istekler aynı context'i paylaşır, eski veriler görülür, eşzamanlı erişimde DbContext hata fırlatır ve context hiç dispose edilmediği için bellek şişer. Buna captive dependency denir.
ASP.NET Core, geliştirme ortamında ValidateScopes sayesinde bu hatayı uygulama başlarken yakalar. Production'da da yakalamak için:
builder.Host.UseDefaultServiceProvider(o =>
{
o.ValidateScopes = true;
o.ValidateOnBuild = true;
});
Doğru çözüm, singleton içinde scoped servise ihtiyaç olduğunda kısa ömürlü bir scope açmaktır:
public class PriceCache(IServiceScopeFactory scopes)
{
public async Task RefreshAsync()
{
using var scope = scopes.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// ... fiyatları oku
}
}
Aynı kalıp BackgroundService sınıflarında da geçerlidir: hosted service'ler singleton'dır, veritabanına erişmek için her çalışmada yeni bir scope açmalıdırlar.
Options pattern ile yapılandırma
IConfiguration'ı her servise enjekte edip config["Smtp:Host"] gibi string anahtarlarla okumak hataya açıktır. Bunun yerine yapılandırmayı tipli sınıflara bağlayın:
public class SmtpOptions
{
[Required] public string Host { get; set; } = "";
[Range(1, 65535)] public int Port { get; set; } = 587;
}
builder.Services.AddOptions<SmtpOptions>()
.BindConfiguration("Smtp")
.ValidateDataAnnotations()
.ValidateOnStart();
public class SmtpEmailSender(IOptions<SmtpOptions> options) : IEmailSender { }
ValidateOnStart() sayesinde eksik veya hatalı ayar, uygulama açılırken fark edilir; ilk e-posta gönderilmeye çalışıldığında değil.
Üç farklı arayüz vardır: IOptions<T> (singleton, değer sabit), IOptionsSnapshot<T> (scoped, her istekte yeniden okunur) ve IOptionsMonitor<T> (singleton, değişiklikleri anlık izler). Ayarların çalışma sırasında değişmesini bekliyorsanız Monitor veya Snapshot kullanın.
Tipli yapılandırma, yazım hatalarını derleme ve başlangıç anında yakalar.
Aynı arayüzün birden fazla uygulaması: keyed services
.NET 8 ile birlikte aynı arayüzün farklı uygulamalarını bir anahtarla kaydedip seçebilirsiniz. Örneğin farklı kargo firmaları:
builder.Services.AddKeyedScoped<IShippingProvider, ArasProvider>("aras");
builder.Services.AddKeyedScoped<IShippingProvider, YurticiProvider>("yurtici");
public class ShipmentService([FromKeyedServices("aras")] IShippingProvider provider) { }
Seçim çalışma anında yapılacaksa, IServiceProvider.GetRequiredKeyedService<T>(key) ile ya da tüm uygulamaları IEnumerable<IShippingProvider> olarak alıp bir özelliğe göre filtreleyerek ilerleyebilirsiniz.
HttpClient'ı doğru kullanmak
new HttpClient() ile her istekte istemci oluşturmak, yük altında soket tükenmesine yol açabilir. Doğrusu IHttpClientFactory ve tipli istemcilerdir:
builder.Services.AddHttpClient<IPaymentClient, PaymentClient>(c =>
{
c.BaseAddress = new Uri("https://api.odeme-saglayici.com/");
c.Timeout = TimeSpan.FromSeconds(10);
});
Factory, altta yatan bağlantıları havuzlar ve DNS değişikliklerini de doğru şekilde takip eder.
Service Locator'dan kaçının
Her yerde IServiceProvider enjekte edip GetService<T>() çağırmak (Service Locator), DI'ın tüm faydasını ortadan kaldırır: sınıfın gerçek bağımlılıkları constructor'dan görünmez olur ve test etmek zorlaşır. IServiceProvider kullanımını sadece altyapı kodlarıyla (factory'ler, scope açılan yerler) sınırlı tutun.
Constructor çok mu kalabalık?
Bir sınıfın constructor'ı yedi sekiz bağımlılık alıyorsa, sorun DI'da değil sınıfın tasarımındadır: muhtemelen birden fazla sorumluluk üstlenmiştir. Bu bir kod kokusudur ve sınıfı daha küçük, odaklı parçalara bölmenin zamanı geldiğini gösterir.
Kontrol listesi
- DbContext ve istek bazlı servisler Scoped olsun.
- Singleton servisler thread-safe olsun ve scoped servis yakalamasın.
- Arka plan servislerinde her iş için
CreateScope()kullanın. - Yapılandırmayı Options pattern ile tipli ve doğrulanmış şekilde alın.
HttpClientiçinIHttpClientFactorykullanın.ValidateScopesveValidateOnBuildayarlarını açın.
Sonuç
Dependency Injection, .NET uygulamalarının iskeletidir. Doğru yaşam döngüsünü seçmek, captive dependency'den kaçınmak ve yapılandırmayı tipli hale getirmek; uygulamanızı daha test edilebilir, daha öngörülebilir ve yük altında daha sağlam kılar. Bir sonraki kod incelemesinde (code review) sadece şu soruyu sorun: "Bu servisin yaşam döngüsü doğru mu?" Şaşırtıcı sayıda hata bu soruyla yakalanır.
Sık sorulan sorular
Autofac gibi bir container'a ihtiyacım var mı?
Çoğu proje için hayır. Yerleşik container; constructor injection, yaşam döngüleri, keyed servisler ve generic kayıtları destekler. Property injection veya gelişmiş dekoratör senaryolarına ihtiyaç duyarsanız üçüncü parti container'lar düşünülebilir.
Static sınıflar DI ile nasıl kullanılır?
Kullanılmaz. Durum tutan veya dış kaynağa erişen static sınıfları, bir arayüz arkasına alıp singleton olarak kaydetmek hem test edilebilirlik hem de esneklik sağlar.
Scoped servisi Transient servise enjekte edebilir miyim?
Evet, transient servis aynı istek scope'u içinde çözümlendiği sürece sorun yoktur. Sorun, kısa ömürlü bir servisin daha uzun ömürlü bir servis tarafından yakalanmasıdır.