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

EF Core'da İlişkiler 1: One-to-Many, Foreign Key ve Cascade Delete

Mert Özen 5 Kas 2026 10 dk 1 görüntülenme
EF Core'da İlişkiler 1: One-to-Many, Foreign Key ve Cascade Delete

Author ve Book üzerinden one-to-many ilişkiyi kuruyoruz: principal, dependent ve foreign key, convention'ın ilişkiyi nasıl bulduğu, Fluent API ile HasOne/WithMany, graf ekleme ve fixup, zorunlu ve opsiyonel ilişki, Cascade, ClientSetNull ve Restrict davranışlarının yüklü ve yüklü olmayan dependent'larla gerçek SQLite çıktıları.

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

Bir yazarın tek bir kitabı var. Yazarı kitaplarıyla birlikte yükleyip siliyoruz:

Author beck = db.Authors.Include(a => a.Books).Single(a => a.Name == "Kent Beck");
db.Authors.Remove(beck);
db.SaveChanges();

Bir başka yazarı ise kitaplarını yüklemeden siliyoruz:

Author fowler = db.Authors.Single(a => a.Name == "Martin Fowler");
db.Authors.Remove(fowler);
db.SaveChanges();

İki durumda da SaveChanges veritabanına kaç DELETE gönderir? İkinci durumda EF Core'un haberi olmayan kitaplara ne olur: silinirler mi, yetim mi kalırlar, yoksa işlem hata mı verir? Cevap, ilişkiyi nasıl tanımladığına bağlı. Bu yazının sonunda üç farklı cevabı da gerçek çıktısıyla görmüş olacağız.

Author ve Book: Modeli Kurmak

Bir yazarın birden çok kitabı olabilir, her kitabın bir yazarı vardır. Bu one-to-many ilişki. Bookstore modelinde şöyle:

namespace Bookstore;

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

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

    public int AuthorId { get; set; }
    public Author Author { get; set; } = null!;
}

public class Order
{
    public int Id { get; set; }
    public DateTime OrderedAt { get; set; }
    public List<OrderItem> Items { get; set; } = new();
}

public class OrderItem
{
    public int Id { get; set; }
    public int Quantity { get; set; }

    public int OrderId { get; set; }
    public Order Order { get; set; } = null!;

    public int BookId { get; set; }
    public Book Book { get; set; } = null!;
}

Terimleri bir kez netleştirelim. Author principal, yani ilişkinin "sahibi". Book dependent; ilişkiyi kendi tablosunda bir kolonla taşıyan taraf. O kolon foreign key: AuthorId. Book.Author reference navigation, Author.Books ise collection navigation. Navigation'lar C# tarafında object'ten object'e yürümeni sağlar, foreign key ise veritabanında ilişkiyi gerçekten tutan şeydir.

Author Author { get; set; } = null!; satırı NRT ile yaşamanın bir sonucu. Kitabın yazarı her zaman var, o yüzden tipi Author? değil. Ama değeri constructor'da değil, EF Core yüklediğinde ya da sen atadığında gelir. null! derleyiciye "bunu ben garanti ediyorum" demenin yolu. Collection navigation'ı ise new() ile başlatıyoruz ki yeni bir yazara kitap eklerken null kontrolü gerekmesin.

Convention İlişkiyi Nasıl Buluyor?

Bu ilişkiyi kurmak için aslında tek satır yazmana gerek yok. EF Core Author.Books ile Book.Author'ı görür, birinin diğerinin tersi olduğunu anlar ve ikisini tek bir ilişki olarak eşler. Foreign key için de <NavigationName>Id ya da <PrincipalName>Id kalıbına uyan property'yi arar; AuthorId ikisine de uyuyor. Bulamasaydı, bir önceki yazılarda gördüğümüz gibi bir shadow property eklerdi.

EnsureCreated sonrası şema:

