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

EF Core'da Seeding: HasData ile Model Seeding ve Uygulama Tarafında Seed

Mert Özen 5 Kas 2026 12 dk 1 görüntülenme
EF Core'da Seeding: HasData ile Model Seeding ve Uygulama Tarafında Seed

EF Core'da başlangıç verisini iki yoldan ekliyoruz: HasData ile model seeding'in InsertData/UpdateData/DeleteData üretmesi, key ve navigation kuralları, dinamik değer tuzağı; sonra uygulama tarafında tekrar çalıştırılabilir bir seeder ve EF Core 9'un UseSeeding'ine kısa bir işaret.

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: Id Vermezsen Ne Olur?

Kitapçı uygulamasının her ortamda hazır bulunması gereken birkaç kaydı var: başlangıç yazarları, birkaç kitap, sabit etiketler. EF Core'a bunları modelin bir parçası olarak verebilirsin. Şu satıra bak:

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

Id kolonu SQLite'ta AUTOINCREMENT. Normalde yeni bir Author eklerken Id vermezsin, veritabanı üretir. Peki ikinci satırdaki Id'siz kayıt ne olur? Veritabanı ona 2 mi verir?

Hayır, iş veritabanına hiç ulaşmaz. Model oluşturulurken EF Core 6 de EF Core 10 da aynı hatayı fırlatır:

Unhandled exception. System.InvalidOperationException: The seed entity for entity type 'Author' cannot be added because a non-zero value is required for property 'Id'. Consider providing a negative value to avoid collisions with non-seed data.

Sebebi HasData'nın doğasında. Bu veri bir INSERT komutu değil, modelin kendisi. EF Core seed kayıtlarını tıpkı tablolar ve kolonlar gibi model snapshot'ında saklar ve iki model arasındaki farkı primary key üzerinden hesaplar. Key'i olmayan bir kaydın "değişti mi, silindi mi" sorusuna cevap verilemez. Bu yazıda bu fikrin getirdiği rahatlığı ve sınırlarını göreceğiz, sonra aynı işi uygulama kodundan yapmanın yoluna bakacağız.

HasData ile Model Seeding

Model, önceki yazılardaki Bookstore modelinin kısa hâli: Author, Book ve aralarında many-to-many ilişki kurulmuş Tag.

using Microsoft.EntityFrameworkCore;

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 List<Tag> Tags { get; set; } = new();
}

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

Seed verisi OnModelCreating içinde, her entity için ayrı HasData çağrısıyla verilir:

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

    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseSqlite("Data Source=bookstore.db");

    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" });

        // İlişki navigation ile değil, foreign key değeriyle kurulur
        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 });

        modelBuilder.Entity<Tag>().HasData(
            new Tag { Id = 1, Name = "fantasy" },
            new Tag { Id = 2, Name = "science-fiction" });

        // Join tablosunun bir class'ı yok; anonymous type ile seed edilir
        modelBuilder.Entity<Book>()
            .HasMany(b => b.Tags)
            .WithMany(t => t.Books)
            .UsingEntity(j => j.HasData(
                new { BooksId = 1, TagsId = 1 },
                new { BooksId = 2, TagsId = 2 },
                new { BooksId = 3, TagsId = 2 }));
    }
}

Seed verisi iki yoldan veritabanına girer: migration'larla ya da EnsureCreated() ile. Hızlı bir deneme için ikincisi yeterli:

using Bookstore;
using Microsoft.EntityFrameworkCore;

using var db = new BookstoreContext();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();

foreach (var book in db.Books.Include(b => b.Author).Include(b => b.Tags).OrderBy(b => b.Id))
{
    Console.WriteLine($"{book.Title} - {book.Author.Name} [{string.Join(", ", book.Tags.Select(t => t.Name))}]");
}
A Wizard of Earthsea - Ursula K. Le Guin [fantasy]
The Left Hand of Darkness - Ursula K. Le Guin [science-fiction]
Foundation - Isaac Asimov [science-fiction]

Hiç Add ya da SaveChanges çağırmadık. Tablolar oluşturulurken seed satırları da onlarla birlikte geldi.

Seed Verisi Migration'a Nasıl Girer?

Aynı modelle dotnet ef migrations add InitialCreate çalıştırınca Up method'unda tabloları oluşturan komutların arasına InsertData çağrıları da eklenir. EF Core 6'nın ürettiği koddan bir parça:

migrationBuilder.InsertData(
    table: "Authors",
    columns: new[] { "Id", "Name" },
    values: new object[] { 1, "Ursula K. Le Guin" });

migrationBuilder.InsertData(
    table: "Books",
    columns: new[] { "Id", "AuthorId", "Price", "Title" },
    values: new object[] { 3, 2, 11.00m, "Foundation" });

migrationBuilder.InsertData(
    table: "BookTag",
    columns: new[] { "BooksId", "TagsId" },
    values: new object[] { 1, 1 });

EF Core 6 her satır için ayrı bir InsertData yazıyor. EF Core 10 aynı tablonun satırlarını tek çağrıda, values: new object[,] { ... } şeklinde iki boyutlu bir array ile topluyor; sonuç aynı. Değerlerin migration dosyasının içine sabit olarak yazıldığına dikkat et. Migration bir kez üretildikten sonra seed kodunla bağı kopar; o migration her zaman o anki değerleri ekler.

Seed Verisini Değiştirmek: UpdateData ve DeleteData

Şimdi model seeding'in güçlü tarafını görelim. Foundation'ın fiyatını 9.99 yap ve The Left Hand of Darkness ile science-fiction etiketi arasındaki bağı (new { BooksId = 2, TagsId = 2 } satırı) sil. Sonra yeni bir migration ekle:

dotnet ef migrations add UpdateSeedData
protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.DeleteData(
        table: "BookTag",
        keyColumns: new[] { "BooksId", "TagsId" },
        keyValues: new object[] { 2, 2 });

    migrationBuilder.UpdateData(
        table: "Books",
        keyColumn: "Id",
        keyValue: 3,
        column: "Price",
        value: 9.99m);
}

EF Core snapshot'taki eski seed listesini yenisiyle key'ler üzerinden karşılaştırdı. Key'i aynı kalan satırda yalnızca değişen kolonu güncelledi, listeden çıkan satırı sildi. Bu migration'ın SQLite script'i de beklediğin gibi:

DELETE FROM "BookTag"
WHERE "BooksId" = 2 AND "TagsId" = 2;
SELECT changes();

UPDATE "Books" SET "Price" = '9.99'
WHERE "Id" = 3;
SELECT changes();

Price'ın '9.99' olarak tırnak içinde yazılmasının sebebi SQLite provider'ının decimal'ı TEXT kolonda saklaması. Burada bir şeyi açıkça söylemek gerekiyor: seed satırları artık migration'ların malı. Biri canlıda Foundation'ın fiyatını elle 10.50 yaptıysa bir sonraki seed değişikliği o kolonu senin değerinle ezer. DeleteData da satırın veritabanında hâlâ referans alınıp alınmadığına bakmaz.

İlişkilerde HasData Kuralı

HasData bir nesne grafı kabul etmez. Yazarı kitabın içinde, navigation property üzerinden vermeye çalışırsan:

new Book { Id = 3, Title = "Foundation", Price = 11.00m, Author = new Author { Id = 2, Name = "Isaac Asimov" } }
Unhandled exception. System.InvalidOperationException: The seed entity for entity type 'Book' cannot be added because it has the navigation 'Author' set. To seed relationships,  add the entity seed to 'Book' and specify the foreign key values {'AuthorId'}. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see the involved property values.

