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

EF Core'da Change Tracking: Entity State'leri, AsNoTracking ve Identity Resolution

Mert Özen 5 Kas 2026 10 dk 3 görüntülenme
EF Core'da Change Tracking: Entity State'leri, AsNoTracking ve Identity Resolution

EF Core'un change tracker'ını açıyoruz: beş entity state'i, ChangeTracker.Entries ve DebugView, aynı satır için tek nesne kuralı (identity resolution), AsNoTracking ile AsNoTrackingWithIdentityResolution farkı, DetectChanges'in ne zaman çalıştığı ve 'already being tracked' hatası.

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: Hangi Kolonlar Güncellenir?

Veritabanındaki 1 numaralı kitabın başlığı zaten "A Wizard of Earthsea", fiyatı 12.50. Şu kod çalışınca UPDATE komutunda hangi kolonlar yer alır?

var book = db.Books.Single(b => b.Id == 1);
book.Title = "A Wizard of Earthsea";
book.Price = 13.00m;
db.SaveChanges();

İki property'ye de değer atadık. Ama EF Core'un gönderdiği komut yalnızca birini içeriyor:

UPDATE "Books" SET "Price" = @p0
WHERE "Id" = @p1;
SELECT changes();

Title'a atama yapıldı ama değer değişmedi, EF Core da bunu biliyor. Nereden? Kitap sorguyla geldiği anda EF Core bütün property değerlerinin bir kopyasını sakladı. SaveChanges çağrıldığında her property'nin şimdiki değerini o kopyayla karşılaştırdı ve yalnızca farklı olanları yazdı. Bu mekanizmanın adı change tracking, işi yapan parça da change tracker. Bu yazıda onun içine bakıyoruz: entity state'leri, change tracker'ı nasıl okuyacağın, identity resolution, AsNoTracking ve DetectChanges.

EF Core 10 aynı UPDATE'i SELECT changes() yerine RETURNING 1 ile bitiriyor; EF Core 7'den beri SQLite'ta RETURNING kullanılıyor. Davranış aynı.

Model

Bu yazıda kısa bir model yeterli. Seed verisini HasData ile veriyoruz ve yalnızca çalışan SQL komutlarını logluyoruz:

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;

namespace Bookstore;

public class Author
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public List<Book> Books { get; set; } = new();
}

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = null!;
    public decimal Price { get; set; }
    public int AuthorId { get; set; }
    public Author Author { get; set; } = null!;
}

public class BookstoreContext : DbContext
{
    public DbSet<Author> Authors => Set<Author>();
    public DbSet<Book> Books => Set<Book>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options
            .UseSqlite("Data Source=bookstore.db")
            .LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted });

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Author>().HasData(
            new Author { Id = 1, Name = "Ursula K. Le Guin" },
            new Author { Id = 2, Name = "Isaac Asimov" });

        modelBuilder.Entity<Book>().HasData(
            new Book { Id = 1, Title = "A Wizard of Earthsea", Price = 12.50m, AuthorId = 1 },
            new Book { Id = 2, Title = "The Left Hand of Darkness", Price = 14.00m, AuthorId = 1 },
            new Book { Id = 3, Title = "Foundation", Price = 11.00m, AuthorId = 2 });
    }
}

Her örnek temiz bir veritabanı ve yeni bir BookstoreContext ile başlıyor.

Entity State'leri: Bir Nesnenin Hayat Döngüsü

Change tracker takip ettiği her entity için bir state tutar. Beşi var ve bir yazarın hayatı boyunca hepsini görebiliriz:

var author = new Author { Name = "Stanislaw Lem" };
Console.WriteLine($"1. {db.Entry(author).State}");

db.Authors.Add(author);
Console.WriteLine($"2. {db.Entry(author).State}");

db.SaveChanges();
Console.WriteLine($"3. {db.Entry(author).State}, Id = {author.Id}");

author.Name = "Stanislaw Herman Lem";
Console.WriteLine($"4. {db.Entry(author).State}");
db.SaveChanges();

db.Authors.Remove(author);
Console.WriteLine($"5. {db.Entry(author).State}");

db.SaveChanges();
Console.WriteLine($"6. {db.Entry(author).State}");
1. Detached
2. Added
3. Unchanged, Id = 3
4. Modified
5. Deleted
6. Detached