CREATE TABLE "Books" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Books" PRIMARY KEY AUTOINCREMENT,
    "Title" TEXT NOT NULL,
    "Price" TEXT NOT NULL,
    "AuthorId" INTEGER NOT NULL,
    CONSTRAINT "FK_Books_Authors_AuthorId" FOREIGN KEY ("AuthorId") REFERENCES "Authors" ("Id") ON DELETE CASCADE
);
CREATE TABLE "OrderItem" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_OrderItem" PRIMARY KEY AUTOINCREMENT,
    "Quantity" INTEGER NOT NULL,
    "OrderId" INTEGER NOT NULL,
    "BookId" INTEGER NOT NULL,
    CONSTRAINT "FK_OrderItem_Books_BookId" FOREIGN KEY ("BookId") REFERENCES "Books" ("Id") ON DELETE RESTRICT,
    CONSTRAINT "FK_OrderItem_Orders_OrderId" FOREIGN KEY ("OrderId") REFERENCES "Orders" ("Id") ON DELETE CASCADE
);
CREATE INDEX "IX_Books_AuthorId" ON "Books" ("AuthorId");
CREATE INDEX "IX_OrderItem_BookId" ON "OrderItem" ("BookId");
CREATE INDEX "IX_OrderItem_OrderId" ON "OrderItem" ("OrderId");

İki şey dikkat çekiyor. Her foreign key kolonuna EF Core kendiliğinden bir index koydu, çünkü ilişki üzerinden yapılan sorgular ve silmeler bu kolonu sürekli arar. AuthorId int olduğu, yani null olamadığı için ilişki zorunlu kabul edildi ve ON DELETE CASCADE aldı. OrderItem → Book ilişkisindeki RESTRICT ise bizim kararımız; şimdi ona bakalım.

Fluent API ile Açık Yazmak

Convention'ın bulduğunu açıkça yazmak, ilişkinin davranışını değiştirmek istediğinde şart; istemediğinde de okuyana niyetini gösterir:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Book>()
        .HasOne(b => b.Author)
        .WithMany(a => a.Books)
        .HasForeignKey(b => b.AuthorId)
        .OnDelete(DeleteBehavior.Cascade);

    modelBuilder.Entity<OrderItem>()
        .HasOne(i => i.Book)
        .WithMany()
        .HasForeignKey(i => i.BookId)
        .OnDelete(DeleteBehavior.Restrict);
}

Okuması doğal bir cümle gibi: "Book'un bir Author'u var, Author'un birçok Book'u var, foreign key AuthorId, yazar silinince kitapları da silinsin." İkinci ilişkide WithMany() parametresiz, çünkü Book üzerinde sipariş kalemlerine giden bir navigation tutmak istemiyoruz. Navigation'ın sadece bir tarafta olması tamamen geçerli.

Restrict bir iş kuralını yansıtıyor: sipariş edilmiş bir kitap katalogdan silinemesin. Siparişin geçmişi, ürünün silinmesinden daha değerli.

Graf Eklemek ve Fixup

İlişkili veriyi eklemek için foreign key'lerle uğraşmana gerek yok. Object'leri birbirine bağla, kökü ekle:

Author fowler = new() { Name = "Martin Fowler" };
fowler.Books.Add(new Book { Title = "Refactoring", Price = 42.50m });
fowler.Books.Add(new Book { Title = "Patterns of Enterprise Application Architecture", Price = 49.00m });

db.Authors.Add(fowler);
db.SaveChanges();

Console.WriteLine($"Author Id: {fowler.Id}");
foreach (Book book in fowler.Books)
{
    Console.WriteLine($"{book.Title} -> AuthorId {book.AuthorId}, Author {book.Author.Name}");
}
      INSERT INTO "Authors" ("Name")
      VALUES (@p0);
      SELECT "Id"
      FROM "Authors"
      WHERE changes() = 1 AND "rowid" = last_insert_rowid();
      INSERT INTO "Books" ("AuthorId", "Price", "Title")
      VALUES (@p1, @p2, @p3);
      SELECT "Id"
      FROM "Books"
      WHERE changes() = 1 AND "rowid" = last_insert_rowid();
      INSERT INTO "Books" ("AuthorId", "Price", "Title")
      VALUES (@p0, @p1, @p2);
      SELECT "Id"
      FROM "Books"
      WHERE changes() = 1 AND "rowid" = last_insert_rowid();
Author Id: 1
Refactoring -> AuthorId 1, Author Martin Fowler
Patterns of Enterprise Application Architecture -> AuthorId 1, Author Martin Fowler