Mesaj çözümü de söylüyor: her entity kendi HasData çağrısında durur, ilişkiler foreign key değerleriyle kurulur. Many-to-many'de ise ortada bir join entity class'ı yok; EF Core join tablosunu BookTag adıyla, BooksId ve TagsId kolonlarıyla kendisi oluşturuyor. Bu yüzden yukarıda UsingEntity(j => j.HasData(...)) içinde bu kolon adlarını taşıyan anonymous type'lar kullandık. Kolon adlarını yanlış yazarsan EF Core yine model oluşturulurken itiraz eder. Join entity'yi kendin tanımladıysan (önceki yazıdaki payload'lı join entity gibi) onun için de normal bir HasData yazarsın.

HasData'nın Sınırları

Model seeding'in kuralları tek bir cümleden çıkıyor: seed verisi modelin parçasıdır. Bunun sonuçlarını sıralayalım.

Key zorunlu. Veritabanı tarafından üretilen key'ler için bile değeri sen verirsin. Hata mesajındaki negatif değer önerisi, uygulamanın normal yoldan eklediği kayıtlarla çakışmayı önlemek için. SQLite'ta AUTOINCREMENT en büyük değerin üstünden devam ettiği için 1, 2, 3 vermek sorun çıkarmadı; ama başka bir provider'da ya da elle eklenen verilerle çakışma ihtimalini düşünmek gerekir.

Değerler sabit olmalı. Model her oluşturulduğunda aynı sonucu vermeli; DateTime.UtcNow ya da Guid.NewGuid() gibi her çağrıda değişen bir değer her migration'ı kirletir (tuzaklar bölümünde göreceğiz).

Veritabanına soru soramazsın. HasData model oluşturulurken çalışır; o anda bir connection yok. "Önce şu yazar var mı bak, yoksa ekle" türünden bir mantık kuramazsın.

Büyük veri için değil. Her seed satırı snapshot dosyasına ve migration'a C# kodu olarak yazılır. Birkaç düzine satır sorun değil; on bin satırlık bir şehir listesi derleme sürelerini ve dosya boyutlarını şişirir.

Ortama göre değişmez. Seed verisi her veritabanına aynen gider; "geliştirmede yüz test siparişi olsun, canlıda olmasın" gibi bir ayrım yapamazsın.

Sonuç: HasData hiç değişmeyen ya da çok seyrek değişen referans verisi için ideal. Etiketler, ülke kodları, sipariş durumları gibi.

Uygulama Tarafında Seeding

Geri kalan her şey için en esnek yol, seed işini sıradan bir uygulama kodu olarak yazmak. Bu kez model HasData içermiyor:

using Microsoft.EntityFrameworkCore;

namespace Bookstore;

public static class BookstoreSeeder
{
    public static async Task SeedAsync(BookstoreContext db)
    {
        if (await db.Authors.AnyAsync())
        {
            Console.WriteLine("Seed skipped: data already exists.");
            return;
        }

        var fantasy = new Tag { Name = "fantasy" };
        var sciFi = new Tag { Name = "science-fiction" };

        // Key vermiyoruz; veritabanı üretiyor ve navigation'larla graf kurabiliyoruz
        db.Authors.AddRange(
            new Author
            {
                Name = "Ursula K. Le Guin",
                Books =
                {
                    new Book { Title = "A Wizard of Earthsea", Price = 12.50m, Tags = { fantasy } },
                    new Book { Title = "The Left Hand of Darkness", Price = 14.00m, Tags = { sciFi } }
                }
            },
            new Author
            {
                Name = "Isaac Asimov",
                Books = { new Book { Title = "Foundation", Price = 11.00m, Tags = { sciFi } } }
            });

        var count = await db.SaveChangesAsync();
        Console.WriteLine($"Seed inserted {count} rows.");
    }
}

Uygulama açılırken önce migration'lar, sonra seed çalışır:

using Bookstore;
using Microsoft.EntityFrameworkCore;

await using var db = new BookstoreContext();
await db.Database.MigrateAsync();
await BookstoreSeeder.SeedAsync(db);

var books = await db.Books.OrderBy(b => b.Id).Select(b => new { b.Id, b.Title, Author = b.Author.Name }).ToListAsync();
foreach (var b in books)
{
    Console.WriteLine($"{b.Id}: {b.Title} ({b.Author})");
}

İlk ve ikinci çalıştırma:

Seed inserted 10 rows.
1: A Wizard of Earthsea (Ursula K. Le Guin)
2: The Left Hand of Darkness (Ursula K. Le Guin)
3: Foundation (Isaac Asimov)
Seed skipped: data already exists.
1: A Wizard of Earthsea (Ursula K. Le Guin)
2: The Left Hand of Darkness (Ursula K. Le Guin)
3: Foundation (Isaac Asimov)

10 satır: iki yazar, üç kitap, iki etiket ve join tablosundaki üç bağ. HasData'da yapamadığımız her şey burada serbest. Key'leri veritabanı üretti, ilişkileri navigation'larla kurduk, istersek dosyadan ya da bir API'den veri okuyabiliriz. if (env.IsDevelopment()) ile seed'i ortama göre de ayırabilirsin.

Bedeli de var. Bu kod migration script'lerinde ya da bundle'da yer almaz; seed'i uygulama çalıştırır. Önceki yazıda konuştuğumuz "açılışta migrate" riskleri burada da geçerli: üç instance aynı anda açılırsa üçü de AnyAsync()'ten false alıp aynı veriyi üç kez ekleyebilir.

Hangisini Ne Zaman?

Kısa bir karar listesi:

Veri sabit, küçük ve her ortamda aynıysa HasData kullan. Değişiklikleri migration geçmişinde izlenir, script'lerle canlıya gider, DBA da ne eklendiğini SQL'de görür.

Veri büyükse, key'leri veritabanı üretmeliyse, ortama göre değişiyorsa ya da başka bir kaynaktan geliyorsa uygulama tarafında seed et. Kontrolü (AnyAsync() gibi) mutlaka koy ki seed tekrar tekrar çalıştırılabilsin.

EF Core 9, bu ikinci yolu resmîleştiren UseSeeding ve UseAsyncSeeding method'larını getirdi. Seed kodun DbContextOptionsBuilder üzerinde tanımlanıyor; EnsureCreated, Migrate ve dotnet ef database update sırasında kendiliğinden çalışıyor. Bunu EF Core 9 yazısında göreceğiz; o zamana kadar yukarıdaki seeder kalıbı aynı işi görüyor.

Sık Düşülen Tuzaklar

En sinsi tuzak HasData içinde dinamik bir değer kullanmak. Author'a bir CreatedAt property'si ekleyip seed'de CreatedAt = DateTime.UtcNow yazdığını düşün. İlk migration'ı üretip uyguladıktan sonra modelde hiçbir şey değiştirmeden dotnet ef migrations add Nothing çalıştırınca EF Core 6 şu "boş" migration'ı üretiyor:

migrationBuilder.UpdateData(
    table: "Authors",
    keyColumn: "Id",
    keyValue: 1,
    column: "CreatedAt",
    value: new DateTime(2026, 11, 3, 21, 53, 37, 675, DateTimeKind.Utc).AddTicks(4360));

Her model oluşturulduğunda değer değiştiği için EF Core her seferinde fark görür. EF Core 6 bunu sessizce kabul eder, sen de her migration'da anlamsız UpdateData satırları taşırsın. Aynı kodla EF Core 10'da dotnet ef database update daha ilk seferde duruyor:

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'.

Çözüm her iki sürümde aynı: new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc) gibi sabit bir değer yaz.

