Mert Özen Her Satırda Daha İleri
Veritabanı Entity Framework Core — #16

ASP.NET Core'da EF Core: AddDbContext, Scoped Lifetime, Factory ve Pooling

Mert Özen 5 Kas 2026 8 dk 1 görüntülenme
ASP.NET Core'da EF Core: AddDbContext, Scoped Lifetime, Factory ve Pooling

DbContext'i ASP.NET Core'a AddDbContext ile bağlıyor, scoped lifetime'ı ContextId ile görüyor, singleton ve background service tuzaklarını çözüyor, AddDbContextFactory ile AddDbContextPool'un ne zaman işe yaradığını gerçek çıktılarla inceliyoruz.

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

Önce Tahmin Et: Aynı Context mi?

Bir ASP.NET Core uygulamasında BookstoreContext'i iki yere enjekte ediyoruz: endpoint'in kendisine ve endpoint'in kullandığı BookService'e. Sonra endpoint iki context'in kimliğini yazdırıyor:

app.MapGet("/books/low-stock", (BookService service, CancellationToken cancellationToken)
    => service.GetLowStockAsync(cancellationToken));

app.MapGet("/whoami", (BookstoreContext db, BookService service)
    => $"endpoint: {db.ContextId}, service: {service.ContextId}");

/whoami'ye arka arkaya iki istek atıyoruz. Soru şu: aynı istekteki iki context aynı instance mı? İkinci istek, birincinin context'ini mi kullanır, yoksa yenisini mi alır? Cevabı vermeden önce bu yapıyı kuralım.

OnConfiguring'den AddDbContext'e

Şimdiye kadar context'i new BookstoreContext() ile kendimiz oluşturduk ve connection string'i OnConfiguring'in içine gömdük. Web uygulamasında bu iki karar da başkasına geçiyor. Context'i DI container oluşturuyor, ayarları da dışarıdan, constructor üzerinden alıyor:

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public decimal Price { get; set; }
    public int Stock { get; set; }
}

public class BookstoreContext : DbContext
{
    public BookstoreContext(DbContextOptions<BookstoreContext> options)
        : base(options)
    {
    }

    public DbSet<Book> Books => Set<Book>();
}

DbContextOptions<BookstoreContext> provider'ı, connection string'i ve diğer bütün ayarları taşır. OnConfiguring override'ı artık yok. Connection string appsettings.json'a gidiyor:

{
  "ConnectionStrings": {
    "Bookstore": "Data Source=bookstore.db"
  }
}

Kaydı Program.cs'te yapıyoruz:

builder.Services.AddDbContext<BookstoreContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("Bookstore")));

builder.Services.AddScoped<BookService>();

AddDbContext, context'i ve onun options'ını container'a scoped olarak kaydeder. Paket olarak Microsoft.EntityFrameworkCore.Sqlite yeterli; ASP.NET Core entegrasyonu için ayrı bir paket gerekmiyor. Uygulama açılırken veritabanını hazırlamak için bir scope açıp context'i oradan istiyoruz:

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<BookstoreContext>();
    db.Database.EnsureDeleted();
    db.Database.EnsureCreated(); // örnek için; gerçek projede migration
    db.Books.AddRange(
        new Book { Title = "Dune", Price = 12.5m, Stock = 5 },
        new Book { Title = "Dune Messiah", Price = 9.9m, Stock = 2 });
    db.SaveChanges();
}

EnsureCreated burada örneği kısa tutmak için var. Gerçek bir projede migration kullanırsın; uygulama açılışında migrate etmenin risklerini migration yazısında konuşmuştuk.

Scoped Lifetime: Her İstek Kendi Context'i

Açılıştaki soruya dönelim. Uygulamayı çalıştırıp /whoami'ye iki istek attık:

endpoint: 4e02b9cf-46da-413f-94c0-9ff13123a0e5:0, service: 4e02b9cf-46da-413f-94c0-9ff13123a0e5:0
endpoint: be96b457-7249-4a93-9e46-66e8fc4aeb63:0, service: be96b457-7249-4a93-9e46-66e8fc4aeb63:0

ContextId, iki parçalı bir kimlik: instance'ın Guid'i ve iki nokta üst üsteden sonra bir lease numarası; ona pooling'de döneceğiz. Aynı istekte endpoint ve service aynı instance'ı aldı. İkinci istek ise bambaşka bir instance aldı. Scoped lifetime tam olarak budur: ASP.NET Core her HTTP isteği için bir scope açar, o scope içinde context'i isteyen herkes aynı instance'ı alır, istek bitince scope kapanır ve context dispose edilir. Çıktı EF Core 10'dan; EF Core 6'da yalnız Guid'ler farklı.

Bu davranış EF Core için biçilmiş kaftan. Change tracker'ı hatırla: bir context, yüklediği entity'leri takip eder ve SaveChanges'te onları yazar. İstek boyunca yapılan değişiklikleri tek bir context'te toplamak, isteğin sonunda tek bir SaveChanges ile bitirmek demek. Context'in istekler arasında taşınmaması da önemli: biri kullanıcının track edilen verisi diğerine sızmaz, change tracker da sonsuza kadar büyümez. Lifetime'ların genel mantığını ASP.NET Core Web API serisindeki dependency injection yazısında görmüştük; burada onun EF Core'daki karşılığını görüyoruz.

Singleton'a Context Enjekte Etmek

Bir fiyat önbelleği yazdığını düşün. Uygulama boyunca tek instance yeter diye singleton kaydediyorsun ve içine context'i enjekte ediyorsun:

public class BookCache
{
    private readonly BookstoreContext _db;

    public BookCache(BookstoreContext db) => _db = db; // singleton içinde scoped context
}
builder.Services.AddSingleton<BookCache>();

Uygulama Development ortamında açılırken daha ilk istekten önce duruyor:

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

Bu, Web API serisinde gördüğümüz captive dependency'nin ta kendisi. Singleton hiç ölmediği için, içine aldığı context de hiç ölmez. Bütün istekler aynı context'i, aynı change tracker'ı ve aynı bağlantıyı paylaşır. Development ortamında scope doğrulaması açık olduğu için hata hemen çıkıyor. Production'da bu doğrulama varsayılan olarak kapalı; uygulama açılır ve sorun ilk yük altında, garip veri ve thread hatalarıyla ortaya çıkar. Singleton'ın veritabanına ihtiyacı varsa context'i değil, birazdan göreceğimiz factory'yi ya da scope factory'yi almalı.

Background Service'te DbContext

BackgroundService de bir singleton'dır; AddHostedService ile kaydedilir ve uygulama boyunca yaşar. Yani aynı kural geçerli: constructor'a context alamazsın. Bunun yerine IServiceScopeFactory alıp her iş turu için kendi scope'unu açarsın:

public class LowStockReporter : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<LowStockReporter> _logger;

    public LowStockReporter(IServiceScopeFactory scopeFactory, ILogger<LowStockReporter> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));

        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            using var scope = _scopeFactory.CreateScope(); // her tur için yeni scope, yeni context
            var db = scope.ServiceProvider.GetRequiredService<BookstoreContext>();

            var count = await db.Books.CountAsync(b => b.Stock < 3, stoppingToken);
            _logger.LogInformation("Low stock books: {Count}, context {ContextId}", count, db.ContextId);
        }
    }
}

Uygulamayı 11 saniye açık tuttuğumuzda logda iki tur görünüyor:

info: LowStockReporter[0]
      Low stock books: 1, context 4018e1f1-5fe7-43ae-9f27-3a1b04f7e698:0
info: LowStockReporter[0]
      Low stock books: 1, context 527ad837-5042-4848-912c-381bc75e7197:0

