Mert Özen Her Satırda Daha İleri
Backend C# İleri Seviye — #13

C#'ta Dependency Injection Temelleri: Lifetime'lar, Container ve Captive Dependency

Mert Özen 7 Kas 2026 9 dk 5 görüntülenme
C#'ta Dependency Injection Temelleri: Lifetime'lar, Container ve Captive Dependency

Microsoft.Extensions.DependencyInjection ile constructor injection, singleton/scoped/transient farkı, container'ın dispose sorumluluğu, captive dependency ve ValidateScopes, çoklu registration, TryAdd ve factory'ler.

Sürüm notu: Bu yazıdaki kodlar C# 10 ve .NET 6 API'leriyle yazılmıştır; .NET 10 SDK ile derlenip denenmiştir.

Önce Tahmin Et

Üç boş class'ı üç farklı lifetime ile bir container'a kaydediyoruz, iki scope açıyoruz ve aynı servisi iki kez isteyip aynı instance'ı alıp almadığımıza bakıyoruz. Dört satırın hangileri True?

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddSingleton<Clock>();
services.AddScoped<Cart>();
services.AddTransient<PriceFormatter>();

using ServiceProvider provider = services.BuildServiceProvider();

using IServiceScope first = provider.CreateScope();
using IServiceScope second = provider.CreateScope();

Console.WriteLine(ReferenceEquals(
    first.ServiceProvider.GetRequiredService<Clock>(),
    second.ServiceProvider.GetRequiredService<Clock>()));

Console.WriteLine(ReferenceEquals(
    first.ServiceProvider.GetRequiredService<Cart>(),
    first.ServiceProvider.GetRequiredService<Cart>()));

Console.WriteLine(ReferenceEquals(
    first.ServiceProvider.GetRequiredService<Cart>(),
    second.ServiceProvider.GetRequiredService<Cart>()));

Console.WriteLine(ReferenceEquals(
    first.ServiceProvider.GetRequiredService<PriceFormatter>(),
    first.ServiceProvider.GetRequiredService<PriceFormatter>()));

public sealed class Clock { }
public sealed class Cart { }
public sealed class PriceFormatter { }

Üç Lifetime

Çıktı:

True
True
False
False

Singleton, container'ın ömrü boyunca tek bir instance demek: iki farklı scope da aynı Clock'u aldı. Scoped, her scope'a bir instance: aynı scope içinde iki kez istenen Cart aynı, farklı scope'lardaki farklı. Transient ise her istekte yeni bir instance: aynı scope'ta bile iki PriceFormatter farklı.

13-di-lifetimes-tr.png

Scope'u bir kitapçıdaki tek bir müşteri ziyareti gibi düşün. Kasadaki saat (singleton) herkes için aynı. Sepet (scoped) ziyaret boyunca seninle; bir sonraki müşteri yeni bir sepet alır. Fiyat etiketi yazıcısından çıkan etiket (transient) her seferinde yenidir. ASP.NET Core'da her HTTP isteği kendi scope'unu açar; scoped servisler bu yüzden "istek başına" demektir. EF Core'un DbContext'inin scoped kaydedilmesinin sebebi de bu.

Neden Dependency Injection

Lifetime'lar işin mekaniği; asıl soru neden bir container'a ihtiyaç duyduğumuz. Bir sipariş servisi düşün: fiyatları bir repository'den okuyor, indirimi bir policy'ye soruyor. Servis bu bağımlılıkları kendisi new ile oluşturursa, hangi repository'nin kullanılacağına da o karar verir: test ederken gerçek veritabanını kullanmak zorunda kalırsın, bir gün indirim kuralı değişince servisin kodunu açarsın. Dependency injection'da servis ihtiyaç duyduklarını constructor'da ister ve kim olduklarıyla ilgilenmez:

using System.Globalization;
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddSingleton<IBookRepository, InMemoryBookRepository>();
services.AddSingleton<IDiscountPolicy, MemberDiscountPolicy>();
services.AddScoped<OrderService>();

using ServiceProvider provider = services.BuildServiceProvider();
using IServiceScope scope = provider.CreateScope();

var orders = scope.ServiceProvider.GetRequiredService<OrderService>();
decimal total = orders.Total("978-0441013593", quantity: 3);
Console.WriteLine(total.ToString(CultureInfo.InvariantCulture));

public interface IBookRepository
{
    decimal GetPrice(string isbn);
}

public interface IDiscountPolicy
{
    decimal Apply(decimal amount);
}