İkinci tuzak, seed'deki key'i değiştirmek. EF Core key'i değişen satırı "eski satır silindi, yeni satır eklendi" diye yorumlar ve DeleteData ile InsertData üretir. O satırı referans alan başka kayıtlar varsa cascade delete devreye girer ya da foreign key hatası alırsın.

Üçüncüsü, HasData ile uygulama tarafı seed'i aynı tabloda karıştırmak. Seeder'daki if (await db.Authors.AnyAsync()) kontrolü HasData ile gelen iki yazarı görür ve seed'i hiç çalıştırmaz. Kontrolü "tabloda herhangi bir kayıt var mı" yerine aradığın belirli bir kaydın varlığına göre yaz ya da iki yöntemi farklı tablolar için kullan.

Son olarak, test verisini HasData'ya koymak. Test siparişleri bir kez migration'a girdi mi, canlıya da gider. Test verisi testlerin kendi kurulumunda durmalı; test konusunu ayrı bir yazıda ele alacağız.

Küçük Bir Deneme

★☆☆ Modele HasData ile üçüncü bir etiket ekle (Id = 3, Name = "classic") ve Foundation'ı bu etiketle de bağla. Yeni bir migration üret ve Up method'unda tam olarak hangi InsertData çağrılarının oluştuğunu kontrol et.

★★☆ CreatedAt = DateTime.UtcNow tuzağını kendin üret. Arka arkaya iki migrations add çalıştırıp ikincisinin içeriğine bak, sonra değeri sabitleyip aynı denemeyi tekrarla ve ikinci migration'ın boş kaldığını gör.

★★★ BookstoreSeeder'ı tekrar çalıştırılabilir hâle getir: "herhangi bir yazar var mı" yerine her yazarı adına göre kontrol et, eksik olanı ekle, var olana dokunma. Uygulama çalışırken veritabanından bir yazarı silip uygulamayı yeniden başlat ve yalnızca o yazarın geri geldiğini doğrula.

Artık tablolarımızda veri var. Sıradaki soru o veriyi nasıl okuyacağımız: yazdığın LINQ ifadesinin hangi kısmı SQL'e çevrilir, hangi kısmı bellekte çalışır ve IQueryable ile IEnumerable arasındaki fark neden sorgunun bütün tabloyu çekmesine yol açabilir? Bir sonraki yazıda LINQ ile sorgulamaya geçiyoruz.