Mert Özen Her Satırda Daha İleri
Backend .NET Core Web API — #11

ASP.NET Core'da Dependency Injection: Lifetime'lar ve Captive Dependency Tuzağı

Mert Özen 4 Kas 2026 10 dk 2 görüntülenme
ASP.NET Core'da Dependency Injection: Lifetime'lar ve Captive Dependency Tuzağı

ASP.NET Core'da dependency injection'ı gerçek çıktılarla inceliyoruz: constructor injection, singleton/scoped/transient lifetime'ları, captive dependency, scope validation, IServiceProvider ve .NET 6'da [FromServices] detayı.

Sürüm notu: Bu yazıdaki kodlar .NET 6 ile yazılmıştır ve .NET 10'da da değişmeden çalışır.

Önce Tahmin Et

Minimal API yazısında handler'a bir parametre eklediğinde o parametrenin bazen route'tan, bazen body'den, bazen de sen hiçbir şey yapmadan "bir yerlerden" geldiğini gördük. O "bir yer" bu yazının konusu: dependency injection container'ı. Önce küçük bir soru. Program.cs'e şu üç kaydı yapıyorsun:

builder.Services.AddSingleton<SingletonOperation>();
builder.Services.AddScoped<ScopedOperation>();
builder.Services.AddTransient<TransientOperation>();

Her biri oluşturulduğu anda kendine kısa bir ID üretiyor. Bir endpoint bu üç ID'yi iki yerden okuyor: controller'ın kendi constructor'ından ve controller'a enjekte edilen başka bir service'in içinden. Aynı endpoint'e art arda iki request atarsan hangi ID iki request boyunca aynı kalır, hangisi aynı request içinde bile iki farklı değer gösterir? Cevabı birazdan gerçek çıktıyla göreceğiz.

new Anahtar Kelimesinin Gizli Bedeli

Controller yazısında ürün listesini controller'ın içinde static bir alan olarak tutmuştuk. Listeyi InMemoryProductStore adında ayrı bir class'a taşıdığını ve controller'ın bu class'ı kendisinin oluşturduğunu düşün:

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly InMemoryProductStore _store = new();

    [HttpGet("{id:int}")]
    public ActionResult<Product> GetById(int id)
    {
        Product? product = _store.GetById(id);
        if (product is null)
        {
            return NotFound();
        }

        return product;
    }

    [HttpPost]
    public ActionResult<Product> Create(Product product)
    {
        Product created = _store.Add(product);
        return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
    }
}

Derlenir ve ilk bakışta çalışır. Ama POST ile bir ürün ekleyip hemen ardından onu istediğinde şunu alırsın:

HTTP/1.1 201 Created
Location: http://localhost:5080/api/Products/3

{"id":3,"name":"USB-C Hub","price":890}

HTTP/1.1 404 Not Found

ASP.NET Core her request için yeni bir controller instance'ı oluşturur. Static alan gidince new() da her request'te yepyeni bir store, yani yepyeni bir liste demek oldu. Asıl sorun ise daha derinde: controller belirli bir class'a perçinlendi. Yarın veriyi veritabanından okumak istediğinde controller'ı da değiştirmen gerekecek, test yazarken de yerine sahte bir store koyamayacaksın.

Inversion of Control (IoC) bu ilişkiyi tersine çevirir: class ihtiyacı olan şeyi kendisi üretmez, dışarıdan ister. Dependency injection bunu yapmanın en yaygın yolu. Bir restoran mutfağı düşün: aşçı domatesi tarladan kendisi toplamaz, tezgâhına ne gerektiğini söyler, tedarikçi getirir. ASP.NET Core'da tedarikçinin adı DI container. builder.Services üzerinden yaptığın her kayıt ona "biri bunu isterse şunu ver" demektir.

Constructor Injection ile Store'u Dışarı Almak

İlk adım, controller'ın neye ihtiyaç duyduğunu bir interface ile tarif etmek:

using ProductCatalog.Models;

namespace ProductCatalog.Services;

public interface IProductStore
{
    IReadOnlyList<Product> GetAll();
    Product? GetById(int id);
    Product Add(Product product);
}
using ProductCatalog.Models;