public sealed class InMemoryBookRepository : IBookRepository
{
    private readonly Dictionary<string, decimal> _prices = new()
    {
        ["978-0441013593"] = 9.99m,
        ["978-0141439518"] = 7.50m,
    };

    public decimal GetPrice(string isbn) => _prices[isbn];
}

public sealed class MemberDiscountPolicy : IDiscountPolicy
{
    public decimal Apply(decimal amount) => Math.Round(amount * 0.90m, 2);
}

public sealed class OrderService
{
    private readonly IBookRepository _books;
    private readonly IDiscountPolicy _discounts;

    public OrderService(IBookRepository books, IDiscountPolicy discounts)
    {
        _books = books;
        _discounts = discounts;
    }

    public decimal Total(string isbn, int quantity) =>
        _discounts.Apply(_books.GetPrice(isbn) * quantity);
}
26.97

OrderService sadece IBookRepository ve IDiscountPolicy interface'lerini biliyor. Hangi implementasyonun verileceğine programın girişindeki registration satırları karar veriyor; bu noktaya composition root denir. Container OrderService'i istediğimizde constructor'ına bakıyor, parametrelerin registration'larını buluyor, onları oluşturup (onların da bağımlılıkları varsa önce onları) OrderService'e veriyor. 3 × 9.99 = 29.97, yüzde 10 indirimle 26.97.

Container olmadan da DI yapılır: new OrderService(new InMemoryBookRepository(), new MemberDiscountPolicy()) da constructor injection'dır. Container, bu zinciri onlarca servis için senin yerine kurar ve lifetime'ları yönetir. OOP serisinde konuştuğumuz SOLID'in "D"si (dependency inversion) burada somutlaşıyor: üst seviye kod, alt seviye detaylara değil abstraction'lara bağlı.

Container'ı Kurmak

.NET'in standart container'ı Microsoft.Extensions.DependencyInjection paketinde. ASP.NET Core ve Worker Service projelerinde zaten gelir; console uygulamasında paketi eklersin:

<ItemGroup>
  <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
</ItemGroup>

Akış her zaman aynı: bir ServiceCollection'a registration'ları ekle, BuildServiceProvider() ile provider'ı oluştur, servisleri bir scope'tan iste. GetRequiredService<T>() registration yoksa exception fırlatır; GetService<T>() ise null döner. Uygulama kodunda neredeyse her zaman ilkini istersin, çünkü eksik bir kaydı sessizce null ile geçmek hatayı ileriye taşır.

Registration'ların sırası, bir servisin bağımlılıklarından önce ya da sonra kaydedilmesi açısından önemli değil; container çözümlemeyi ilk istekte yapar. Sıranın önemli olduğu tek yer, aynı interface için birden fazla registration olduğunda; ona birazdan geleceğiz.

Dispose'u Container Yapar

Container'ın oluşturduğu ve IDisposable uygulayan servisleri, ömürleri bitince container dispose eder:

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddSingleton<AuditLog>();
services.AddScoped<UnitOfWork>();

using (ServiceProvider provider = services.BuildServiceProvider())
{
    provider.GetRequiredService<AuditLog>();

    using (IServiceScope scope = provider.CreateScope())
    {
        scope.ServiceProvider.GetRequiredService<UnitOfWork>();
        Console.WriteLine("request done");
    }

    Console.WriteLine("app shutting down");
}

public sealed class AuditLog : IDisposable
{
    public void Dispose() => Console.WriteLine("dispose AuditLog");
}

public sealed class UnitOfWork : IDisposable
{
    public void Dispose() => Console.WriteLine("dispose UnitOfWork");
}
request done
dispose UnitOfWork
app shutting down
dispose AuditLog

Scoped UnitOfWork, scope kapanır kapanmaz dispose edildi; singleton AuditLog ise provider dispose edilene, yani uygulama kapanana kadar yaşadı. OOP serisindeki "new diyen Dispose der" kuralının DI karşılığı bu: servisi container oluşturduysa, onu container temizler. Constructor'dan aldığın bir bağımlılığı sen dispose etmemelisin; başka servisler de onu kullanıyor olabilir.

Captive Dependency

En sinsi DI hatası, uzun ömürlü bir servisin kısa ömürlü bir servisi constructor'dan alması. Singleton bir StockCache, scoped bir UnitOfWork'e bağımlı olsun:

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddScoped<UnitOfWork>();
services.AddSingleton<StockCache>();

using ServiceProvider loose = services.BuildServiceProvider();
StockCache a, b;
using (IServiceScope s1 = loose.CreateScope())
    a = s1.ServiceProvider.GetRequiredService<StockCache>();