Add yalnızca yazarı eklemedi; navigation'ları takip ederek bütün grafı Added olarak işaretledi. EF Core sırayı da kendisi belirledi: önce principal (yazarın Id'si lazım), sonra dependent'lar. Yazarın veritabanından dönen Id'sini kitapların AuthorId'sine yazdı ve book.Author'ı doldurdu. Biz hiçbir foreign key'e değer vermedik.

Ters yön de çalışır. Yazar zaten veritabanındaysa, onu yüklemeden yalnızca foreign key'i vererek kitap ekleyebilirsin: new Book { Title = "Test-Driven Development", Price = 35.00m, AuthorId = 2 }. Bunun için foreign key'i class'ta açıkça tutmak iyi bir fikir; shadow property ile bunu bu kadar rahat yapamazdın.

EF Core aynı context'te izlediği entity'leri birbirine kendisi bağlar. Buna fixup denir:

Author author = db.Authors.Single(a => a.Name == "Kent Beck");
Console.WriteLine($"Books before: {author.Books.Count}");
List<Book> books = db.Books.Where(b => b.AuthorId == author.Id).ToList();
Console.WriteLine($"Books after: {author.Books.Count}");
Books before: 0
Books after: 1

author.Books'a hiç dokunmadık. Kitaplar ayrı bir sorguyla geldi, change tracker AuthorId'leri izlediği yazarla eşleştirdi ve koleksiyonu doldurdu. İlişkili veriyi yüklemenin düzgün yolları (Include, explicit ve lazy loading) ilerideki bir yazının konusu; fixup ise hepsinin altındaki mekanizma.

Required mı Optional mı?

İlişkinin zorunlu olup olmadığını foreign key'in tipi belirler. int AuthorId null olamaz, demek ki her kitabın bir yazarı olmak zorunda. Yazarı bilinmeyen kitapları da tutmak istersen foreign key'i ve navigation'ı nullable yaparsın:

public int? AuthorId { get; set; }
public Author? Author { get; set; }

Fluent API'den OnDelete satırını çıkarıp convention'a bıraktığımızda şema değişiyor:

CREATE TABLE "Books" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Books" PRIMARY KEY AUTOINCREMENT,
    "Title" TEXT NOT NULL,
    "Price" TEXT NOT NULL,
    "AuthorId" INTEGER NULL,
    CONSTRAINT "FK_Books_Authors_AuthorId" FOREIGN KEY ("AuthorId") REFERENCES "Authors" ("Id")
);

Kolon NULL kabul ediyor ve ON DELETE kısmı yok. Convention'ın varsayılanları şöyle: zorunlu ilişkide DeleteBehavior.Cascade, opsiyonel ilişkide DeleteBehavior.ClientSetNull.

Cascade Delete Davranışları

Principal silindiğinde dependent'lara ne olacağını iki ayrı yer belirler: EF Core'un change tracker'da izlediği entity'lere yaptığı ve veritabanının foreign key constraint'ine yazılan kural. DeleteBehavior ikisini birlikte ayarlar.

Cascade: EF Core izlediği dependent'ları da siler, veritabanında ON DELETE CASCADE olur. ClientSetNull: EF Core izlediği dependent'ların foreign key'ini null yapar, veritabanına ise bir kural yazmaz. SetNull: aynısını yapar ama veritabanına da ON DELETE SET NULL yazar. Restrict: EF Core izlenen dependent'lara dokunmaz, veritabanında ON DELETE RESTRICT olur.

Bunların yanında NoAction, ClientCascade ve ClientNoAction da var. Özellikle SQL Server'ın izin vermediği cascade yollarında, silme işini veritabanına değil EF Core'a bırakmak istediğinde işe yararlar. Kitapçı için ilk dördü yeterli.

Yüklü ve Yüklü Olmayan Dependent'lar

Tahmin sorusuna dönelim. Zorunlu ilişki, Cascade, yazar kitabıyla birlikte yüklü:

Book state: Deleted
      DELETE FROM "Books"
      WHERE "Id" = @p0;
      SELECT changes();
      DELETE FROM "Authors"
      WHERE "Id" = @p1;
      SELECT changes();

Remove çağrıldığı anda kitap da Deleted oldu. EF Core iki DELETE gönderdi, önce dependent'ı, sonra principal'ı. Kitaplar yüklü değilken ise yalnızca tek bir DELETE FROM "Authors" gitti; sonrasında db.Books.Count() 0 döndü. Kitapları veritabanının ON DELETE CASCADE kuralı sildi, EF Core bunu hiç görmedi.