namespace ProductCatalog.Services;

public class InMemoryProductStore : IProductStore
{
    private readonly List<Product> _products = new()
    {
        new Product { Id = 1, Name = "Mechanical Keyboard", Price = 1450m },
        new Product { Id = 2, Name = "Wireless Mouse", Price = 520m }
    };
    private readonly object _lock = new();

    public IReadOnlyList<Product> GetAll()
    {
        lock (_lock)
        {
            // Dışarıya listenin kopyasını veriyoruz
            return _products.ToList();
        }
    }

    public Product? GetById(int id)
    {
        lock (_lock)
        {
            return _products.FirstOrDefault(p => p.Id == id);
        }
    }

    public Product Add(Product product)
    {
        lock (_lock)
        {
            product.Id = _products.Count == 0 ? 1 : _products.Max(p => p.Id) + 1;
            _products.Add(product);
            return product;
        }
    }
}

Bu store bütün uygulamada tek bir instance olarak paylaşılacak. Aynı anda gelen iki request aynı List<Product>'ı bozmasın diye erişimi lock ile koruduk. Controller ise artık yalnızca sözleşmeyi biliyor:

using Microsoft.AspNetCore.Mvc;
using ProductCatalog.Models;
using ProductCatalog.Services;

namespace ProductCatalog.Controllers;

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductStore _store;

    public ProductsController(IProductStore store)
    {
        _store = store;
    }

    [HttpGet]
    public ActionResult<IEnumerable<Product>> GetAll()
    {
        return Ok(_store.GetAll());
    }

    [HttpGet("{id:int}")]
    public ActionResult<Product> GetById(int id)
    {
        Product? product = _store.GetById(id);
        if (product is null)
        {
            return NotFound();
        }

        return product;
    }

    [HttpPost]
    public ActionResult<Product> Create(Product product)
    {
        Product created = _store.Add(product);
        return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
    }
}

Constructor parametreleri controller'ın bağımlılık listesidir. Eşleştirmeyi Program.cs'te yaparsın:

builder.Services.AddControllers();
builder.Services.AddSingleton<IProductStore, InMemoryProductStore>();

Bu satır container'a "biri IProductStore isterse InMemoryProductStore ver ve uygulama boyunca hep aynısını ver" der. Request geldiğinde container controller'ı oluştururken constructor'a bakar, parametreleri tek tek çözer ve instance'ı hazır hâlde teslim eder. Aynı POST ve GET ikilisini tekrar dene; bu kez GET 200 ve {"id":3,"name":"USB-C Hub","price":890} döner, çünkü herkes aynı store ile konuşuyor.

Üç Lifetime: Singleton, Scoped, Transient

"Hep aynısını ver" kısmı kaydın lifetime'ı. Üç seçenek var ve farkı görmenin en iyi yolu ölçmek. Tahmin sorusundaki class'lar şunlar:

namespace ProductCatalog.Services;

public abstract class Operation
{
    // Instance oluştuğu anda kısa bir ID üretir
    public string Id { get; } = Guid.NewGuid().ToString()[..8];
}

public class SingletonOperation : Operation { }
public class ScopedOperation : Operation { }
public class TransientOperation : Operation { }

public class OperationReporter
{
    private readonly SingletonOperation _singleton;
    private readonly ScopedOperation _scoped;
    private readonly TransientOperation _transient;

    public OperationReporter(SingletonOperation singleton, ScopedOperation scoped, TransientOperation transient)
    {
        _singleton = singleton;
        _scoped = scoped;
        _transient = transient;
    }

    public object Report() => new { singleton = _singleton.Id, scoped = _scoped.Id, transient = _transient.Id };
}
using Microsoft.AspNetCore.Mvc;
using ProductCatalog.Services;

namespace ProductCatalog.Controllers;

[ApiController]
[Route("api/[controller]")]
public class LifetimesController : ControllerBase
{
    private readonly SingletonOperation _singleton;
    private readonly ScopedOperation _scoped;
    private readonly TransientOperation _transient;
    private readonly OperationReporter _reporter;