Detached: context bu nesneyi tanımıyor. new ile oluşturduğun her nesne böyle başlar. Added: SaveChanges'te INSERT edilecek. Kayıttan sonra state Unchanged'e döndü ve Id veritabanının ürettiği değerle doldu. Modified: en az bir property'si değişti, UPDATE edilecek. Deleted: Remove çağrıldı, DELETE edilecek; kayıttan sonra nesne yeniden Detached olur.

Her SaveChanges aslında bu state'lerin SQL'e çevrilmesidir: Added → INSERT, Modified → UPDATE, Deleted → DELETE, Unchanged → hiçbir şey. Yazının başındaki tahmin sorusu da bunun bir sonucu: kitabın state'i Modified, ama UPDATE'e yalnızca değişmiş olarak işaretlenen property girdi.

Change Tracker'ın İçine Bakmak

Takip edilen her şeyi ChangeTracker.Entries() ile görebilirsin:

var books = db.Books.Where(b => b.AuthorId == 1).ToList();
books[0].Price = 9.99m;
db.Books.Remove(books[1]);
db.Authors.Add(new Author { Name = "Stanislaw Lem" });

foreach (var entry in db.ChangeTracker.Entries())
{
    Console.WriteLine($"{entry.Metadata.ClrType.Name,-7} {entry.State}");
}

var priceEntry = db.Entry(books[0]).Property(b => b.Price);
Console.WriteLine($"Price: {priceEntry.OriginalValue} -> {priceEntry.CurrentValue}, modified: {priceEntry.IsModified}");
Author  Added
Book    Modified
Book    Deleted
Price: 12.5 -> 9.99, modified: True

Entry(...) sana tek bir entity'nin kaydını verir. Property(...) üzerinden orijinal değere, şimdiki değere ve IsModified bayrağına ulaşırsın. Bu bayrağı elle de değiştirebilirsin; bir sonraki yazıda disconnected senaryolarda bunu kullanacağız.

Hata ayıklarken daha hızlı bir yol var: EF Core 5 ile gelen DebugView. Aynı durumda db.ChangeTracker.DebugView.ShortView şunu yazar:

Author {Id: -2147482646} Added
Book {Id: 1} Modified FK {AuthorId: 1}
Book {Id: 2} Deleted FK {AuthorId: 1}

Yeni yazarın Id'si garip görünüyor: -2147482646. Bu bir temporary value. Veritabanı gerçek değeri INSERT sırasında üretecek; o zamana kadar EF Core nesneyi ayırt edebilmek için geçici, negatif bir key veriyor. Daha ayrıntılısı LongView. Tek bir kitabın fiyatını değiştirip baktığında:

Book {Id: 1} Modified
  Id: 1 PK
  AuthorId: 1 FK
  Price: 9.99 Modified Originally 12.5
  Title: 'A Wizard of Earthsea'
  Author: <null>

Hangi property'nin değiştiği, eski değeri ve yüklenmemiş navigation'lar tek bakışta görünüyor. Bir SaveChanges beklemediğin bir SQL üretiyorsa ilk bakacağın yer burası.

Identity Resolution: Aynı Satır, Aynı Nesne

Önceki yazılarda Find'ın veritabanına gitmeden nesne döndürdüğünü ve Include sırasında fix-up yapıldığını gördük. İkisinin arkasında aynı kural var: bir context içinde, aynı key'e sahip bir satır için yalnızca bir nesne bulunur.

var first = db.Books.Single(b => b.Id == 3);
first.Title = "Foundation (unsaved)";

var second = db.Books.Single(b => b.Title == "Foundation");
Console.WriteLine($"Same instance: {ReferenceEquals(first, second)}");
Console.WriteLine($"Title: {second.Title}");
Same instance: True
Title: Foundation (unsaved)

İkinci sorgu gerçekten veritabanına gitti ve WHERE "b"."Title" = 'Foundation' ile satırı buldu. Ama EF Core gelen satırın key'ine bakınca 3 numaralı kitabı zaten takip ettiğini gördü ve yeni bir nesne oluşturmak yerine elindekini döndürdü. Satırdaki değerlerle de nesneyi ezmedi; başlık hâlâ kaydedilmemiş "Foundation (unsaved)". Yani sorgu "Foundation" başlıklı kitabı aradı ve sana başlığı farklı bir nesne döndürdü.

