EF Core Interceptor'ları ve Event'leri: Audit Alanları ve Yavaş Sorgu Logu
SaveChangesInterceptor ile CreatedAt ve UpdatedAt alanlarını otomatik dolduruyor, DbCommandInterceptor ile eşiği aşan sorguları logluyor, SavingChanges ve StateChanged event'leriyle change tracker'ı canlı izliyoruz. Async override'ı unutunca neden hiçbir şeyin çalışmadığını da görüyoruz.
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: Bu Interceptor Kaç Kez Çalışır?
Aşağıdaki class, EF Core'un SaveChanges sürecine araya giren küçük bir parça. Tek yaptığı konsola bir satır yazmak:
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
public class SyncOnlyInterceptor : SaveChangesInterceptor
{
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData, InterceptionResult<int> result)
{
Console.WriteLine("SavingChanges called");
return base.SavingChanges(eventData, result);
}
}
Context'e options.UseSqlite("Data Source=bookstore.db").AddInterceptors(new SyncOnlyInterceptor()) ile ekledik ve iki kitabı iki farklı şekilde kaydediyoruz:
db.Books.Add(new Book { Title = "Dune" });
Console.WriteLine("calling SaveChanges");
db.SaveChanges();
db.Books.Add(new Book { Title = "Emma" });
Console.WriteLine("calling SaveChangesAsync");
await db.SaveChangesAsync();
Console.WriteLine("done");
Konsolda "SavingChanges called" satırını kaç kez görürsün? EF Core 6 ve 10'daki gerçek çıktı:
calling SaveChanges
SavingChanges called
calling SaveChangesAsync
done
Bir kez. SaveChangesAsync interceptor'ın async method'unu çağırıyor, biz onu override etmediğimiz için hiçbir şey olmadı. İkinci kitap kaydedildi ama interceptor'ın haberi olmadı. Bir ASP.NET Core uygulamasında hemen her şey async olduğu için bu hata, interceptor'ın "hiç çalışmaması" gibi görünür. Bu yazıda interceptor'ları doğru yazmayı, onlarla audit alanlarını otomatik doldurmayı, yavaş sorguları yakalamayı ve EF Core'un daha hafif alternatifi olan event'leri göreceğiz.
Interceptor Nedir, Nerede Durur?
Interceptor, EF Core'un kendi iş akışının belirli noktalarına takılan bir kancadır. EF bir komut göndermeden önce ve sonra, bir bağlantı açarken, bir transaction başlatırken ya da SaveChanges çalışırken senin kodunu çağırır. Bir fabrika bandındaki kalite kontrol noktası gibi düşün: ürün banttan geçerken durdurup bakabilir, üzerine etiket yapıştırabilir, hatta banttan indirebilirsin.
EF Core 6'da en sık kullanacağın dört interceptor arayüzü var. ISaveChangesInterceptor kaydetme sürecine, IDbCommandInterceptor veritabanına giden her komuta, IDbConnectionInterceptor bağlantının açılıp kapanmasına, IDbTransactionInterceptor da transaction'lara girer. Her birinin işini kolaylaştıran bir base class var: SaveChangesInterceptor, DbCommandInterceptor gibi. Bu class'ların bütün method'ları boş virtual method'lar; sen yalnızca ilgilendiğin anı override edersin.
Kayıt her zaman aynı yerden yapılır: DbContextOptionsBuilder.AddInterceptors(...). Interceptor bir kez kaydedilir ve o options'la oluşturulan her context için çalışır.
SaveChangesInterceptor ile Audit Alanları
Klasik ihtiyaç: her kaydın ne zaman oluşturulduğunu ve en son ne zaman güncellendiğini tutmak. Bunu her Add çağrısının yanına yazmak yerine bir arayüzle işaretleyip tek bir yerde çözelim:
public interface IAuditable
{
DateTime CreatedAt { get; set; }
DateTime? UpdatedAt { get; set; }
}
public class Book : IAuditable
{
public int Id { get; set; }
public string Title { get; set; } = "";
public decimal Price { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
public class AuditInterceptor : SaveChangesInterceptor
{
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData, InterceptionResult<int> result)
{
StampAuditFields(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
StampAuditFields(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private static void StampAuditFields(DbContext? context)
{
if (context is null)
return;
var now = DateTime.UtcNow;
foreach (var entry in context.ChangeTracker.Entries<IAuditable>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAt = now;
}
else if (entry.State == EntityState.Modified)
{
entry.Entity.UpdatedAt = now;
// CreatedAt bir kez yazılır, sonra asla güncellenmez
entry.Property(e => e.CreatedAt).IsModified = false;
}
}
}
}
Sync ve async method'ların ikisini de override ettik ve asıl işi ortak bir method'a koyduk. SavingChanges, EF değişiklikleri SQL'e çevirmeden hemen önce çağrılıyor; bu yüzden burada entity'lere yazdığın değerler INSERT ve UPDATE'e giriyor. ChangeTracker.Entries<IAuditable>() da DetectChanges'i kendisi çalıştırıyor, yani state'ler güncel. eventData.Context'in nullable olmasının sebebi, aynı event data tipinin context'i olmayan senaryolarda da kullanılması; kontrol etmeden geçme.
Context'e kaydedip deneyelim:
public class BookstoreContext : DbContext
{
public DbSet<Book> Books => Set<Book>();
protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options.UseSqlite("Data Source=bookstore.db")
.AddInterceptors(new AuditInterceptor())
.LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted });
}
var book = new Book { Title = "Dune", Price = 18.50m };
db.Books.Add(book);
await db.SaveChangesAsync();
Console.WriteLine($"CreatedAt: {book.CreatedAt:O}, UpdatedAt: {book.UpdatedAt?.ToString("O") ?? "null"}");
book.Price = 16.00m;
book.CreatedAt = DateTime.MinValue; // yanlışlıkla ezmeye çalışalım
db.SaveChanges();
db.ChangeTracker.Clear();
var fresh = await db.Books.SingleAsync();
Console.WriteLine($"CreatedAt: {fresh.CreatedAt:O}, UpdatedAt: {fresh.UpdatedAt?.ToString("O") ?? "null"}");
EF Core 10'daki çıktı:
CreatedAt: 2026-11-03T21:57:49.5267920Z, UpdatedAt: null
CreatedAt: 2026-11-03T21:57:49.5267920, UpdatedAt: 2026-11-03T21:57:49.5537210
Güncellemede giden SQL, CreatedAt'i ezme denememizin veritabanına hiç ulaşmadığını gösteriyor:
UPDATE "Books" SET "Price" = @p0, "UpdatedAt" = @p1
WHERE "Id" = @p2
RETURNING 1;
Bir kez async, bir kez sync kaydettik ve iki yolda da interceptor çalıştı. İkinci satırda Z harfinin kaybolduğuna da dikkat et; buna tuzaklarda döneceğiz.
Araya Girip Durdurmak: InterceptionResult
Interceptor'ın event'ten farkı, sadece izlemekle kalmayıp akışı değiştirebilmesi. SavingChanges'in döndürdüğü InterceptionResult<int>, EF'e "devam et" ya da "bu işi ben hallettim, sonuç şu" demenin yolu. Örneğin bakım modunda bir uygulama hiçbir şey yazmasın:
public class ReadOnlyInterceptor : SaveChangesInterceptor
{
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData, InterceptionResult<int> result)
=> InterceptionResult<int>.SuppressWithResult(0);
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result,
CancellationToken cancellationToken = default)
=> ValueTask.FromResult(InterceptionResult<int>.SuppressWithResult(0));
}
var book = new Book { Title = "Dune" };
db.Books.Add(book);
var saved = await db.SaveChangesAsync();
Console.WriteLine($"saved: {saved}, state: {db.Entry(book).State}, rows: {await db.Books.AsNoTracking().CountAsync()}");
Çıktı saved: 0, state: Added, rows: 0. Veritabanına hiçbir komut gitmedi, SaveChangesAsync 0 döndürdü ve entity hâlâ Added durumunda bekliyor. Gerçek hayatta bunu daha çok bir exception fırlatarak yaparsın, ama bu örnek interceptor'ın elindeki gücü gösteriyor. Aynı mantık komut interceptor'larında da var: ReaderExecuting içinde command.CommandText'i değiştirebilir ya da komutu hiç göndermeden bir sonuç verebilirsin.
DbCommandInterceptor ile Yavaş Sorguları Yakalamak
Production'da en çok işe yarayan interceptor'lardan biri, belirli bir süreyi aşan sorguları loglayan basit bir class:
using System.Data.Common;
public class SlowQueryInterceptor : DbCommandInterceptor
{
private readonly TimeSpan _threshold;
public SlowQueryInterceptor(TimeSpan threshold) => _threshold = threshold;
public override DbDataReader ReaderExecuted(
DbCommand command, CommandExecutedEventData eventData, DbDataReader result)
{
Report(command, eventData);
return base.ReaderExecuted(command, eventData, result);
}
public override ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command, CommandExecutedEventData eventData, DbDataReader result,
CancellationToken cancellationToken = default)
{
Report(command, eventData);
return base.ReaderExecutedAsync(command, eventData, result, cancellationToken);
}
private void Report(DbCommand command, CommandExecutedEventData eventData)
{
if (eventData.Duration > _threshold)
Console.WriteLine($"SLOW ({eventData.Duration.TotalMilliseconds:F0} ms): {command.CommandText}");
}
}
ReaderExecuted, sonuç döndüren her komuttan sonra çağrılıyor; eventData.Duration da komutun ne kadar sürdüğünü veriyor. Sonuç döndürmeyen komutlar için NonQueryExecuted, tek değer döndürenler için ScalarExecuted var. Denemek için 10.000 kitap ekleyip bilerek pahalı bir sorgu yazalım:
// options.AddInterceptors(new SlowQueryInterceptor(TimeSpan.FromMilliseconds(100)))
db.Books.AddRange(Enumerable.Range(1, 10_000).Select(i => new Book { Title = $"Book {i}" }));
await db.SaveChangesAsync();
var first = await db.Books.TagWith("Single book").FirstAsync(b => b.Id == 42);
var pairs = await db.Books
.TagWith("Cross join report")
.SelectMany(a => db.Books, (a, b) => new { a.Id, Other = b.Id })
.CountAsync(x => x.Id < x.Other);
Kendi makinemde EF Core 10 ile çıktı (EF Core 6'da süre 179 ms çıktı, SQL aynı):
SLOW (172 ms): -- Cross join report
SELECT COUNT(*)
FROM "Books" AS "b"
CROSS JOIN "Books" AS "b0"
WHERE "b"."Id" < "b0"."Id"
Tek kitabı getiren sorgu eşiğin altında kaldığı için loglanmadı. TagWith ile verdiğimiz etiket SQL'in başına yorum olarak eklendi ve logda sorgunun kodda nereden geldiğini hemen gösterdi. Bu ikili, production'da "hangi ekran yavaş?" sorusunun en ucuz cevabıdır.
Dependency Injection ile Kayıt
ASP.NET Core'da context'i OnConfiguring yerine AddDbContext ile kaydedersin. Interceptor'ın kendisi de bağımlılık alabilsin diye onu container'a koyup oradan çözmek en temiz yol:
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddSingleton<AuditInterceptor>();
services.AddDbContext<BookstoreContext>((sp, options) =>
options.UseSqlite("Data Source=bookstore.db")
.AddInterceptors(sp.GetRequiredService<AuditInterceptor>()));
AddDbContext'in (sp, options) alan overload'u, options'ı kurarken service provider'a erişmeni sağlıyor. Context'in kendisi de DbContextOptions<BookstoreContext> alan bir constructor'a sahip olmalı. Interceptor'ı singleton kaydettiğimize dikkat et: aynı instance bütün request'lerin context'lerine hizmet edecek. ASP.NET Core entegrasyonunun geri kalanını daha önceki yazımızda ve ASP.NET Core Web API serisinde ayrıntılı gördük.
Event'ler: Daha Hafif Bir Kanca
Her ihtiyaç bir interceptor gerektirmiyor. Sadece "bir şey oldu, haberim olsun" diyorsan DbContext'in ve change tracker'ın event'leri yeterli:
public class BookstoreContext : DbContext
{
public DbSet<Book> Books => Set<Book>();
public BookstoreContext()
{
SavingChanges += (sender, e) =>
Console.WriteLine($"SavingChanges: {ChangeTracker.Entries().Count(x => x.State != EntityState.Unchanged)} pending");
SavedChanges += (sender, e) =>
Console.WriteLine($"SavedChanges: {e.EntitiesSavedCount} saved");
SaveChangesFailed += (sender, e) =>
Console.WriteLine($"SaveChangesFailed: {e.Exception.GetType().Name}");
ChangeTracker.Tracked += (sender, e) =>
Console.WriteLine($"Tracked: {((Book)e.Entry.Entity).Title ?? "(no title)"} (from query: {e.FromQuery})");
ChangeTracker.StateChanged += (sender, e) =>
Console.WriteLine($"StateChanged: {((Book)e.Entry.Entity).Title} {e.OldState} -> {e.NewState}");
}
protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options.UseSqlite("Data Source=bookstore.db");
}
Bir kitap ekleyip, sonra okuyup fiyatını değiştirip, en sonda da Title'ı null olan bir kitapla kaydı bilerek patlatalım:
db.Books.Add(new Book { Title = "Dune", Price = 18.50m });
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
var dune = await db.Books.SingleAsync();
dune.Price = 16.00m;
await db.SaveChangesAsync();
db.Books.Add(new Book { Title = null!, Price = 9.99m });
try { await db.SaveChangesAsync(); }
catch (DbUpdateException ex) { Console.WriteLine(ex.InnerException?.Message); }
EF Core 6 ve 10'da aynı çıktı (okunabilirlik için üç bölümü boş satırla ayırdım):
Tracked: Dune (from query: False)
SavingChanges: 1 pending
StateChanged: Dune Added -> Unchanged
SavedChanges: 1 saved
Tracked: Dune (from query: True)
StateChanged: Dune Unchanged -> Modified
SavingChanges: 1 pending
StateChanged: Dune Modified -> Unchanged
SavedChanges: 1 saved
Tracked: (no title) (from query: False)
SavingChanges: 1 pending
SaveChangesFailed: DbUpdateException
SQLite Error 19: 'NOT NULL constraint failed: Books.Title'.
Change tracking yazısında gördüğümüz state geçişlerini burada canlı izleyebiliyorsun. Tracked, entity'nin ilk kez takibe girdiği anı, FromQuery da bunun bir sorgudan mı yoksa Add'den mi geldiğini söylüyor. StateChanged ise sadece zaten takip edilen bir entity'nin state'i değiştiğinde tetikleniyor.
Hangisini ne zaman seçeceğin basit bir soruya bağlı. Akışı değiştirmen, durdurman ya da birden fazla context'te ortak bir davranış kurman gerekiyorsa interceptor. Belli bir context instance'ında olanları izlemek, loglamak ya da metrik toplamak istiyorsan event. Event'ler instance'a bağlıdır, interceptor'lar options'a.
Sık Düşülen Tuzaklar
Birincisi yazının başındaki tuzak: sync ve async method'lar ayrı. SavingChanges yazıp SavingChangesAsync'i unutursan async kayıtlarda interceptor çalışmaz. Aynısı ReaderExecuted ile ReaderExecutedAsync için de geçerli. Kuralı alışkanlık hâline getir: ortak bir method yaz, ikisinden de onu çağır.
İkincisi paylaşılan instance. AddInterceptors'a verdiğin nesne, o options'la oluşan bütün context'ler tarafından paylaşılır; DI'da singleton kaydettiğinde bütün request'ler aynı nesneyi kullanır. Interceptor'ın içinde "şu anki kullanıcı" ya da "bu request'te değişen kayıtlar" gibi bir alan tutarsan farklı thread'ler birbirinin verisini ezer. Interceptor'ı stateless yaz; context'e özgü bilgiye eventData.Context üzerinden ulaş.
Üçüncüsü, SaveChanges'i atlayan her şey interceptor'ı da atlar. ExecuteSqlRaw ile gönderdiğin bir UPDATE'te SavingChanges çağrılmaz, UpdatedAt da yazılmaz. EF Core 7 ile gelen ExecuteUpdate/ExecuteDelete için de aynısı geçerli; onları sürüm yazılarında göreceğiz. Audit kuralın kesin olmalıysa, bu yolları kullanan kodu bil ya da kuralı veritabanı tarafına taşı.
Dördüncüsü zamanla ilgili. Çıktıda ilk CreatedAt Z ile bitiyordu, veritabanından okunanınki bitmiyordu. SQLite'ta DateTime metin olarak saklanıyor ve DateTimeKind bilgisi yazılmıyor; geri okuduğunda Kind Unspecified oluyor. Değerin UTC olduğunu biliyorsun, ama kod bilmiyor. Bunu bir value converter ile ya da okurken DateTime.SpecifyKind ile düzeltebilirsin. EF Core 10'un SQLite'taki tarih davranışında yaptığı değişiklikleri de sürüm yazısında ele alacağız.
Küçük Bir Deneme
★☆☆ AuditInterceptor'dan SavingChangesAsync override'ını sil, yazıdaki kodu tekrar çalıştır ve ilk satırdaki CreatedAt değerinin ne olduğuna bak. Sonra override'ı geri koy.
★★☆ IAuditable'a string? UpdatedBy ekle. Interceptor'a constructor'dan Func<string> tipinde bir "current user" sağlayıcısı ver ve Modified kayıtlarda bu değeri yaz. Interceptor'ın içinde kullanıcı adını bir alanda tutmak yerine her çağrıda sağlayıcıdan okumanın neden önemli olduğunu düşün.
★★★ Bir DbConnectionInterceptor yaz ve ConnectionOpened ile ConnectionClosed anlarını logla. Önce tek bir sorgu, sonra Database.BeginTransaction() ile iki SaveChanges çalıştır ve bağlantının kaç kez açılıp kapandığını karşılaştır.
Bu yazıda EF Core'un davranışını kendi kodumuzla genişlettik. Peki yazdığımız interceptor'ın, value converter'ın ya da sorgunun gerçekten doğru çalıştığından nasıl emin olacağız? Bir sonraki yazıda EF Core ile test yazmayı, SQLite in-memory ile gerçekçi testleri ve InMemory provider'ın tuzaklarını konuşacağız.