using (IServiceScope s2 = loose.CreateScope())
    b = s2.ServiceProvider.GetRequiredService<StockCache>();
Console.WriteLine($"same UnitOfWork in two requests: {ReferenceEquals(a.Work, b.Work)}");

try
{
    using ServiceProvider strict = services.BuildServiceProvider(new ServiceProviderOptions
    {
        ValidateScopes = true,
        ValidateOnBuild = true,
    });
}
catch (AggregateException ex)
{
    Console.WriteLine(ex.InnerExceptions[0].Message);
}

public sealed class UnitOfWork { }

public sealed class StockCache
{
    public StockCache(UnitOfWork work) => Work = work;

    public UnitOfWork Work { get; }
}
same UnitOfWork in two requests: True
Error while validating the service descriptor 'ServiceType: StockCache Lifetime: Singleton ImplementationType: StockCache': Cannot consume scoped service 'UnitOfWork' from singleton 'StockCache'.

Varsayılan ayarlarla container itiraz etmedi. StockCache ilk istekte oluşturuldu ve constructor'ında bir UnitOfWork aldı. İkinci istek de aynı singleton'ı, dolayısıyla aynı UnitOfWork'ü kullandı. Daha da kötüsü, singleton root scope'ta oluşturulduğu için onun bağımlılığı da root scope'tan çözüldü: "istek başına" olması gereken servis, uygulama kapanana kadar yaşayan ve bütün isteklerin paylaştığı tek bir object'e dönüştü. Scoped servis singleton'ın içinde "esir" kaldı. Gerçek hayatta bu, istekler arasında paylaşılan bir DbContext demektir: thread-safe olmayan bir object'e aynı anda birden fazla istekten erişim ve tuhaf, tekrarlanamayan hatalar.

ValidateScopes ve ValidateOnBuild bu hatayı provider oluşturulurken yakalar. ASP.NET Core, Development ortamında ikisini de varsayılan olarak açar; kendi console ya da worker uygulamanda bunları açman iyi bir alışkanlık. Kural: bir servis, kendisinden kısa ömürlü bir servise constructor'dan bağımlı olmamalı. Singleton'ın gerçekten scoped bir servise ihtiyacı varsa IServiceScopeFactory alıp her iş için kendi scope'unu açar.

Birden Fazla Implementasyon ve Factory

Aynı interface'i birden fazla kez kaydedebilirsin. Tek bir instance istendiğinde son registration kazanır; IEnumerable<T> istendiğinde hepsi registration sırasıyla gelir:

using System.Globalization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

var services = new ServiceCollection();
services.AddSingleton<IDiscountRule, MemberDiscount>();
services.AddSingleton<IDiscountRule, BulkDiscount>();
services.TryAddSingleton<IDiscountRule, NoDiscount>();
services.AddSingleton(sp => new Checkout(sp.GetServices<IDiscountRule>(), taxRate: 0.18m));

using ServiceProvider provider = services.BuildServiceProvider();

Console.WriteLine(provider.GetRequiredService<IDiscountRule>().GetType().Name);
Console.WriteLine(string.Join(", ", provider.GetServices<IDiscountRule>().Select(r => r.GetType().Name)));
Console.WriteLine(provider.GetRequiredService<Checkout>().Total(100m, quantity: 12).ToString(CultureInfo.InvariantCulture));

public interface IDiscountRule
{
    decimal Apply(decimal amount, int quantity);
}

public sealed class MemberDiscount : IDiscountRule
{
    public decimal Apply(decimal amount, int quantity) => amount * 0.95m;
}

public sealed class BulkDiscount : IDiscountRule
{
    public decimal Apply(decimal amount, int quantity) => quantity >= 10 ? amount - 50m : amount;
}

public sealed class NoDiscount : IDiscountRule
{
    public decimal Apply(decimal amount, int quantity) => amount;
}

public sealed class Checkout
{
    private readonly IEnumerable<IDiscountRule> _rules;
    private readonly decimal _taxRate;

    public Checkout(IEnumerable<IDiscountRule> rules, decimal taxRate)
    {
        _rules = rules;
        _taxRate = taxRate;
    }

    public decimal Total(decimal unitPrice, int quantity)
    {
        decimal amount = unitPrice * quantity;
        foreach (IDiscountRule rule in _rules)
            amount = rule.Apply(amount, quantity);
        return Math.Round(amount * (1 + _taxRate), 2);
    }
}
BulkDiscount
MemberDiscount, BulkDiscount
1286.20