Her tur yeni bir context ile çalışıyor. Burada scope, HTTP isteğinin yaptığı işi elle yapıyor: başlangıçta açılıyor, turun sonunda using ile kapanıyor ve context'i de götürüyor. Context'i döngünün dışında bir kez alıp sonsuza kadar kullanmak cazip gelebilir. Yapma: change tracker her turda büyür ve bir saat önce okunan veri hâlâ "güncel" sanılır. stoppingToken'ı da sorgulara geçirdik; uygulama kapanırken uzun bir sorgu yarıda kesilebiliyor. CancellationToken'ı veritabanına kadar taşımayı Web API serisinde ayrıca konuşmuştuk.

AddDbContextFactory

Scope açmak yerine doğrudan "bana bir context üret" demek istediğin durumlar var. Background service bunlardan biri. Blazor Server başka bir örnek: orada bir scope bütün kullanıcı bağlantısı boyunca yaşar ve scoped bir context'i saatlerce tutmak istemezsin. Bunun için AddDbContextFactory var:

builder.Services.AddDbContextFactory<BookstoreContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("Bookstore")));

builder.Services.AddHostedService<LowStockFactoryReporter>();

Service, IDbContextFactory<BookstoreContext> alıyor ve her turda yeni bir context oluşturuyor:

public class LowStockFactoryReporter : BackgroundService
{
    private readonly IDbContextFactory<BookstoreContext> _contextFactory;
    private readonly ILogger<LowStockFactoryReporter> _logger;

    public LowStockFactoryReporter(IDbContextFactory<BookstoreContext> contextFactory,
        ILogger<LowStockFactoryReporter> logger)
    {
        _contextFactory = contextFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));

        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            using var db = await _contextFactory.CreateDbContextAsync(stoppingToken); // dispose sende
            var count = await db.Books.CountAsync(b => b.Stock < 3, stoppingToken);
            _logger.LogInformation("Low stock books: {Count}, context {ContextId}", count, db.ContextId);
        }
    }
}

Factory'nin ürettiği context'in sahibi sensin; DI container onu senin yerine dispose etmez. Bu yüzden using şart. Factory singleton olarak kaydedildiği için singleton'lara güvenle enjekte edilebilir. EF Core 6 ile gelen bir kolaylık da var: AddDbContextFactory, context'in kendisini de scoped olarak kaydediyor. Bu örnekte yalnız factory'yi kaydettik, ama /whoami endpoint'i BookstoreContext'i yine doğrudan alabildi ve aynı istekte endpoint ile service aynı instance'ı gördü. Yani factory'yi kaydetmek, endpoint'lerdeki normal kullanımı bozmuyor.

AddDbContextPool

Context oluşturmak ucuzdur ama bedava değildir. Her istekte yeni bir instance, birkaç iç service ve ayar object'i kurulur. Çok yüksek trafikli uygulamalarda EF Core, dispose edilen context'leri atmak yerine sıfırlayıp bir havuzda saklayabilir:

builder.Services.AddDbContextPool<BookstoreContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("Bookstore")));

Aynı iki /whoami isteği bu kez şu çıktıyı verdi:

endpoint: e64683e6-e6b9-49ee-8ac9-1f99ca888e4a:3, service: e64683e6-e6b9-49ee-8ac9-1f99ca888e4a:3
endpoint: e64683e6-e6b9-49ee-8ac9-1f99ca888e4a:4, service: e64683e6-e6b9-49ee-8ac9-1f99ca888e4a:4

İki istek de aynı instance'ı kullandı; değişen yalnız lease numarası. Her istek havuzdan context'i kiralıyor, istek bitince context sıfırlanıp havuza dönüyor ve bir sonraki istek onu yeniden kiralıyor. Daha önceki lease'leri açılıştaki seed scope'u ve uygulamanın ayağa kalktığını kontrol eden ilk isteğimiz kullandı. Havuzun varsayılan boyutu 1024; poolSize parametresiyle değiştirilebilir.