Bu, kafa karıştırıcı görünse de doğru davranış. Aksi hâlde aynı kitabın iki farklı nesnesi olur, birinde yaptığın değişiklik diğerinde görünmez ve SaveChanges hangisini yazacağını bilemezdi. Bedeli de şu: uzun yaşayan bir context, veritabanındaki güncel değerleri değil, ilk yüklediği değerleri gösterir.

AsNoTracking: Yalnızca Okuyacaksan

Bir liste ekranı ya da bir rapor veriyi yalnızca okur. Bu durumda değerlerin kopyasını saklamak, state tutmak ve identity resolution yapmak boşuna iştir. AsNoTracking() bu işlerin hepsini kapatır:

var a = db.Books.AsNoTracking().Single(b => b.Id == 3);
var b2 = db.Books.AsNoTracking().Single(b => b.Id == 3);
Console.WriteLine($"Same instance: {ReferenceEquals(a, b2)}, tracked: {db.ChangeTracker.Entries().Count()}");

a.Price = 1m;
Console.WriteLine($"SaveChanges: {db.SaveChanges()}");
Same instance: False, tracked: 0
SaveChanges: 0

Aynı satır için iki ayrı nesne geldi, change tracker boş kaldı ve fiyat değişikliği kaydedilmedi; SaveChanges 0 döndü. Bu nesneler context için yok hükmünde. Okuma ağırlıklı bir uygulamada varsayılanı tersine çevirmek de mümkün: options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking) yazarsan bütün sorgular takipsiz olur, takip istediğin yerde AsTracking() dersin.

AsNoTracking'in bir yan etkisi var. Identity resolution olmadığı için aynı ilişkili nesne her satırda yeniden oluşturulur:

var plain = db.Books.AsNoTracking().Include(b => b.Author).Where(b => b.AuthorId == 1).ToList();
Console.WriteLine($"AsNoTracking: {ReferenceEquals(plain[0].Author, plain[1].Author)}");

var resolved = db.Books.AsNoTrackingWithIdentityResolution().Include(b => b.Author).Where(b => b.AuthorId == 1).ToList();
Console.WriteLine($"WithIdentityResolution: {ReferenceEquals(resolved[0].Author, resolved[1].Author)}, tracked: {db.ChangeTracker.Entries().Count()}");
AsNoTracking: False
WithIdentityResolution: True, tracked: 0

İki kitap da Ursula K. Le Guin'e ait, ama AsNoTracking ile iki ayrı Author nesnesi geldi. Bin kitaplık bir listede bin yazar nesnesi demek. EF Core 5 ile gelen AsNoTrackingWithIdentityResolution() sorgu süresince bir kimlik tablosu tutar, aynı yazarı tek nesnede toplar, sorgu bitince de her şeyi bırakır. Change tracker yine boş. Graf içinde tekrar eden ilişkiler varsa ya da sonucu JSON'a çevirirken aynı nesnenin tekrar etmesini istemiyorsan bunu kullan.

DetectChanges: Değişikliği Kim Fark Ediyor?

book.Price = 9.99m satırı sıradan bir property ataması. EF Core'a hiçbir şey haber vermiyor. Peki state nasıl Modified oluyor? EF Core, takip ettiği her entity'nin şimdiki değerlerini saklı kopyayla karşılaştıran DetectChanges()'i belirli anlarda kendisi çağırıyor: SaveChanges'ten önce, Entries() ve Entry() çağrılarında, HasChanges()'te ve birkaç yerde daha. Otomatik çağrıyı kapatınca bu görünür hâle geliyor:

db.ChangeTracker.AutoDetectChangesEnabled = false;

var book = db.Books.Single(b => b.Id == 1);
book.Price = 9.99m;
Console.WriteLine($"Before DetectChanges: {db.Entry(book).State}");
Console.WriteLine($"HasChanges: {db.ChangeTracker.HasChanges()}");

db.ChangeTracker.DetectChanges();
Console.WriteLine($"After DetectChanges: {db.Entry(book).State}");
Before DetectChanges: Unchanged
HasChanges: False
After DetectChanges: Modified