    public LifetimesController(
        SingletonOperation singleton,
        ScopedOperation scoped,
        TransientOperation transient,
        OperationReporter reporter)
    {
        _singleton = singleton;
        _scoped = scoped;
        _transient = transient;
        _reporter = reporter;
    }

    [HttpGet]
    public IActionResult Get()
    {
        return Ok(new
        {
            controller = new { singleton = _singleton.Id, scoped = _scoped.Id, transient = _transient.Id },
            reporter = _reporter.Report()
        });
    }
}

Program.cs'teki üç kayda builder.Services.AddTransient<OperationReporter>(); satırını da ekle ve iki request at:

curl http://localhost:5080/api/lifetimes
{"controller":{"singleton":"6c6364e0","scoped":"60aeb626","transient":"c46448de"},"reporter":{"singleton":"6c6364e0","scoped":"60aeb626","transient":"3df88c69"}}

curl http://localhost:5080/api/lifetimes
{"controller":{"singleton":"6c6364e0","scoped":"4d0513be","transient":"78103649"},"reporter":{"singleton":"6c6364e0","scoped":"4d0513be","transient":"6a68f1c9"}}

Tahminin tuttu mu? Singleton ID'si iki request'te de 6c6364e0; uygulama ayakta kaldıkça tek bir instance var. Scoped ID'si aynı request içinde controller'da ve reporter'da aynı, ama ikinci request'te değişmiş. ASP.NET Core her HTTP request için bir scope açar ve scoped service o scope boyunca tektir. Transient ise istendiği her yerde yeniden oluşur; aynı request'te bile controller ile reporter farklı ID görüyor.

Bir otel düşün. Singleton lobidir, bütün misafirler aynısını kullanır. Scoped senin odandır; kaldığın süre boyunca senindir, çıktığında bir sonraki misafir için yenisi hazırlanır. Transient kahvaltıdaki kâğıt bardaktır, her uzandığında yenisini alırsın.

Seçim için kısa kural: state tutmayan ya da thread-safe biçimde paylaşılabilen şeyler (cache, bizim store'umuz) singleton; bir request'e ait iş birimi (veritabanı bağlantısı, o anki kullanıcı bilgisi) scoped; hafif ve state'siz yardımcılar transient. EF Core'un DbContext'i varsayılan olarak scoped kaydedilir, nedenini veri katmanı yazısında göreceğiz.

Captive Dependency: Singleton'a Hapsolan Scoped Service

Lifetime'ların bir kuralı var: bir service, kendisinden daha kısa yaşayan bir service'e bağımlı olmamalı. Bu kuralı bozan klasik hata şöyle görünür:

namespace ProductCatalog.Services;

public class PriceCache
{
    private readonly ScopedOperation _operation;

    public PriceCache(ScopedOperation operation)
    {
        _operation = operation;
    }

    public string OperationId => _operation.Id;
}
builder.Services.AddSingleton<PriceCache>();

// ...

app.MapGet("/cache", (PriceCache cache) => cache.OperationId);

Singleton bir kez oluşur ve constructor'ında aldığı scoped instance'ı ömrü boyunca elinde tutar. O instance artık hiçbir request'e ait değil; ilk misafirin odasında unutulmuş, kapısı da dışarıdan kilitlenmiş. Bu scoped service bir DbContext olsaydı, bütün request'ler thread-safe olmayan tek bir DbContext'i paylaşıyor olurdu.

Uygulamayı Development ortamında çalıştırınca açılmıyor bile:

Unhandled exception. System.AggregateException: Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: ProductCatalog.Services.PriceCache Lifetime: Singleton ImplementationType: ProductCatalog.Services.PriceCache': Cannot consume scoped service 'ProductCatalog.Services.ScopedOperation' from singleton 'ProductCatalog.Services.PriceCache'.)

Aynı kodu Production ortamında çalıştırdığında ise uygulama sessizce açılıyor. /cache adresine kaç request atarsan at hep aynı ID'yi (c559c5a7) dönüyor, /api/lifetimes ise her request'te yeni bir scoped ID gösteriyor. Hata ortadan kalkmadı, sadece görünmez oldu.

Scope Validation ve IServiceProvider

Development'taki bu koruma tesadüf değil. WebApplication.CreateBuilder, ortam Development olduğunda container'ın iki kontrolünü açar. ValidateScopes, scoped bir service'in singleton'dan ya da root provider'dan çözülmesini yakalar. ValidateOnBuild ise uygulama açılırken bütün kayıtların gerçekten oluşturulabildiğini dener. Production'da ikisi de kapalı. Bu hataları her ortamda yakalamak istersen ikisini kendin açabilirsin:

builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateScopes = true;
    options.ValidateOnBuild = true;
});

