EF Core 9 Yenilikleri: UseSeeding, Migration Locking ve PendingModelChangesWarning
EF Core 9'da eksik migration'ı Migrate anında yakalayan PendingModelChangesWarning, HasData ve DateTime.Now tuzağı, UseSeeding/UseAsyncSeeding, __EFMigrationsLock ile migration locking, kendiliğinden bulunan compiled model'ler ve EFOptimizeContext, complex type ile ExecuteUpdate.
Sürüm notu: Bu yazı EF Core 9 ile gelen yenilikleri anlatır; kodlar EF Core 9 içindir.
Önce Tahmin Et
Uygulaman açılışta migration'ları kendisi uyguluyor. Kemik yapıdaki migration yazısında bunun risklerini konuşmuştuk, ama sahada çok yaygın bir kalıp:
using var db = new BookstoreContext();
db.Database.Migrate();
db.Authors.Add(new Author { Name = "Ada", Books = { new Book { Title = "EF Notes", PublishedYear = 2020 } } });
db.SaveChanges();
Bir sabah Book'a public string? Isbn { get; set; } ekliyorsun ve dotnet ef migrations add çalıştırmayı unutuyorsun. Uygulama açılınca ne olur? Migrate() eksik kolonu kendisi mi ekler, hata mı verir, yoksa hiçbir şey olmamış gibi mi davranır? Hata varsa hangi satırda patlar?
12 Kasım 2024'te çıkan EF Core 9 bir STS sürümü ve yeniliklerinin çoğu tam bu tür "sessiz" hataları görünür kılmakla ilgili. EF Core 9 net8.0 hedefliyor, yani .NET 8 projesinde kalıp EF tarafını 9'a çekebilirsin. Örnekleri net8.0 hedefleyen bir projede EF Core 9.0.22 ve aynı sürümde dotnet-ef ile çalıştırdım.
EF Core 8 Susuyordu, EF Core 9 Durduruyor
Aynı senaryoyu önce EF Core 8.0.33 ile çalıştırdım. Migrate() sorunsuz döndü, loga warning seviyesinde tek satır bile düşmedi. Hata, SaveChanges sırasında geldi:
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Microsoft.Data.Sqlite.SqliteException (0x80004005): SQLite Error 1: 'table Books has no column named Isbn'.
Yani hata, sebebinden uzakta çıkıyor. Production'da bunu gören kişi önce veritabanından şüphelenir, sonra kodu inceler; migration'ın eksik olduğunu anlaması zaman alır. EF Core 9.0.22'de aynı kod Migrate() satırında duruyor:
InvalidOperationException: An error was generated for warning 'Microsoft.EntityFrameworkCore.Migrations.PendingModelChangesWarning': The model for context 'BookstoreContext' has pending changes. Add a new migration before updating the database. See https://aka.ms/efcore-docs-pending-changes. This exception can be suppressed or logged by passing event ID 'RelationalEventId.PendingModelChangesWarning' to the 'ConfigureWarnings' method in 'DbContext.OnConfiguring' or 'AddDbContext'.
Mesaj ne yapacağını da söylüyor: önce migration'ı ekle. Aynı kontrol dotnet ef database update için de geçerli. EF Core 8'de gelen has-pending-model-changes komutunu CI'a koyduysan bu hatayı zaten deploy'dan önce yakalıyorsun; koymadıysan EF Core 9 en geç uygulama açılırken yakalıyor.
Kimse Modeli Değiştirmediği Hâlde
Bu exception bazen hiçbir şeyi değiştirmediğin bir projede de çıkıyor. Kemik yapıdaki seeding yazısında HasData'nın sınırlarını konuşmuştuk; en sinsi olanı şu:
protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.Entity<Order>().HasData(new Order { Id = 1, CreatedAt = DateTime.Now });
Migration'ı ekledim, hemen ardından uygulamayı çalıştırdım:
InvalidOperationException: An error was generated for warning 'Microsoft.EntityFrameworkCore.Migrations.PendingModelChangesWarning': The model for context 'BookstoreContext' changes each time it is built. This is usually caused by dynamic values used in a 'HasData' call (e.g. `new DateTime()`, `Guid.NewGuid()`). Add a new migration and examine its contents to locate the cause, and replace the dynamic call with a static, hardcoded value. See https://aka.ms/efcore-docs-pending-changes. This exception can be suppressed or logged by passing event ID 'RelationalEventId.PendingModelChangesWarning' to the 'ConfigureWarnings' method in 'DbContext.OnConfiguring' or 'AddDbContext'.
HasData modelin bir parçası. DateTime.Now her çalıştırmada farklı olduğu için model her seferinde başka, migration snapshot'ı ile asla eşleşmiyor. EF Core 8.0.33 bunu da sessizce geçti: Migrate() "migrated" dedi, bir sonraki migrations add ise yalnız CreatedAt'i güncelleyen anlamsız bir UpdateData üretti. Her yeni migration'da aynısı tekrar ediyor. Çözüm, HasData'ya yalnız sabit değerler vermek. Dinamik veriye ihtiyacın varsa yeri HasData değil.
UseSeeding ve UseAsyncSeeding
EF Core 9 tam bu iş için resmî bir yer açıyor. Seeding kodu context ayarlarına yazılıyor ve EnsureCreated, Migrate ya da dotnet ef database update çalıştığında tetikleniyor:
protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options
.UseSqlite("Data Source=bookstore.db")
.UseSeeding((context, _) =>
{
if (!context.Set<Author>().Any())
{
Console.WriteLine("seeding...");
context.Set<Author>().Add(new Author
{
Name = "Ada",
Books = { new Book { Title = "EF Notes", PublishedYear = 2020 } }
});
context.SaveChanges();
}
})
.UseAsyncSeeding(async (context, _, cancellationToken) =>
{
if (!await context.Set<Author>().AnyAsync(cancellationToken))
{
Console.WriteLine("seeding async...");
context.Set<Author>().Add(new Author
{
Name = "Ada",
Books = { new Book { Title = "EF Notes", PublishedYear = 2020 } }
});
await context.SaveChangesAsync(cancellationToken);
}
});
Uygulamayı arka arkaya iki kez Migrate() ile, sonra boş bir veritabanında EnsureCreatedAsync() ile çalıştırdım:
seeding...
run 1: authors = 1
run 2: authors = 1
seeding async...
authors = 1
Üç şeye dikkat et. Birincisi, "zaten veri var mı" kontrolü senin işin. EF seeding'i her Migrate'te çağırıyor; kontrol olmasa ikinci çalıştırmada ikinci bir Ada olurdu. İkincisi, senkron method senkron callback'i, async method async callback'i çağırıyor. Dokümanın ikisini birden yazmanı önermesinin sebebi bu. Üçüncüsü, HasData'nın aksine burada normal entity'ler ve navigation'lar kullanıyorsun; key vermek zorunda değilsin, DateTime.Now da serbest.
Migration Locking
Uygulaman üç instance ile ayağa kalkıyorsa, üçü de aynı anda Migrate() çağırıyor demektir. EF Core 9 bunun için veritabanı seviyesinde bir kilit getiriyor. Yukarıdaki ilk çalıştırmanın logundan ilgili komutlar:
CREATE TABLE IF NOT EXISTS "__EFMigrationsLock" (
"Id" INTEGER NOT NULL CONSTRAINT "PK___EFMigrationsLock" PRIMARY KEY,
"Timestamp" TEXT NOT NULL
);
INSERT OR IGNORE INTO "__EFMigrationsLock"("Id", "Timestamp") VALUES(1, '2026-11-03 22:06:06.92746+00:00');
SELECT changes();
DELETE FROM "__EFMigrationsLock";
SQLite'ta kilit, tek satırlık bir tablo. Satırı ekleyebilen instance kilidi almış oluyor, migration'ları ve seeding'i çalıştırıyor, sonunda satırı siliyor. Diğerleri bu arada bekliyor. Logda seeding'in INSERT'leri de DELETE'ten önce görünüyor, yani seeding de kilidin içinde çalışıyor. SQL Server provider'ı aynı iş için tablo yerine sp_getapplock kullanıyor.
Bunun SQLite'taki zayıf noktası, süreç kilidi bırakamadan ölürse satırın tabloda kalması. Bunu elle satır ekleyerek denedim: Migrate() hata vermedi, sadece bekledi. İki dakika sonra süreci ben durdurdum. Çözüm, __EFMigrationsLock tablosundaki satırı elle silmek.
Compiled Model'ler Kendiliğinden Bulunuyor
Yüzlerce entity'li modellerde açılış süresini kısaltan compiled model, EF Core 6'dan beri var. Ama dotnet ef dbcontext optimize çalıştırdıktan sonra options.UseModel(...) satırını elle eklemek gerekiyordu. EF Core 9'da komutun çıktısı:
Build started...
Build succeeded.
Successfully generated a compiled model, it will be discovered automatically, but you can also call 'options.UseModel(BookstoreContextModel.Instance)'. Run this command again when the model is modified.
"Kendiliğinden bulunacak" kısmı, üretilen dosyalardan birinin içindeki bir assembly attribute'u: [assembly: DbContextModel(typeof(BookstoreContext), typeof(BookstoreContextModel))]. Context ile compiled model aynı assembly'de olduğu sürece UseModel yazmana gerek yok. db.Model.GetType() ile kontrol ettim: CompiledModels klasörü varken BookstoreContextModel, silince RuntimeModel döndü.
Bir adım daha var: modeli her değiştirdiğinde komutu yeniden çalıştırmak yerine build'e bağlamak. Bunun için Microsoft.EntityFrameworkCore.Tasks paketi ve iki MSBuild property'si gerekiyor:
<PropertyGroup>
<EFOptimizeContext>true</EFOptimizeContext>
<EFScaffoldModelStage>build</EFScaffoldModelStage>
</PropertyGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tasks" Version="9.0.22">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
dotnet build artık "Optimizing DbContext..." satırını basıyor ve compiled model'i obj altına BookstoreContextModel.g.cs gibi dosyalar olarak yazıyor. Build çıktısında bir de şu uyarıyı gördüm: "NativeAOT support is experimental and can change in the future." Projede NativeAOT kullanmasan da bu uyarı geliyor; uyarıyı basan MSBuild görevi, EF Core 9'un deneysel precompiled query desteğini de yürüten görevle aynı.
Sorgu Tarafındaki İyileştirmeler
EF Core 8'deki complex type'ları hatırla. ExecuteUpdate ile bir siparişin teslimat adresini topluca değiştirmeyi denediğimde EF Core 8.0.33 şunu söyledi:
InvalidOperationException: The LINQ expression 'DbSet<Order>()
.Where(o => o.ShippingAddress.City == "London")
.ExecuteUpdate(s => s.SetProperty<Address>(
propertyExpression: o => o.ShippingAddress,
valueExpression: __newAddress_0))' could not be translated. Additional information: The following lambda argument to 'SetProperty' does not represent a valid property to be set: 'o => o.ShippingAddress'. See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.
EF Core 9'da aynı kod çalışıyor ve complex type'ı kolonlarına açıyor:
var newAddress = new Address("10 New Rd", "London", "N2");
var count = db.Orders
.Where(o => o.ShippingAddress.City == "London")
.ExecuteUpdate(s => s.SetProperty(o => o.ShippingAddress, newAddress));
UPDATE "Orders" AS "o"
SET "ShippingAddress_City" = @__complex_type_newAddress_0_City,
"ShippingAddress_PostCode" = @__complex_type_newAddress_0_PostCode,
"ShippingAddress_Street" = @__complex_type_newAddress_0_Street
WHERE "o"."ShippingAddress_City" = 'London'
Complex type ile GroupBy da artık çalışıyor. Parametrelemeyi elle yönetmek için de iki araç var. EF Core 8.0.2'de gelen EF.Constant, normalde parametre olacak bir değeri SQL'e sabit olarak gömüyor; EF Core 9'un EF.Parameter'ı tersini yapıyor. EF Core 8 yazısında gördüğümüz json_each davranışını bütün context için kapatmak istersen:
options.UseSqlite("Data Source=q.db", o => o.TranslateParameterizedCollectionsToConstants());
Bu ayarla ids.Contains(o.Id) yeniden WHERE "o"."Id" IN (1, 2) olarak çıktı. SQL Server'da EF Core 8'deki UseCompatibilityLevel(120) hilesine gerek kalmadan aynı sonucu veriyor. Küçük bir ek de .NET 7 ile gelen Order() ve OrderDescending() operatörlerinin artık SQL'e çevrilmesi.
EF Core 9'a Geçiş
.NET 8 projesinde TFM'e dokunmadan paketleri yükseltmek yetiyor:
<TargetFramework>net8.0</TargetFramework>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.22" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.22">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
Dokümanın "High" olarak işaretlediği iki değişiklikten biri az önce gördüğümüz PendingModelChangesWarning. Diğeri, Migrate()'i elle açılmış bir transaction içinde çağırmak: kilit alınamadığı için doküman bunun exception fırlattığını söylüyor. Ben aynı kalıbı SQLite'ta EF Core 9.0.22 ile denediğimde exception almadım; MigrationsUserTransactionWarning loga warning olarak düştü ve migration'lar kilitsiz uygulandı. Davranış ne olursa olsun bu kalıbı bırak; transaction'ı EF zaten kendisi yönetiyor.
Doküman bir de .NET SDK 9.0.200 ile gelen bir sorundan bahsediyor: Design paketi yukarıdaki gibi PrivateAssets ile eklenmişse EF araçları onu bulamıyor. Ben 10.0.402 SDK ile bu sorunu yaşamadım, ama "Could not load file or assembly 'Microsoft.EntityFrameworkCore.Design'" hatası görürsen sebep bu. Bir de EF Core 9, bekleyen bütün migration'ları tek transaction'da uyguluyor; EF Core 10 bunu geri alıp her migration'ı yeniden kendi transaction'ına koydu.
Destek tarafında Microsoft STS sürümlerin desteğini 18 aydan 24 aya çıkardı ve EF Core 9 bundan yararlanan ilk sürüm oldu. Sonuç ilginç: EF Core 9'un desteği, LTS olan EF Core 8 ile aynı gün, 10 Kasım 2026'da bitiyor.
Sık Düşülen Tuzaklar
Birincisi, PendingModelChangesWarning'i susturmak. ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) tek satır ve cazip, ama yaptığın şey EF Core 8'in sessiz davranışına geri dönmek. Exception'ı gördüysen önce bir migration ekle ve içine bak; boş değilse unuttuğun bir şey var, sürekli değişiyorsa HasData'da dinamik bir değer var.
İkincisi, seeding callback'inde var olan veriyi kontrol etmemek. UseSeeding, "bir kez çalışır" değil, "her Migrate ve EnsureCreated'te çalışır" demek. Kontrol yoksa her deploy veriyi çoğaltır.
Üçüncüsü, yalnız birini yazmak. Uygulama MigrateAsync çağırıyor, sen yalnız UseSeeding yazdıysan seeding hiç çalışmaz. dotnet ef database update ise, denediğimde senkron olanı çağırdı.
Dördüncüsü, otomatik compiled model'i unutmak. Model değişip build alınmadan dotnet ef dbcontext optimize çıktısı eski kalırsa uygulama eski modeli kullanır. Elle ürettiysen her model değişikliğinden sonra komutu tekrar çalıştır ya da build entegrasyonunu kullan.
Küçük Bir Deneme
★☆☆ Kendi projende bir property ekle, migration eklemeden Migrate() çağır ve exception'ı gör. Sonra dotnet ef migrations has-pending-model-changes komutunu çalıştır ve çıktısını not et.
★★☆ UseSeeding ve UseAsyncSeeding'i yaz, ikisine farklı birer Console.WriteLine koy. Migrate, MigrateAsync, EnsureCreated ve dotnet ef database update ile ayrı ayrı çalıştırıp hangisinin hangi callback'i çağırdığını tablo olarak çıkar.
★★★ İki terminalde aynı anda Migrate() çağıran iki uygulama başlat; migration'lardan birinin içine Thread.Sleep eşdeğeri uzun bir SQL koy. __EFMigrationsLock tablosunu üçüncü bir terminalden izle ve ikinci instance'ın ne zaman devam ettiğini gözle.
EF Core 9'un desteği EF Core 8 ile aynı gün bitiyor. Bir sonraki yazıda ikisinin de varisi olan EF Core 10 LTS'e geçiyoruz: named query filter'lar, LeftJoin ve complex type'ların JSON'a eşlenmesi.