Pooling'in kuralları var. Context'in constructor'ı yalnız DbContextOptions almalı; constructor'a başka bir scoped service enjekte edemezsin, çünkü context bir kez oluşturulup tekrar tekrar kullanılıyor. Context'e kendi eklediğin field'lar da, örneğin bir tenant id, kiralar arasında sıfırlanmaz ve bir önceki isteğin değeri sonraki isteğe taşınabilir. EF Core change tracker'ı ve kendi state'ini sıfırlıyor, senin state'ini değil. Kazanç da genelde küçüktür; ancak saniyede binlerce istek alan ve ölçülmüş bir darboğazı olan uygulamalarda fark yaratır. Performans yazısında bu konuya kısaca döneceğiz.

Sık Düşülen Tuzaklar

Aynı context'te paralel sorgu. "İki sorguyu aynı anda atarsam daha hızlı olur" düşüncesi şu koda dönüşür:

var booksTask = db.Books.ToListAsync();
var countTask = db.Books.CountAsync();
await Task.WhenAll(booksTask, countTask); // aynı context'te iki sorgu aynı anda

SQLite'ta bu kod çalışıyor, çünkü Microsoft.Data.Sqlite'ın async method'ları aslında senkron çalışıyor; ikinci sorgu, birincisi bittikten sonra başlıyor. SQL Server'da ise iki sorgu gerçekten üst üste biner. Aynı context'i iki thread'den gerçekten aynı anda kullandığımızda, SQLite'ta bile şu exception'ı aldık:

InvalidOperationException: A second operation was started on this context instance before a previous operation completed. This is usually caused by different threads concurrently using the same instance of DbContext. For more information on how to avoid threading issues with DbContext, see https://go.microsoft.com/fwlink/?linkid=2097913.

Context thread-safe değil. Bir context'te her seferinde tek bir işlem olur: her sorguyu await et, sonra bir sonrakine geç. Gerçekten paralellik gerekiyorsa her iş için factory'den ayrı bir context al.

Context'i static bir field'da ya da singleton'da saklamak. Captive dependency'nin elle yazılmış hâli; DI doğrulaması bile seni bundan kurtaramaz.

async void ve Task.Run ile istekten kaçan iş. Endpoint'ten "ateşle ve unut" diye başlattığın bir iş, istek bitip scope kapandıktan sonra context'e dokunursa ObjectDisposedException alırsın. İsteği aşan işi background service'e ya da bir kuyruğa devret.

Pooling'de OnConfiguring'e güvenmek. Ayarları AddDbContextPool'a verdiğin lambda'da topla; context'in içinde istek başına değişen bir ayar yapmaya çalışma.

Küçük Bir Deneme

★☆☆ BookService'i AddScoped yerine AddTransient ile kaydet ve /whoami'yi tekrar çağır. Endpoint ile service hâlâ aynı context'i görüyor mu? Neden?

★★☆ ASPNETCORE_ENVIRONMENT'ı Production yapıp BookCache'li uygulamayı çalıştır. Uygulama açılıyor mu? BookCache'e context'in ContextId'sini döndüren bir property ekle ve iki istekte aynı değeri gördüğünü doğrula.

★★★ AddDbContextPool ile çalışan context'e public string? TenantId { get; set; } ekle. Bir endpoint'te değeri ata, başka bir endpoint'te oku ve lease numarasıyla birlikte yazdır. Değerin istekler arasında taşındığını gördükten sonra bunu nasıl düzelteceğini düşün.

Context'i doğru yere bağladık; şimdi onun ne kadar hızlı çalıştığına bakma zamanı. Bir sonraki yazıda N+1 sorgularını, projection ile DTO'ları, gereksiz tracking'i, index'leri, compiled query'leri ve sorguları ölçmeyi ele alacağız.