Bu ayarla PriceCache örneği Production'da da aynı exception ile açılışta duruyor. Bedeli biraz daha uzun bir başlangıç süresi; çoğu API için ödenmeye değer.

Container'ın kendisi IServiceProvider interface'iyle temsil edilir ve ona app.Services üzerinden ulaşırsın. Ama bu root provider'dır. Scoped bir service'i doğrudan ondan istersen Development'ta şunu alırsın:

ScopedOperation op = app.Services.GetRequiredService<ScopedOperation>();
Unhandled exception. System.InvalidOperationException: Cannot resolve scoped service 'ProductCatalog.Services.ScopedOperation' from root provider.

Request dışında, örneğin uygulama açılırken bir kerelik bir iş için scoped service gerekiyorsa scope'u kendin açarsın:

using (IServiceScope scope = app.Services.CreateScope())
{
    ScopedOperation op = scope.ServiceProvider.GetRequiredService<ScopedOperation>();
    Console.WriteLine($"scope resolved {op.Id}");
}

using bloğu bitince scope kapanır ve içinde oluşan disposable service'ler temizlenir. Bir şeyi de netleştirelim: IServiceProvider'ı controller'a enjekte edip her şeyi GetRequiredService ile çekmek mümkün. Buna service locator denir; bağımlılıkları constructor'dan gizler ve test yazmayı zorlaştırır. Varsayılan yolun constructor injection olsun.

Action'a ve Minimal API Handler'ına Service Almak

Bir service'i yalnızca tek bir action kullanıyorsa onu constructor yerine action parametresi olarak da alabilirsin. .NET 6'da burada küçük ama sinsi bir detay var:

[HttpGet("reporter")]
public IActionResult FromAction([FromServices] OperationReporter reporter)
{
    return Ok(reporter.Report());
}

[FromServices] attribute'unu silip aynı isteği .NET 6'da atarsan şu yanıt gelir:

HTTP/1.1 415 Unsupported Media Type
Content-Type: application/problem+json; charset=utf-8