Fiyat değişti ama EF Core karşılaştırmayı yapana kadar kitap Unchanged göründü. Neden kapatmak isteyesin? DetectChanges takip edilen her entity'yi gezer. Binlerce entity takip ederken bir döngünün içinde Entries() ya da HasChanges() gibi taramayı tetikleyen bir çağrı yaparsan her tur bütün listeyi baştan gezer ve iş karesel olarak yavaşlar. Add çağrısının kendisi EF Core'da taramayı tetiklemez (klasik Entity Framework 6'da tetikliyordu), bu yüzden sade bir ekleme döngüsünde fark görmezsin. Toplu işlemlerde otomatik algılamayı kapatıp iş bitince bir kez DetectChanges() çağırmak bilinen bir optimizasyon.

Sık Düşülen Tuzaklar

Otomatik algılamayı kapatıp geri açmayı unutmak. AutoDetectChangesEnabled = false iken SaveChanges de DetectChanges çağırmaz. Yukarıdaki örneğin aynısında DetectChanges yerine doğrudan SaveChanges() çağırdığımızda sonuç 0 oldu; hiçbir UPDATE gönderilmedi ve hata da alınmadı. Kapattıysan, kaydetmeden önce elle çağır ya da bir try/finally ile geri aç.

Aynı key'e sahip ikinci bir nesneyi takibe sokmak. Context bir kitabı zaten takip ederken aynı Id'ye sahip yeni bir nesneyi Update ya da Attach ile vermeye çalışırsan:

var tracked = db.Books.Single(b => b.Id == 1);
var incoming = new Book { Id = 1, Title = "A Wizard of Earthsea", Price = 15.00m, AuthorId = 1 };
db.Books.Update(incoming);
System.InvalidOperationException: The instance of entity type 'Book' cannot be tracked because another instance with the same key value for {'Id'} is already being tracked. When attaching existing entities, ensure that only one entity instance with a given key value is attached. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see the conflicting key values.

Identity resolution kuralı burada sert bir şekilde karşına çıkıyor. Bu hata genellikle bir API'de önce kaydı "var mı" diye okuyup ardından istemciden gelen nesneyi Update etmeye çalışınca görülür. Çözümleri bir sonraki yazıda konuşacağız.

Uzun yaşayan context. Bir context ne kadar çok sorgu çalıştırırsa o kadar çok nesne takip eder; bellek büyür, DetectChanges yavaşlar ve identity resolution yüzünden eski veri görürsün. Context'i kısa bir iş birimi için kullan: bir web isteği, bir komut, bir ekran. Zorunlu olarak uzun yaşayan bir context'in varsa EF Core 5'ten beri db.ChangeTracker.Clear() takip edilen her şeyi bırakır; denemede 3 olan sayı Clear()'dan sonra 0 oldu.

Takip edilmeyen nesneyi değiştirip kaydedildiğini sanmak. AsNoTracking ile gelen bir nesnede yaptığın değişiklik, nesneyi context'e açıkça bağlamadıkça hiçbir yere gitmez. Bunun nasıl yapıldığı da bir sonraki yazının konusu.

Küçük Bir Deneme

★☆☆ Bir kitabı yükle, Title'ını değiştir, sonra eski değerine geri al. SaveChanges'ten önce DebugView.LongView'a bak ve kitabın state'ini not et. Sonra SaveChanges çağırıp kaç UPDATE gittiğini loglardan kontrol et.

★★☆ Aynı sorguyu üç şekilde çalıştır: varsayılan tracking ile, AsNoTracking() ile ve AsNoTrackingWithIdentityResolution() ile. Her birinden sonra ChangeTracker.Entries().Count() değerini ve yazar nesnelerinin ReferenceEquals sonucunu yazdır, tabloyu kendin çıkar.

★★★ Bir döngüde 20.000 kitap ekleyen ve her 100 kitapta bir db.ChangeTracker.Entries<Book>().Count() ile ilerlemeyi raporlayan bir program yaz, süreyi Stopwatch ile ölç. Sonra döngüden önce AutoDetectChangesEnabled = false yap, döngüden sonra bir kez DetectChanges() çağır ve süreyi tekrar ölç. Raporlama satırını silip iki ölçümü tekrarla; farkın nereden geldiğini açıkla. Her durumda SaveChanges'in 20.000 döndürdüğünü doğrula.

Change tracker'ın neyi, ne zaman bildiğini artık görebiliyoruz. Sıradaki soru, bu bilginin veritabanına nasıl yazıldığı: SaveChanges komutları nasıl gruplar, ilişkili bir grafı tek seferde nasıl kaydedersin ve bir API'den gelen, context'in hiç görmediği bir nesneyi nasıl güncellersin? Bir sonraki yazıda veri kaydetmeyi konuşacağız.