GetRequiredService<IDiscountRule>() son eklenen BulkDiscount'u verdi. GetServices iki kuralı da sırayla getirdi; NoDiscount listede yok, çünkü TryAddSingleton o interface için registration varsa hiçbir şey yapmaz. Kütüphaneler varsayılan implementasyonlarını TryAdd ile kaydeder ki uygulama kendi implementasyonunu önce kaydederek onları override edebilsin.

Checkout'un constructor'ı container'ın bilmediği bir decimal istiyor. Bu durumda factory kaydı kullanırsın: lambda provider'ı alır, gerekenleri ondan çözer ve object'i kendisi oluşturur. Hesap: 12 × 100 = 1200, yüzde 5 üye indirimi 1140, toplu alım indirimi 1090, yüzde 18 vergiyle 1286.20.

.NET 8 ile gelen keyed services (aynı interface'in birden fazla implementasyonunu bir anahtarla seçmek) bu senaryoyu daha da kolaylaştırıyor; onu .NET Core Web API serisinin .NET 8 yazısında anlattık.

Sık Düşülen Tuzaklar

Eksik registration. Container bir constructor parametresini çözemezse hatayı ilk istekte verir ve mesaj neyin eksik olduğunu açıkça söyler:

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddScoped<OrderService>();

using ServiceProvider provider = services.BuildServiceProvider();
using IServiceScope scope = provider.CreateScope();

try
{
    scope.ServiceProvider.GetRequiredService<OrderService>();
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message);
}

public interface IBookRepository { }

public sealed class OrderService
{
    public OrderService(IBookRepository books) { }
}
Unable to resolve service for type 'IBookRepository' while attempting to activate 'OrderService'.

Mesaj uzun ama okunması kolay: hangi tip, hangi servisi oluştururken. ValidateOnBuild bu hatayı ilk istek yerine uygulama başlarken yakalar.

Scoped servisi root provider'dan istemek. Scope açmadan doğrudan provider'dan scoped bir servis istersen, o servis pratikte singleton gibi yaşar ve provider dispose edilene kadar temizlenmez. ValidateScopes açıkken bu da exception olur:

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddScoped<UnitOfWork>();

using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);

try
{
    provider.GetRequiredService<UnitOfWork>();
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message);
}

public sealed class UnitOfWork { }
Cannot resolve scoped service 'UnitOfWork' from root provider.

Service locator. Container'ın kendisini (IServiceProvider) her yere enjekte edip ihtiyaç duyulan yerde GetRequiredService çağırmak DI değil, service locator anti-pattern'idir. Bağımlılıklar constructor'da görünmez olur, eksik registration'lar runtime'ın derinliklerinde patlar ve class'ı test etmek için container kurman gerekir. Container'a dokunan kod composition root'ta ve birkaç altyapı noktasında (factory'ler, IServiceScopeFactory kullanan background işler) kalmalı.

Root'tan istenen disposable transient'lar. Container, oluşturduğu disposable transient'ları dispose edebilmek için takip eder. Scope dışında, root provider'dan sürekli disposable transient istersen, hepsi provider kapanana kadar bellekte birikir. Bu bir memory leak'tir ve bir worker'da saatler sonra fark edilir. Kısa ömürlü disposable'ları her zaman bir scope içinde çöz.

Küçük Bir Deneme

★☆☆ Açılıştaki örnekte Cart'ı singleton, Clock'u transient yap. Dört satırın çıktısını önce tahmin et, sonra çalıştır.

★★☆ StockCache'i, constructor'dan IServiceScopeFactory alacak ve her Refresh() çağrısında kendi scope'unu açıp UnitOfWork'ü oradan çözecek şekilde düzelt. ValidateOnBuild artık ne diyor? İki çağrıda aynı UnitOfWork mu geliyor?

★★★ IDiscountRule'lara bir Order property'si ekle ve Checkout kuralları bu sıraya göre uygulasın. Registration sırasını karıştırıp sonucun değişmediğini göster. Sonra bir kütüphane yazıyormuş gibi AddBookstoreDiscounts(this IServiceCollection services) extension method'u yaz ve varsayılan kuralı TryAdd ile kaydet.

Constructor injection'ın en büyük ödülü, bir class'ın bağımlılıklarını sahteleriyle değiştirebilmek. Serinin son yazısında bunu kullanıyoruz: xUnit ile unit test yazmak, Theory ve InlineData ile veri odaklı testler ve test edilebilir kod tasarlamak.