{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.13","title":"Unsupported Media Type","status":415,"traceId":"00-b0b787e91adeb9a6136e863157b3141e-a6c5918b0ca5d881-00"}

[ApiController], kaynağı belirtilmemiş complex tipli bir parametreyi body'den okumaya çalışır; GET isteğinde body de content type da olmadığı için 415 döner. .NET 7'den itibaren framework parametrenin container'da kayıtlı olduğunu fark edip onu service olarak çözüyor; aynı kod .NET 10'da attribute olmadan da 200 döner. Kemik yapımız .NET 6 olduğu için attribute'u her zaman yaz, iki sürümde de çalışır.

Minimal API'de durum baştan beri farklı. .NET 6'daki handler da container'da kayıtlı tipleri kendiliğinden service olarak çözer:

app.MapGet("/api/minimal/reporter", (OperationReporter reporter) => reporter.Report());

Minimal API yazısında parametrenin "bir yerlerden" geldiğini söylediğimiz durum tam olarak buydu.

Aynı Interface, Birden Fazla Kayıt

Bir interface'e birden fazla implementasyon kaydedersen ne olur?

namespace ProductCatalog.Services;

public interface INotifier
{
    string Name { get; }
}

public class EmailNotifier : INotifier
{
    public string Name => "email";
}

public class SmsNotifier : INotifier
{
    public string Name => "sms";
}
builder.Services.AddSingleton<INotifier, EmailNotifier>();
builder.Services.AddSingleton<INotifier, SmsNotifier>();
public NotifiersController(INotifier notifier, IEnumerable<INotifier> all)
{
    _notifier = notifier;
    _all = all;
}

[HttpGet]
public IActionResult Get()
{
    return Ok(new { single = _notifier.Name, all = _all.Select(n => n.Name) });
}
curl http://localhost:5080/api/notifiers
{"single":"sms","all":["email","sms"]}

Tek bir INotifier isteyen son kaydı alır. IEnumerable<INotifier> isteyen ise hepsini kayıt sırasıyla alır; bir bildirimi birden fazla kanala dağıtmak için kullanışlı. Kütüphane yazıyorsan ve uygulamanın kendi kaydını ezmek istemiyorsan Microsoft.Extensions.DependencyInjection.Extensions namespace'indeki TryAddSingleton gibi method'ları kullan; aynı service tipi daha önce kaydedildiyse hiçbir şey yapmazlar. İsimle ayırt edilen kayıtlar (keyed services) .NET 8 ile geldi; onu ilerleyen sürüm yazılarında göreceğiz.

Sık Düşülen Tuzaklar

Birincisi, kaydı unutmak. Container controller'ı oluşturamaz ve hata ancak ilk request'te ortaya çıkar, çünkü controller'lar container'a service olarak kaydedilmez:

System.InvalidOperationException: Unable to resolve service for type 'ProductCatalog.Services.IProductStore' while attempting to activate 'ProductCatalog.Controllers.ProductsController'.

İkincisi, Program.cs'te bir service'e erken ulaşmak için builder.Services.BuildServiceProvider() çağırmak. Bu ikinci bir container yaratır ve singleton'lar iki kez oluşur. Derleyici de seni uyarır:

warning ASP0000: Calling 'BuildServiceProvider' from application code results in an additional copy of singleton services being created. Consider alternatives such as dependency injecting services as parameters to 'Configure'. (https://aka.ms/AA5k895)

Üçüncüsü, singleton'da thread-safe olmayan state tutmak. Singleton aynı anda yüzlerce request tarafından kullanılır; içindeki List<T> ya da Dictionary<TKey, TValue> korunmazsa veri sessizce bozulur. Store'daki lock bu yüzden orada.

Dördüncüsü, captive dependency'nin farklı kılıkları. Singleton bir service'e DbContext enjekte etmek, ya da bir sonraki yazıda göreceğimiz scoped IOptionsSnapshot<T>'ı singleton'a almak aynı hatadır. Development'ta yakalanır, Production'da sessizce yanlış çalışır.

Beşincisi, IDisposable implement eden transient bir service'i root provider'dan çözmek. Root provider oluşturduğu disposable instance'ları kendisi dispose edilene kadar, yani uygulama kapanana kadar tutar. Döngü içinde bunu yaparsan bellek yavaş yavaş dolar.

Küçük Bir Deneme

★☆☆ OperationReporter'ın kaydını AddTransient yerine AddScoped yap. İsteği atmadan önce reporter içindeki transient ID'nin controller'dakiyle aynı mı farklı mı olacağını tahmin et, sonra kontrol et.

★★☆ InMemoryProductStore'u AddScoped ile kaydet. POST ile bir ürün ekle, ardından GET ile iste. Gördüğün sonucu lifetime tablosuyla açıkla. Sonra AddTransient ile tekrar dene; fark var mı?

★★★ PriceCache'i düzelt: singleton kalsın ama constructor'ında ScopedOperation yerine IServiceScopeFactory alsın ve her çağrıda kendi scope'unu açıp scoped service'i o scope'tan çözsün. UseDefaultServiceProvider ile iki kontrolü de açıp uygulamanın Production ortamında açıldığını doğrula.

Bu yazıda ürün listesi de, sayfa boyutu gibi değerler de hâlâ koda gömülü. Bir sonraki yazıda bu değerleri configuration ve Options pattern ile appsettings.json'a, environment variable'lara ve user secrets'a taşıyacağız; IOptionsSnapshot'ın neden singleton'a enjekte edilemediğini de bugünkü lifetime bilgisiyle çözeceğiz.