Opsiyonel ilişki ve ClientSetNull ile aynı iki deneme farklı sonuç veriyor. Kitap yüklüyken EF Core önce kitabın foreign key'ini null'a çekiyor:

      UPDATE "Books" SET "AuthorId" = @p0
      WHERE "Id" = @p1;
      SELECT changes();
      DELETE FROM "Authors"
      WHERE "Id" = @p2;
      SELECT changes();
AuthorId after delete: null

Kitaplar yüklü değilken ise EF Core'un null'a çekebileceği bir şey yok, veritabanında da kural yok. Sonuç:

SQLite Error 19: 'FOREIGN KEY constraint failed'.

ClientSetNull adındaki "client" kelimesi tam olarak bunu söylüyor: davranış yalnızca uygulamanın gördüğü entity'ler için geçerli.

Üçüncü cevap Restrict. Sipariş edilmiş bir kitabı silmeye çalıştığımızda, sipariş kalemleri yüklü değilse hata veritabanından geliyor: yine FOREIGN KEY constraint failed. Kalemler aynı context'te izleniyorsa EF Core daha SaveChanges'e gelmeden, Remove satırında durduruyor:

The association between entity types 'Book' and 'OrderItem' has been severed, but the relationship is either marked as required or is implicitly required because the foreign key is not nullable. If the dependent/child entity should be deleted when a required relationship is severed, configure the relationship to use cascade deletes. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see the key values.

Sık Düşülen Tuzaklar

Tek bir SaveChanges'te sıralamayı EF Core'a emanet etmek. Siparişi ve yazarı aynı SaveChanges'te sildiğimizde (kalemler ve kitaplar yüklü değilken) FOREIGN KEY constraint failed aldık. EF Core yalnızca izlediği entity'ler arasındaki bağımlılığa göre sıralama yapar. Yazar silinince veritabanı kitapları cascade ile siliyor, o kitaplara hâlâ bağlı sipariş kalemleri ise RESTRICT'e takılıyordu. Siparişi önce ayrı bir SaveChanges ile silmek sorunu çözdü.

Restrict hatasını yanlış yerde yakalamak. Dependent'lar izleniyorsa exception SaveChanges'te değil Remove'da fırlar; try bloğun sadece SaveChanges'i sarıyorsa bu hatayı kaçırırsın.

Foreign key ile navigation'ı birbiriyle çelişecek şekilde ayarlamak. Hem book.AuthorId = 2 hem book.Author = someOtherAuthor dersen hangisinin kazanacağını düşünmek zorunda kalırsın. Birini seç ve onunla devam et.

Cascade'i her yerde açık bırakmak. Zorunlu ilişkinin varsayılanı cascade olduğu için tek bir silme, beklemediğin kadar çok tabloya yayılabilir. Silinmesi iş kuralı açısından sorun olan her ilişkide Restrict ya da soft delete düşün; soft delete'i global query filter yazısında göreceğiz.

Küçük Bir Deneme

★☆☆ Book'taki AuthorId property'sini ve Fluent API'deki HasForeignKey satırını sil, navigation'ı bırak. Şemadaki kolon adı ve tipi nasıl kaldı? Kitap eklerken artık neyi kullanmak zorundasın?

★★☆ Opsiyonel ilişkiyi OnDelete(DeleteBehavior.SetNull) ile yapılandır. Şemadaki foreign key satırının değiştiğini gör, sonra yazarı kitaplarını yüklemeden sil. Bu sefer hata aldın mı? AuthorId ne oldu?

★★★ Order ile OrderItem arasındaki ilişki şu an convention'dan geliyor. Bir sipariş silindiğinde kalemlerinin de silindiğini iki yoldan doğrula: kalemler yüklüyken log'daki DELETE'lerle, yüklü değilken de şemadaki kuralla.

One-to-many, ilişkilerin en yaygını ama tek türü değil. Her yazarın tek bir profil sayfası olması one-to-one, bir kitabın birden fazla etiketi ve bir etiketin birden fazla kitabı olması many-to-many. Bir sonraki yazıda bu ikisine bakacağız: dependent tarafın nasıl belirlendiği, skip navigation'lar, join tablosu ve payload taşıyan join entity'ler için UsingEntity.